Stripe
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 change409 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 changed | Stripe effect |
|---|---|
| Nothing | none |
| Name, description, or the billing settings' tax code | the product is updated in place |
| Price, currency, interval or billing mode | a 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.