Billing providers
One BillingProvider port. Three interchangeable adapters. The master switch is a single env var.
Billing follows the adapter pattern that runs through every external dependency in Orbit: a port lives in application/, concrete adapters live in infrastructure/, and composition.ts picks one based on env. Swap the provider by changing BILLING_PROVIDER and the associated secrets — zero code changes, zero domain changes.
The port
apps/api/src/billing/application/billing-provider.ts
export interface BillingProvider { listPlans(): Promise<BillingPlan[]>; findCustomer(providerCustomerId: string): Promise<ProviderCustomer | null>; createCustomer(input: CreateCustomerInput): Promise<ProviderCustomer>; startCheckout(input: StartCheckoutInput): Promise<{ redirectUrl: string; sessionId: string }>; openPortal(input: OpenPortalInput): Promise<{ redirectUrl: string }>; cancelSubscription(input: CancelInput): Promise<void>; fetchSubscription(providerSubscriptionId: string): Promise<ProviderSubscription | null>;}All seven methods are provider-agnostic. The domain layer never sees Stripe's checkout.sessions, Polar's product model, or Dodo's snake_case fields — adapters translate at the boundary.
The three adapters
| Provider | SDK | Pricing model | Webhook verification |
|---|---|---|---|
stripe | stripe | priceId (you're the merchant of record) | webhooks.constructEvent (STRIPE_WEBHOOK_SECRET) |
polar | @polar-sh/sdk | productId (Polar is the MoR) | Standard Webhooks (validateEvent) |
dodo | dodopayments | Product-based, Dodo is the MoR | Standard Webhooks (webhooks.unwrap) |
StripeBillingProvider
apps/api/src/billing/infrastructure/stripe-billing-provider.ts
constructor(config, listPlans) { this.client = new Stripe(config.apiKey, { apiVersion: config.apiVersion });}
startCheckout(input) { return this.client.checkout.sessions.create({ customer: input.providerCustomerId, mode: "subscription", line_items: [{ price: plan.priceId, quantity: 1 }], success_url: input.successUrl, cancel_url: input.cancelUrl, });}PolarBillingProvider
Polar uses productId instead of priceId, and externalId for your-side customer mapping. The POLAR_SERVER env var switches between "sandbox" and "production" targets.
DodoBillingProvider
Dodo's SDK is Stainless-generated. The adapter translates snake_case response fields (customer_id, checkout_url) into the camelCase domain types. DODO_PAYMENTS_ENVIRONMENT toggles "test_mode" vs "live_mode".
How the adapter is selected
apps/api/src/billing/feature.ts
function buildProvider(config, catalog): BillingProvider { if (config.provider === "stripe" && config.stripe) { return new StripeBillingProvider( { apiKey: config.stripe.apiKey }, () => catalog.list(), ); } if (config.provider === "polar" && config.polar) { return new PolarBillingProvider( { accessToken: config.polar.accessToken, server: config.polar.server }, () => catalog.list(), ); } if (config.provider === "dodo" && config.dodo) { return new DodoBillingProvider( { apiKey: config.dodo.apiKey, environment: config.dodo.environment }, () => catalog.list(), ); } return new NoopBillingProvider();}When BILLING_PROVIDER is unset, the feature resolves to NoopBillingProvider. The billing routes still mount, but they return a stable disabled-state response — no SDK is constructed, no secrets are read.
The webhook flow
Every provider calls the same route pattern:
POST /v1/billing/webhooks/:providerFour things happen inside the handler, in order:
- Raw-body preservation. The controller reads
c.req.text(), not.json(), and forwards lowercased headers — signature verification needs the exact bytes. - Signature verification. Each provider has a
WebhookReceiveradapter that uses the SDK's native verifier:StripeWebhookReceiver,PolarWebhookReceiver,DodoWebhookReceiver. Failure throwsInvalidWebhookSignatureError→ 400. - Dedupe.
HandleBillingWebhookServicerecords every event in aBillingEventledger row, keyed byproviderEventId. Replays short-circuit with{ ok: true, processed: false }. - Apply. The service resolves the workspace via
BillingCustomer, then upserts theSubscriptionaggregate. Domain events fire post-commit — the realtime publisher broadcastssubscription.updatedto every socket on the workspace channel.
The signature verification step is non-negotiable. Without the raw body preserved exactly as sent, HMAC comparison fails and legitimate webhooks get rejected. Hono's default JSON parser would mutate the bytes — the controller deliberately reads text instead.
The plan catalog
BILLING_PLANS_JSON is the source of truth for which plans render on the billing settings page. It's read at boot into a BillingCatalog and handed to the provider via listPlans(). Keep it small; when you outgrow a handful of plans, replace the env-backed source in composition.ts with a DB-backed provider.
For Polar and Dodo, priceId holds the provider's product id — the adapter translates the naming, you don't have to.
Swapping providers
- Set
BILLING_PROVIDERto the new value. - Replace the provider-specific secrets (see Environment variables).
- Update
BILLING_PLANS_JSON—priceIdvalues change per provider. - Register a new webhook endpoint with the provider, pointing at
/v1/billing/webhooks/<name>. - Restart the API. Existing
BillingCustomerrows are scoped to the previous provider — you'll want to nullify them or re-provision customers on the new side.
Adding a fourth provider
It's a file, not a refactor:
- Implement
BillingProviderandBillingWebhookReceiverinbilling/infrastructure/<name>-billing-provider.ts. - Extend
readBillingConfig()incomposition.tsto recognize the new name and parse its secrets. - Add the
new_provider === "..."branch tobuildProvider()inbilling/feature.ts.
No changes to the domain layer, no changes to HandleBillingWebhookService, no changes to the HTTP controller. The port shape is the contract — the domain doesn't care what speaks it.