# Stripe Integration (/en/docs/billing-stripe-payment)



A hands-on Stripe integration guide for Realm Admins. Follow it end to end and your users will be able to complete subscription payments and credit-pack purchases through Stripe.

## Who this is for [#who-this-is-for]

Realm Admins who manage Herald's billing configuration. The whole flow happens in the admin console — no code required.

## Prerequisites [#prerequisites]

* The Herald admin console is accessible and you have an admin account
* You have created at least one Realm and one Client App
* You have a Stripe account. If not, register at [stripe.com](https://stripe.com)

## Core concepts [#core-concepts]

Herald does not maintain a local product catalog. You create Products and Prices in the Stripe Dashboard, then configure an Entitlement Mapping in Herald telling the system "Stripe's `prod_xxxx` corresponds to Herald's `pro-plan`." After the user pays, Stripe notifies Herald through a Webhook; Herald uses the `herald_entitlement_key` in the metadata to find the mapping, creates a subscription projection, and grants credits.

Data flow: Stripe Product → Herald Entitlement Mapping → user Checkout → Stripe Webhook (carrying `herald_*` metadata) → Herald subscription projection + credit grant

***

## Step 1: Configure the Stripe connection [#step-1-configure-the-stripe-connection]

Fill in the Stripe credentials in the Herald admin console.

1. Find **Payment Providers** in the left menu and click in
2. Find **Stripe**, click **Configure**
3. Fill the form:
   * **Publishable Key** (required): starts with `pk_`. Get it from Stripe Dashboard → Developers → API Keys. Use a `pk_test_` key for the test environment
   * **Secret Key** (required): starts with `sk_`. Use an `sk_test_` key for the test environment
   * **Webhook Secret** (required): starts with `whsec_`. Obtained in the next step
4. Click **Save**

Saving valid credentials enables Stripe — there is no separate enable toggle. Herald detects the environment from the API key prefix: `sk_test_` goes to test, `sk_live_` goes to production.

## Step 2: Configure the Stripe Webhook [#step-2-configure-the-stripe-webhook]

Stripe pushes payment results to Herald through Webhooks. This is the most critical step of the whole integration.

### Create the Webhook endpoint [#create-the-webhook-endpoint]

1. Open [Stripe Dashboard → Developers → Webhooks](https://dashboard.stripe.com/webhooks)
2. Confirm you are in test mode (top-right toggle); configure the test environment first during development
3. Click **Add endpoint**
4. Fill in:
   * **Endpoint URL**: `https://your-herald-domain/api/third/pay/{realmId}/stripe/webhooks`
     * Replace `{realmId}` with your realm ID, e.g. `admin`
     * For local development, expose Herald with a tool like ngrok
   * **Listen to**: choose **Events** (not Account events)

### Select Webhook events [#select-webhook-events]

Click **Select events** and check the events below. Missing any one of them breaks the corresponding payment flow.

### Checkout events [#checkout-events]

| Event                                      | Stripe path                                           | Herald handling                                                            |
| ------------------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------- |
| `checkout.session.completed`               | Checkout → checkout.session.completed                 | Complete checkout; create a subscription or finish a one-time payment      |
| `checkout.session.expired`                 | Checkout → checkout.session.expired                   | Checkout session expired; mark the payment attempt failed                  |
| `checkout.session.async_payment_succeeded` | Checkout → checkout.session.async\_payment\_succeeded | Async payment (e.g. bank transfer) succeeded; complete the payment attempt |
| `checkout.session.async_payment_failed`    | Checkout → checkout.session.async\_payment\_failed    | Async payment failed; mark the payment attempt failed                      |

### Subscription events [#subscription-events]

| Event                           | Stripe path                               | Herald handling                                                              |
| ------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------- |
| `customer.subscription.created` | Customers → customer.subscription.created | New subscription created; sync the subscription projection and grant credits |
| `customer.subscription.updated` | Customers → customer.subscription.updated | Handle upgrade/downgrade                                                     |
| `customer.subscription.paused`  | Customers → customer.subscription.paused  | Subscription paused; sync state                                              |
| `customer.subscription.resumed` | Customers → customer.subscription.resumed | Subscription resumed; sync state                                             |
| `customer.subscription.deleted` | Customers → customer.subscription.deleted | Subscription canceled; claw back credits                                     |

### Payment and refund events [#payment-and-refund-events]

| Event                           | Stripe path                                | Herald handling                                                |
| ------------------------------- | ------------------------------------------ | -------------------------------------------------------------- |
| `payment_intent.succeeded`      | Payments → payment\_intent.succeeded       | One-time payment (credit-pack purchase) succeeded              |
| `payment_intent.payment_failed` | Payments → payment\_intent.payment\_failed | Payment failed; mark the payment attempt                       |
| `charge.refunded`               | Payments → charge.refunded                 | Refund; claw back credits proportionally                       |
| `charge.dispute.created`        | Payments → charge.dispute.created          | Dispute opened; mark the subscription as disputed              |
| `charge.dispute.closed`         | Payments → charge.dispute.closed           | Dispute closed: lost cancels the subscription, won restores it |

### Billing and invoice events [#billing-and-invoice-events]

| Event                             | Stripe path                                 | Herald handling                                                      |
| --------------------------------- | ------------------------------------------- | -------------------------------------------------------------------- |
| `invoice.payment_succeeded`       | Billing → invoice.payment\_succeeded        | Renewal succeeded; grant renewal credits and sync subscription state |
| `invoice.payment_failed`          | Billing → invoice.payment\_failed           | Invoice payment failed; mark the payment attempt                     |
| `invoice.payment_action_required` | Billing → invoice.payment\_action\_required | Customer action required (e.g. 3D Secure); log                       |
| `invoice.created`                 | Billing → invoice.created                   | Sync the Stripe invoice into Herald's external invoices              |
| `invoice.finalized`               | Billing → invoice.finalized                 | Sync the Stripe invoice into Herald's external invoices              |
| `invoice.paid`                    | Billing → invoice.paid                      | Sync the Stripe invoice into Herald's external invoices              |
| `invoice.voided`                  | Billing → invoice.voided                    | Sync the Stripe invoice into Herald's external invoices              |

All 20 events above must be checked. Herald logs and returns 200 for events outside this list — they are safely ignored.

### Get the Webhook Secret [#get-the-webhook-secret]

1. After the endpoint is created, find **Signing secret** on the endpoint detail page
2. Click **Reveal**, copy the value starting with `whsec_`
3. Go back to the Herald admin console → Payment Providers → Stripe config, and paste it into **Webhook Secret**

### Production [#production]

Before going live, switch the Stripe Dashboard to production mode and repeat these steps to create the production Webhook endpoint. The production API Key and Webhook Secret are completely independent of the test environment.

## Step 3: Create products in Stripe [#step-3-create-products-in-stripe]

Create your products in [Stripe Dashboard → Products](https://dashboard.stripe.com/products).

### Subscription products [#subscription-products]

1. Click **Add product**
2. Fill in a product name (e.g. "AI Writing Assistant Pro")
3. Set the price to **Recurring**, choose monthly or yearly
4. After creation, note the **Product ID** (like `prod_xxxxxxxxxxxx`) and **Price ID** (like `price_xxxxxxxxxxxx`)

Herald uses Stripe's inline pricing to create the Checkout Session, so you don't need to pre-create a Price in Stripe. But the Product ID is a required field for the mapping.

### Credit-pack products (one-time purchase) [#credit-pack-products-one-time-purchase]

1. Create in the same Products page
2. Set the price to **One time**
3. Note the Product ID

## Step 4: Sync Stripe products into Herald [#step-4-sync-stripe-products-into-herald]

Sync Stripe products into Entitlement Mappings in Herald.

1. In the Herald admin console left menu, click **Entitlement Mappings**
2. Click **Sync Provider Products**
3. Select provider **Stripe**
4. Wait for sync to finish

After sync you will see the list of products pulled from Stripe. Each record contains:

* External Product ID (e.g. `prod_xxxx`)
* External Price ID (e.g. `price_xxxx`)
* An auto-generated Entitlement Key (format `stripe-{normalized_product_id}`)
* The owning Credit Account (newly synced mappings default to the Realm's registration receiving pool)
* Points policy state

The list's primary label is the Stripe product name. When the product name is missing, it falls back to the External Product ID, then a placeholder — never an empty label. The product filter matches on product name; hitting the external ID also counts.

Price display respects Stripe's unit conversion. Stripe syncs the price as the smallest currency unit integer (e.g. 999 means 9.99); the list and detail convert it to the major unit (9.99). This conversion branch applies only to Stripe — Creem never hits it.

Sync also pulls Stripe `Product.metadata` and `Price.metadata` (the merchant-defined key/value pairs you set in the Stripe Dashboard) into the mapping detail's display info, read-only. Re-sync uses Stripe's current value as the override. Herald never writes back to Stripe metadata; the authoritative source is the Stripe Dashboard.

The billing period (subscription period) comes from Stripe's `Price.recurring.interval` (day/week/month/year); the frontend is read-only and does not accept manual input. Even if you submit a period value on save/update, the backend will not use it to overwrite the synced value.

The auto-generated Entitlement Key is usually not what you want. The next step adjusts it.

## Step 5: Configure the Entitlement Mapping [#step-5-configure-the-entitlement-mapping]

Each mapping tells Herald: "this Stripe product = this Herald entitlement".

1. In the **Entitlement Mappings** list, find the mapping to configure
2. Click edit and modify these fields:
   * **Entitlement Key** (required): change it to something you recognize, e.g. `pro-monthly`. This key is used in SDK queries, points policy, and webhook handling — try not to change it after creation
   * **Billing Type**: Recurring (subscription) or OneTime (one-time purchase)
   * **Points Per Period**: how many credits to grant per billing period
   * **Grant On Subscribe**: whether to grant credits on first subscription
   * **Validity Days**: credit validity in days; 0 or empty means never expires
   * **Enabled**: whether it is enabled. When disabled, webhooks still update the subscription projection, but no credits are granted
3. Click save

### Anchor to a Credit Account [#anchor-to-a-credit-account]

Each mapping must be attached to a Credit Account. Synced mappings default to this Realm's registration receiving-pool account. If this plan belongs to a separate business line, go to the **Credit Accounts** page and move it to the right account.

The Credit Account decides two things: which pool the user's credits land in after purchase, and which Client Apps can spend those credits. See [Billing Architecture](/docs/billing-overview) for the Credit Account concept.

### Stripe Metadata import (optional) [#stripe-metadata-import-optional]

Stripe Products and Prices support metadata. If you add `herald_entitlement_key` to a Product or Price in the Stripe Dashboard, Herald reads it automatically on sync. But this is only an import source, not the source of truth — the local Herald configuration wins.

## Step 6: User payment flow [#step-6-user-payment-flow]

After configuration, the user-side flow is as follows.

### Subscription payment (Checkout Session) [#subscription-payment-checkout-session]

1. The user picks a plan in your app
2. Your app calls Herald's Checkout API (passing `entitlement_key`, `payment_provider=stripe`)
3. Herald looks up the Entitlement Mapping to find the Stripe product ID
4. Herald calls the Stripe API to create a Checkout Session, writing `herald_entitlement_key`, `herald_user_id`, `herald_client_app_id`, `herald_realm_id`, `herald_billing_kind` into metadata
5. Returns a Stripe payment-page URL
6. The user completes payment on the Stripe page
7. Stripe fires `checkout.session.completed` and `customer.subscription.created` Webhooks
8. Herald parses the entitlement\_key from metadata, creates the subscription projection, and grants credits
9. On each subsequent billing period, Stripe charges automatically and fires `invoice.payment_succeeded`; Herald grants renewal credits

### One-time payment (credit pack / Payment Intent) [#one-time-payment-credit-pack--payment-intent]

1. The user picks a credit pack
2. Your app calls the Herald Payment Attempt API
3. Herald creates a PaymentAttempt and calls the Stripe Payment Intent API
4. Returns `client_secret`
5. The frontend uses Stripe.js to render the payment form
6. The user completes payment; Stripe fires `payment_intent.succeeded`
7. Herald completes the payment and grants `topup_credit`

### Refund [#refund]

When a user initiates a refund in the Stripe Dashboard:

* Stripe fires `charge.refunded`
* Herald claws back credits based on refund type (top-up refunds claw back proportionally; subscription refunds claw back unused credits)

## Step 7: Verify the result [#step-7-verify-the-result]

### Check Entitlement Mapping status [#check-entitlement-mapping-status]

1. On the **Entitlement Mappings** page, confirm the mapping's Enabled status and points policy

### Check subscription status [#check-subscription-status]

1. Click **Subscriptions** in the left menu to see the subscription projection list
2. Confirm the `entitlement_key`, `status`, and `payment_provider` fields are correct

### Check change history [#check-change-history]

1. Click **Subscription History** to see the full change timeline for a subscription
2. Each change record carries event type, entitlement change, and timestamp

If webhooks are firing normally, the state updates within a few seconds. If it is stuck, see the troubleshooting below.

***

## Troubleshooting [#troubleshooting]

### Webhook not received [#webhook-not-received]

1. Confirm the Herald deployment's public address is reachable from Stripe's servers (use ngrok for local dev)
2. Confirm the realm ID in the Webhook endpoint URL is correct
3. In Stripe Dashboard → Webhooks → your endpoint, check the **Attempts** log
4. Confirm the Webhook Secret from Step 1 matches the endpoint's Signing secret

### Webhook signature verification failed [#webhook-signature-verification-failed]

* The Webhook Secret is wrong (must start with `whsec_`)
* A reverse proxy in front of Herald modified the request body (can happen when Nginx's `proxy_request_buffering` is off)
* The signature timestamp is more than 15 minutes old (clock skew)

### Payment succeeded but the subscription is not active [#payment-succeeded-but-the-subscription-is-not-active]

Most likely the Webhook did not reach Herald. Check the Herald logs for a `POST /api/third/pay/{realmId}/stripe/webhooks` request.

Common causes:

* The Webhook URL in the Stripe Dashboard is wrong
* A server firewall is blocking Stripe's callback
* An SSL certificate problem

### Stripe products not visible in Entitlement Mapping [#stripe-products-not-visible-in-entitlement-mapping]

Confirm three things:

1. The Stripe API Key is configured correctly (Payment Providers page)
2. You have run **Sync Provider Products**
3. The Products actually exist in the Stripe Dashboard

### Test card numbers [#test-card-numbers]

The Stripe test environment uses these cards:

* Success: `4242 4242 4242 4242`
* Requires authentication: `4000 0025 0000 3155`
* Declined: `4000 0000 0000 0002`

Use any future expiry date and any 3-digit CVC.

***

## Checklist [#checklist]

* [ ] Stripe configured on the Payment Providers page (Publishable Key, Secret Key, Webhook Secret filled in and enabled)
* [ ] Webhook endpoint created in the Stripe Dashboard, all 20 events checked
* [ ] Webhook Secret matches the Stripe endpoint's Signing secret
* [ ] Product created in Stripe, Product ID noted
* [ ] **Sync Provider Products** run on the Herald Entitlement Mappings page
* [ ] Entitlement Key and points policy reviewed and adjusted
* [ ] Mapping status is Enabled
* [ ] One full payment flow completed with test keys and a test card
