Add a plan + checkout button
Create the product at your provider, list it in BILLING_PLANS_JSON, and the billing settings page renders it automatically.
Adding a plan is mostly a provider-dashboard task — Orbit's side is a single env-var edit. The billing settings page renders whatever's in BILLING_PLANS_JSON; the checkout flow is one generic service that works for all three billing providers.
1. Create the price at your provider
Stripe
- Dashboard → Products → Add product. Set name + description.
- Add a Price (recurring, monthly or yearly, the currency you want).
- Copy the price id —
price_1PAbc.... That's what goes inBILLING_PLANS_JSON.
Polar
- Dashboard → Products → New product.
- Create a recurring price on it.
- Copy the product id (Polar bills by product, not price) — what Orbit calls
priceIdis the Polar product id. The adapter translates.
Dodo
- Dashboard → Products → Create, pick the recurring subscription type.
- Copy the product id —
pdt_.... Same field as Polar.
2. Add it to BILLING_PLANS_JSON
The env var is a JSON array. Each entry is a BillingPlan. Add your new plan alongside the existing ones:
apps/api/.env
BILLING_PLANS_JSON='[ { "key": "pro", "name": "Pro", "description": "For growing teams.", "priceId": "price_...", "unitAmount": 800, "currency": "usd", "interval": "month", "intervalCount": 1, "features": ["Unlimited teams", "Priority support"] }, { "key": "scale", "name": "Scale", "description": "For teams going public.", "priceId": "price_...", "unitAmount": 2400, "currency": "usd", "interval": "month", "intervalCount": 1, "features": [ "Everything in Pro", "SSO/SAML", "Audit log export" ] }]'The fields, in short:
key— stable identifier. The app reads this to match a subscription back to a plan.priceId— the provider-side id. Stripe'sprice_..., Polar/Dodo's product id.unitAmount— in cents.interval—"month"or"year".features— bullet list rendered on the plan card. Marketing copy, not gating.
BILLING_PLANS_JSON is read once at boot. Change it, restart the API, and the new plan appears. For zero-downtime plan rollouts across multiple instances: roll the fleet.
3. What happens on the frontend
The billing settings page calls GET /v1/workspaces/:slug/billing/plans, which returns what the provider's listPlans() hands back — in every adapter, that's the env-driven catalog.
The plan card renders automatically. No component change, no client rebuild — it's data-driven end-to-end.
4. The checkout button
Each plan card shows a checkout button. Clicking it calls one endpoint:
POST /v1/workspaces/:slug/billing/checkout { "planKey": "pro", "successUrl": "...", "cancelUrl": "..." } → { "redirectUrl": "https://checkout.stripe.com/..." }The service walks the same path regardless of provider:
- Look up the
BillingCustomerfor the workspace; if it doesn't exist, callprovider.createCustomer()and save one. - Find the plan in the catalog by
key. - Call
provider.startCheckout()with the customer id, plan, and success/cancel URLs. - Return the provider's redirect URL. The web app does a
window.location.href = redirectUrl.
5. The webhook closes the loop
When the checkout completes, the provider POSTs a subscription.created (or equivalent) to /v1/billing/webhooks/<provider>. HandleBillingWebhookService verifies the signature, dedupes, then upserts a Subscription aggregate with planKey: "pro" resolved from the price/product id.
The realtime publisher broadcasts subscription.updated on the workspace channel — every open tab flips from "Start a trial" to "Pro plan" without a reload.
6. Gate a feature by plan
Orbit doesn't ship plan-gating helpers — by design, because what counts as "Pro" is app-specific. Read the subscription where you need it:
// In a controller, after resolving the workspace:const sub = await uow.read(tx => tx.subscriptions.findByWorkspace(workspace.id));if (sub?.planKey !== "pro" && sub?.planKey !== "scale") { throw new ForbiddenError("plan.insufficient");}For client-side rendering, the subscription is already in the store — just check subscriptionStore.current?.planKey and render accordingly. It updates in realtime, so an upgrade takes effect the instant the webhook arrives.
Testing locally
- Stripe. Use Stripe's dashboard "send test webhook", or run
stripe trigger checkout.session.completedwith the Stripe CLI pointed at your smee tunnel. - Polar / Dodo. Both have a "replay" button on webhook delivery rows in their dashboards. Replay a recent event to exercise the path.
- End-to-end. Do a real checkout in test mode. Every provider has test-card numbers that succeed or fail predictably —
4242 4242 4242 4242is the universal "works".