[data-reveal]{opacity:1!important;transform:none!important}
Stripe
SaaS

Stripe

The Stripe API version and webhooks, how a workspace starts a paid plan, and how plans stay in sync with Stripe products and prices.

dms-saas manages subscriptions through your Stripe account: it creates customers, calculates invoice tax through Stripe Tax, adds usage lines, issues credit notes and mirrors invoices from webhooks. Your business remains the merchant of record; the module does not use Stripe Managed Payments.

Follow a free-to-paid upgrade

For a workspace on a local free plan, the owner chooses a paid plan from Settings › Workspace › Billing › Change plan. DMS opens Stripe Checkout; the completion webhook resolves the recorded checkout. Closing the browser or returning to the app alone is not proof that payment completed.

Before trying this in Stripe test mode, configure real test keys and a webhook, save a paid plan so it has a Stripe price, and allow the app's return host in allowedRedirectHosts. The playground's placeholder keys and fake Stripe IDs are for browsing the UI, not for testing this flow.

Read Starting a paid plan for retries and pending checkouts, or Plans and Stripe before changing catalogue prices.

Stripe API version and webhooks

Every Stripe request is made with API version 2026-08-26.dahlia, the version the installed stripe SDK (v22) is generated for (STRIPE_API_VERSION in src/stripe/client.ts; the build fails if the two drift apart).

A webhook endpoint renders its events in its own API version, set in the Stripe Dashboard (Developers → Webhooks → the endpoint → API version), not in the version of the requests. Every endpoint that targets /api/saas/webhooks/stripe must use 2026-08-26.dahlia. Until it does, dms-saas still accepts events rendered in older versions: the fields basil moved — the subscription billing period (now on each subscription item), the invoice's subscription (parent.subscription_details), invoice and credit note taxes (total_taxes), and the credit note refund (refunds) — are read from their basil location first and from their older one second. The clover and dahlia releases moved none of the fields dms-saas reads.

New subscriptions, whether created at registration or by Checkout, are opened in the classic billing mode (billing_mode[type]=classic). Since 2025-09-30.clover Stripe defaults new subscriptions to flexible, which no longer invoices a free-to-paid upgrade at once and records Customer Portal cancellations as cancel_at instead of cancel_at_period_end; the plan-change flows rely on the classic behaviour every existing subscription has.

Checkout Sessions opt out of Stripe Managed Payments (managed_payments[enabled]=false). An account that enabled Managed Payments applies it to every session by default, but your business remains the merchant of record in this integration: dms-saas collects tax ids, runs Stripe Tax on its invoices, injects invoice items and issues credit notes, none of which a Managed Payments session allows.

Starting a paid plan

Moving a workspace without a Stripe subscription to a paid plan opens a Stripe Checkout Session. The request is recorded on the subscription as a pending checkout before Stripe is called, and the session's completion or expiry webhook resolves it. A pending checkout never blocks the owner for good:

  • when Stripe refuses to open the session (a 4xx answer), the pending checkout, the trial it reserved and the Stripe customer it created are rolled back, and the request answers 422 saas.errors.billing.checkout_rejected; the owner can retry at once;
  • when Stripe cannot be reached or answers a 5xx, a session may exist, so the pending checkout stays and the request answers 503 saas.errors.billing.checkout_unavailable;
  • the next attempt reconciles what it finds: a pending checkout whose recorded session has expired, or which never recorded a session within 15 minutes, is released and a new session is opened. Its redirect URL only reaches the owner once the session is recorded, so an unrecorded session is one nobody can pay;
  • a recorded session that is still open answers 409 saas.errors.plan.checkout_in_progress, and any other pending plan change 409 saas.errors.plan.change_in_progress.

Plans and Stripe

dms-saas keeps every plan billed through Stripe linked to a Stripe product and price that match it, whoever writes the plan: the platform-owner plan editor, or a module that seeds its catalogue straight into the plan table. A writer sets the plan's name, description, price, currency, interval and billing mode, and leaves paymentProviderRefs alone — dms-saas owns it.

A plan is billed through Stripe when its price is above 0, or when it is already linked to a Stripe price its subscriptions may be billed on. A plan priced 0 that never was stays off Stripe: its workspaces hold a local free subscription.

The sync runs:

  • when the plan editor creates or saves a plan, after writing it (a Stripe error is reported to the editor, and the next pass retries the sync);
  • once DMS has initialised the database at startup, in the background, for every active plan;
  • whenever a plan is about to be offered or billed — the tenant plan catalogue, a plan change, an operator action, a new workspace — so a plan written after startup is linked the first time it matters.

Outside the editor, a failed sync is logged and retried on the next pass; the plan meanwhile shows as not payable, and choosing it answers 400 saas.errors.plan.not_synced_with_stripe as before. With a placeholder Stripe key, every sync is skipped.

Each sync compares the plan with the terms recorded in paymentProviderRefs.stripeSyncedTerms and calls Stripe only when they differ:

What changedStripe effect
Nothingnone
Name, description, or the billing settings' tax codethe product is updated in place
Price, currency, interval or billing modea new price is created and the previous one archived; existing subscriptions stay on their price

Amounts are sent in the currency's minor unit (price × 100) and the currency in lowercase. A plan linked before the terms were recorded is read back from Stripe once: a price that still matches is kept, so its subscriptions still resolve to the plan.

Every Stripe creation carries an idempotency key derived from the plan id, its creation time and the request, so replicas syncing the same plan at once, or a retry within Stripe's 24-hour window, land on the same product and price. Only paymentProviderRefs is written back: a sync never rolls back a concurrent edit, and one that raced an edit is out of line again and redone on the next pass.