SsuperslateDocs

Billing

Configure one billing provider and enforce entitlements on the server.

Billing

The application supports Polar, Stripe, and Dodo Payments behind one provider-neutral application boundary. A deployment selects exactly one adapter with BILLING_PROVIDER; providers are not combined, and only the selected provider can change local entitlements.

This is billing for the SaaS built from the foundation. It is separate from the seller's source-product purchase and fulfillment design in docs/commercial/fulfillment.md.

Modes and provider selection

ModeConfigurationExpected behavior
Local zero-keyselected provider credentials/products all unsetapp trial works; plans are empty; checkout/portal are unavailable; no provider IO
Automated testnormalized fake provider plus disposable Postgresordering, replay, rollback, and entitlement run without external calls
Sandbox/testcomplete selected-provider test configurationhosted checkout, portal, and signed lifecycle events without a real charge
Productioncomplete selected-provider live configurationreal lifecycle; release smoke and incident ownership are required

Choose one:

BILLING_PROVIDER=polar # or stripe or dodo

Configuration is atomic for the selected provider. Leave every selected-provider credential and product/price ID unset for zero-key development. Once any is set, provide its API credential, webhook secret, and at least one unique plan mapping:

ProviderCredentialsPlan identifiersEnvironment
PolarPOLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_ORGANIZATION_IDPOLAR_*_PRODUCT_IDPOLAR_ENVIRONMENT=sandbox|production
StripeSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETSTRIPE_*_PRICE_IDdetermined by the Stripe key
Dodo PaymentsDODO_PAYMENTS_API_KEY, DODO_PAYMENTS_WEBHOOK_SECRETDODO_PAYMENTS_*_PRODUCT_IDDODO_PAYMENTS_ENVIRONMENT=test_mode|live_mode

Each provider has monthly and yearly display-price variables matching its identifier prefix. Displayed price is application configuration, not provider authority. Compare identifier, interval, currency, and charged amount during the release smoke.

Inactive-provider variables are ignored. This permits deliberate provider switching without allowing two providers to mutate the same entitlement. Switching an application that already has customers requires a migration and reconciliation plan; changing BILLING_PROVIDER alone is not a live-customer migration.

Boundaries

packages/billing
  provider.ts             provider-neutral methods
  types.ts                normalized customers/subscriptions/payments/events
  providers/polar.ts      Polar SDK and normalization
  providers/stripe.ts     Stripe SDK and normalization
  providers/dodo.ts       Dodo SDK and normalization

apps/server/src/domains/billing
  routes.ts               session endpoints and selected-provider webhook route
  service.ts              plan mapping, event transaction, notifications, app trial
  repository.ts           provider-neutral customers/subscriptions/event ledger
  entitlements.ts         sole paid-access policy

Raw SDK payloads stop inside their provider adapter. Hono routes, repositories, web code, and public contracts never accept a provider SDK object.

Checkout and portal

Authenticated users can list configured plans, create a hosted checkout for a configured plan, open the selected provider's portal after a provider customer exists, and fetch server-derived subscription/trial/entitlement state.

Checkout derives the user and email from Better Auth. It sends user_id and plan_type as provider-side metadata. The return URL is UX/recovery information only and never grants access. Stripe receives its own checkout-session placeholder; Dodo receives a plain return URL; Polar receives its native checkout placeholder. Polar also receives the buyer address resolved by the selected TRUSTED_PROXY_PROFILE so its hosted checkout can localize currency and calculate tax from the buyer rather than from the API server. Keep the application origin inaccessible around that trusted ingress.

Portal creation uses the provider customer ID stored from a verified webhook. Stripe and Dodo require that provider-side ID; Polar uses the application external customer ID. Portal-session creation remains rate-limited.

Trials and entitlements

The application has a 14-day local product trial starting at users.created_at. This is separate from any provider-side trial.

Paid access is decided only in entitlements.ts:

Local statusPaid accessNew checkout blockedRecovery behavior
activeyesyesportal available
trialingyesyesprovider trial
canceled, period end still in futureyesyesaccess until exact period boundary
past_duenoyesupdate payment method; do not double-buy
unpaidnonoprovider state is inactive
paused, incomplete, incomplete_expirednonono paid entitlement
absent or expired cancellationnonoapp trial may still grant product access

Provider statuses are normalized fail-closed. For example, Dodo on_hold maps to past_due and failed maps to unpaid; Stripe and Polar lifecycle states map to the same application policy. A scheduled cancellation remains entitled only before its provider period end.

Webhook contract

Register the endpoint matching the selected provider:

https://<api-origin>/webhook/<polar|stripe|dodo>

The route returns 404 when the path provider is not the selected provider. It returns 503 when the selected webhook integration is intentionally unset and 403 for a missing or invalid signature.

Each adapter verifies the raw body before normalization:

  • Polar uses Standard Webhooks headers and its SDK verifier.
  • Stripe requires Stripe-Signature and constructs the event with Stripe's verifier.
  • Dodo requires its Standard Webhooks ID/timestamp/signature headers and official verifier.

After verification, the application:

  1. normalizes the provider event;
  2. claims (provider, event_id) and mutates subscription/customer/access state in one transaction;
  3. orders state using provider event time in provider_modified_at;
  4. commits before best-effort email/in-app notification.

Duplicate committed events are no-ops. A failed mutation rolls back the claim so the provider can retry. An older event may be claimed without overwriting newer state. Unknown products, contradictory plan metadata, and missing user correlation fail the transaction instead of defaulting to a paid plan.

Subscribe only to lifecycle events handled by the selected adapter:

  • Polar: subscription created/active/updated/canceled/uncanceled/revoked/past-due and order paid.
  • Stripe: customer subscription created/updated/deleted and invoice payment succeeded/failed.
  • Dodo: payment succeeded/failed and subscription active/renewed/plan-changed/updated, update-payment-method, cancelled/expired/on-hold/failed/paused.

Provider-valid unrelated events are acknowledged and recorded as unhandled. Treat a newly required event as an adapter/version change with a regression fixture.

Notifications, deletion, and failure semantics

Billing state commits before lifecycle notification delivery. A notification failure must not make the provider retry an already-committed financial mutation. The current implementation is not backed by an outbox, so an operator resend must not replay billing state.

Deleting local application data does not cancel a remote subscription. Account deletion returns 409 CONFLICT while billing can renew or paid time remains. The customer cancels through Billing and waits for the period boundary before local account deletion.

Never manually toggle users.is_premium, delete event claims, or grant access from a checkout redirect. Use Diagnose a failed webhook.

Provider verification

Before production, run the same lifecycle in the selected provider's sandbox/test mode:

  1. create every enabled recurring product/price;
  2. configure the complete selected provider;
  3. register the exact webhook route and events;
  4. run checkout with a fresh verified application user and a deliverable email address accepted by the provider sandbox;
  5. confirm customer correlation, subscription, event claim, entitlement, and portal;
  6. replay the same event and deliver an older lifecycle event;
  7. fail and recover a payment;
  8. schedule cancellation and verify the exact access boundary;
  9. reject an invalid signature;
  10. verify account deletion remains blocked until billing is resolved.

The optional Add a billing plan, Add a billing-gated feature, and Diagnose a failed webhook playbooks provide maintained implementation and recovery sequences.

Reconciliation and alerting

Run a bounded dry-run against the selected provider:

vp run --filter @app/server billing:reconcile -- --limit=100

Apply remote subscription state only after reviewing that report:

vp run --filter @app/server billing:reconcile -- --apply --limit=100

The compiled deployment artifact supports the same operator command:

./server billing:reconcile --apply --limit=100

The command exits non-zero for unresolved drift, provider errors, or a locally stored subscription that no longer exists remotely. Configure Railway or Dokploy scheduled-job failure alerts around that exit status. Missing remote subscriptions are never silently deleted or revoked; investigate them in the provider dashboard first. This explicit bounded command is the v1 recovery mechanism, not a generic worker or queue.

Known limits

  • Only one provider can be active per deployment.
  • Plan currency and amount are not reconciled into the application plan response.
  • There is no generic coupon, tax display, usage, seat, or refund UI.
  • Reconciliation is bounded and operator-triggered; each deployment must schedule it and route a non-zero exit to its platform alerting channel.
  • Adapter unit/integration tests do not replace a real Polar sandbox, Stripe test-mode, or Dodo test-mode checkout-to-webhook proof.

On this page