Herald
Billing

Billing Architecture

Herald's billing system does not keep a local product catalog. Payment providers like Stripe and Creem own Product, Price, Checkout, and the subscription lifecycle; Herald keeps only the minimum data needed for authorization and credit grants — enough to run a paywall that stays in sync with every payment and refund.

Herald's billing system does not keep a local product catalog. Payment providers like Stripe and Creem own Product, Price, Checkout, and the subscription lifecycle; Herald keeps only the minimum data needed for authorization and credit grants.

The paywall loop

Everything in this module serves one goal: turning payments into a paywall that stays in sync on its own.

  1. The user pays via Stripe, Creem, WeChat Pay, or an in-app purchase.
  2. Herald resolves the purchase to an entitlement_key and grants credits into the bound Credit Account.
  3. Your product checks that entitlement — or the credit balance — and lets the request through or blocks it.
  4. Refunds and cancellations claw the credits back automatically. Subscription entitlements are revoked on any refund; one-time purchase roles are revoked only when the refund covers the whole payment, so partial refunds keep them.

Free allowances and rolling quota windows ride on the same model, so freemium, subscriptions, and metered pricing all end in the same check.

Who this is for

Developers who need to understand the design of Herald's billing module. It does not cover the hands-on integration steps for any specific provider — for those, see each provider's dedicated guide.

Core concepts

Credit Account

A Credit Account is the unit of isolation for credits. A Realm can have multiple Credit Accounts, each with an independent balance that does not affect the others. It solves this problem: a single Realm may run several business lines at once — say, an AI chatbot app and an image-generation app — and their credits must be settled and consumed separately, never mixed. Each Credit Account covers a set of Client Apps; only covered Client Apps can spend credits from this pool.

Payments grant into the Entitlement Mapping's bound account; registration and free-periodic credits are routed by the Realm's distribution rules. Balances are derived from a per-grant credit ledger and break down by the five credit types: top-up, subscription, registration, free-periodic, and granted. The full model — fields, constraints, the ledger, and the admin API — is covered in Points Accounts; the spend side in Points Consumption.

Entitlement Mapping

An Entitlement Mapping is a table that maps a provider's external product ID to an internal Herald entitlement_key, and at the same time anchors it to a Credit Account. It is not a product catalog — it is an allowlist plus a sync cache.

Each mapping includes:

FieldDescription
payment_providerProvider name (stripe, creem, apple, google, wechat)
external_product_idProduct ID on the provider side (e.g. prod_xxxx; store product ID for IAP)
external_price_idPrice ID on the provider side (Stripe has this; not applicable to Creem or IAP)
entitlement_keyInternal Herald entitlement identifier (e.g. pro-plan)
bucket_idOwning Credit Account. After purchasing this product, credits go into this account's pool
billing_typeBilling type: Recurring, OneTime, or Non-renewing
point_rulesPoints distribution rules owned by the mapping. Empty is valid — a role-only mapping or a pure payment record (validity and grant policy live on the rules)
granted_role_idsRoles auto-granted on payment success. Empty = no role grant; see the paywall configuration guide
enabledWhether it is enabled. When disabled, webhooks still update the subscription projection, but no credits are granted

The mapping also carries a copy of display info synced from the provider (provider_product_info), read-only, used to show the product's original appearance in the admin console, not used for billing:

FieldDescription
Product nameThe list and grouping use it as the primary label. When missing, falls back to the external product id, then a placeholder — never an empty label
PriceDisplay respects the provider's unit. Stripe syncs the smallest currency unit as an integer (e.g. 999 means 9.99), converted to the major unit for display; Creem uses the value as-is (a string like "9.99"), with no extra divide-by-100. WeChat Pay has no catalog to sync from — the price is set on the mapping itself as an integer in fen and used directly as the order amount. The unit conversion is driven by the provider source and is not shared across providers
CurrencyProduct currency, follows the sync override
Billing typerecurring / one_time / non_renewing; synced from the provider for Stripe and Creem, set by the admin for providers without a catalog (IAP, WeChat Pay)
Billing periodStripe takes Price.recurring.interval (day/week/month/year), read-only, no manual input; re-sync uses Stripe's current value. Creem takes its product response's billing_period (e.g. every-month, mapped to display copy by the frontend); when missing, shows "—", never fabricated
MetadataSyncs Stripe Product.metadata and Price.metadata (merchant-defined key/value pairs), read-only display. Creem Product has no native metadata, treated as empty, never fabricated

Display info is overwritten by the provider's current value on every re-sync; business fields like entitlement_key, points, grant policy, and quota_windows are not overwritten by sync.

The billing period (from Stripe) and the quota rolling window (quota_windows, e.g. 5 hours / week) are two independent concepts, deliberately decoupled. The billing period decides when Stripe charges and when it fires the renewal webhook; the quota window decides how much quota a user can spend within a rolling window. The two do not need to be equal, to divide evenly, or to align. A long billing period (yearly) can still carry rolling limits (monthly / weekly windows). Quota grants are driven by renewal webhook events, anchored to one entitlement period using the provider-returned (period_start, period_end); the full-period total is never pre-granted, and there is no fixed-calendar-day reset.

Mappings are produced in three ways:

  1. Admin manual sync: calls the provider's Product API, pulls all products, and auto-creates or updates mappings. Each newly created mapping must be bound to a Credit Account in this Realm (required; the admin can reassign it to another account afterwards). Applies to Stripe and Creem.
  2. Admin manual create: IAP providers (App Store / Google Play) and WeChat Pay have no catalog sync, so the admin creates the mapping directly — IAP with the store product ID, WeChat Pay with a product ID and price of its own.
  3. Webhook incremental update: when a payment event fires, info is extracted from the webhook payload to update the cache.

When sync fails, the local cache keeps serving — there is no silent degradation.

Subscription Projection

A Subscription is a local, read-only copy of the provider's subscription state — it is not a subscription owned by Herald. The SDK and authorization queries read this projection instead of hitting the live provider API.

Projection fields include realm_id, user_id, entitlement_key, bucket_id, status, current_period_start/end, provider_metadata, and so on. When a subscription is established, it is bound to the Credit Account that the purchased Entitlement Mapping belonged to at that moment. Later renewals, upgrades, downgrades, cancellations, and refunds all return to the same pool — they never leak into another Credit Account. When you need billing period or tier information, derive it from entitlement_key or provider_metadata.

Subscription statuses: Active, Trialing, PastDue, Canceled, Expired, Paused, Disputed, ScheduledCancel, Incomplete.

The has_access() method decides whether a user has access: Active and Trialing return true; the rest return false.

Payment Attempt

A Payment Attempt is a unified ledger recording every successful payment, covering one-time purchases, subscription first installments, and subscription renewals. Previously, a subscription renewal only updated the subscription period and credits, leaving no record of "this payment happened"; now every successful renewal also writes a successful payment attempt, which serves as the local anchor for external invoices. Every third-party-synced invoice can be traced directly to the payment attempt or subscription it belongs to. Zero-amount periods (100% discount / free tier) produce no renewal payment attempt. Attribution and anomaly detection are detailed in Invoice Management.

Metadata contract

All metadata keys Herald uses share the herald_ prefix. This metadata is written into the Checkout Session or Subscription and returned verbatim by the provider in webhooks.

Required metadata:

KeyDescription
herald_realm_idRealm ID
herald_client_app_idClient App ID
herald_user_idUser ID
herald_entitlement_keyEntitlement identifier
herald_billing_kindsubscription or points_package

Checkout creation validates this metadata; if any is missing, creation is rejected. After receiving a webhook event, the entitlement_key is resolved in this order:

  1. The herald_entitlement_key in the webhook metadata
  2. The local mapping table (looked up by provider + external_product_id)
  3. If neither is found, an error is logged — it is not silently skipped

Points policy

The source of truth for the points policy is the local Entitlement Mapping in Herald, not the provider's metadata. Stripe's Product.metadata and Price.metadata flow in with product sync into the display info (Stripe only), read-only on the Herald side — Herald never writes back or edits them. Creem has no native metadata feature, so it must be configured manually in Herald.

The policy is queried by entitlement_key and covers: first-subscription grant, renewal grant, cancellation clawback, refund clawback, and upgrade/downgrade handling. Grants and clawbacks both target the subscription's bound Credit Account pool, booked separately by credit type (see Points Accounts).

Admins can modify the points policy in Herald. What they modify is Herald's business rules — nothing is written back to the provider.

Data flow

Provider Herald Admin herald_* metadata belongs to bound to grant/clawback to pool Checkout Session Webhook Entitlement Mapping Subscription Projection Points Policy Credit Account Pool Sync Products Create Credit Account / Set Coverage Configure Points Policy

User payment flow:

  1. The frontend calls the Checkout API, passing entitlement_key and payment_provider
  2. Herald looks up the Entitlement Mapping to find the corresponding external product and its owning Credit Account
  3. Herald calls the provider API to create a Checkout Session, writing the herald_* fields into metadata
  4. The user completes payment on the provider's page
  5. The provider sends a Webhook to Herald
  6. Herald parses the entitlement_key from metadata, creates/updates the Subscription Projection, and binds it to the mapping's Credit Account
  7. Herald looks up the points policy by entitlement_key and grants credits into that account's pool, or claws them back from it

How SDK spend is routed

When a third-party app spends credits through the SDK, it passes only the Client App and the amount — it knows nothing about Credit Accounts. Herald finds every Credit Account pool that covers this Client App and debits across them, quota windows first and pool balances nearest-expiry first, atomically, never overdrawing. The full spend semantics — request, deduction order, records, and clawback — are covered in Points Consumption.

Supported providers

ProviderCommercial catalogSubscriptionsOne-time purchase (credit packs)Metadata support
StripeProduct / Price APICheckout Session + SubscriptionPayment IntentSupported on Product/Price/Checkout/Subscription
CreemProduct APICheckoutCheckoutCheckout metadata, returned in webhook
Apple App Store IAPApp Store Connect (no sync; manual mapping)StoreKit 2 + Server Notifications V2Consumable, fulfilled on receipt submissionUser binding via appAccountToken
Google Play BillingPlay Console (no sync; manual mapping)Play Billing + scheduled polling (no RTDN)Consumable, fulfilled on receipt submissionUser binding via obfuscatedExternalAccountId
WeChat PayNone (order-based pricing; manual mapping)Non-renewing only — fixed period, single payment, no auto-renewUnified order (Native QR on PC / JSAPI inside WeChat)No catalog metadata; price and currency set on the mapping

Stripe, Creem, and WeChat Pay are initiator-style platforms; the payment process is unified through PaymentAttempt. WeChat subscriptions stop at a fixed end date — there is no renewal charge, the user buys again to extend. IAP also settles through PaymentAttempt, but fulfillment is triggered by the mobile app submitting the purchase proof (Apple jwsRepresentation / Google purchaseToken) instead of a checkout webhook — the platform notifications (Apple) and the scheduled polling (Google) only drive the lifecycle after that. See the IAP integration guide and the WeChat Pay integration guide.

Multi-currency

Currency rides on the catalog itself: each Stripe Price carries its currency, and a product sold in several currencies simply has several mapping rows. There is no default currency — the purchase page shows every configured currency and the user explicitly picks one; resolution by currency is strict — no fallback to a second currency, no zero-amount charges. Creem, IAP, and WeChat Pay stay single-price (WeChat's manual price and currency live on the mapping). See the Multi-Currency Pricing guide.

On this page