This page takes your product from no billing to paid plans through Stripe, first in test mode, then live.
Each group has one subscription, paid by the group's owner. A group without a paid subscription is on the Free plan. Stripe owns the money and the subscription. The app keeps only what Stripe's webhooks report and never decides a plan on its own.
You need:
- A Stripe account.
- The Stripe CLI, to receive webhooks on your machine.
- Seed running locally (development.md).
The billing guides:
- This page: settings, test mode, going live, the development provider.
- Plans and limits: the plans, per-member pricing, and what each plan allows.
- Trials, failed payments and cancelling: what happens to a subscription over time, and the mails the owner gets.
- Billing for operators: the admin panel's Billing pages, transfers of ownership, and what to fix in the Stripe dashboard.
Set these in infra/envs/.env (development) or infra/envs/.env.prod (the server). Compose passes
them to the API and the worker.
| Variable | What it does | Default |
|---|---|---|
BILLING_PROVIDER |
stripe, off or fake (development only). |
off; fake with ENV=dev |
STRIPE_SECRET_KEY |
Stripe's secret key. sk_test_… in test mode. |
none |
STRIPE_WEBHOOK_SECRET |
The signing secret of the webhook endpoint, whsec_…. |
none |
STRIPE_PRICE_PRO |
The id of the Pro plan's price in Stripe, price_…. |
none |
STRIPE_PRICE_PRO_YEARLY |
Pro's yearly price id; only when Pro has a yearlyAmount. |
none |
BILLING_GRACE_DAYS |
Days a group keeps its plan after a failed payment. 0 to 90. | 7 |
BILLING_TRIAL_REQUIRES_CARD |
true or false: whether a trial's checkout asks for a card. |
true |
What each provider does:
BILLING_PROVIDER |
What runs |
|---|---|
off |
No billing. Every group shows as Free, with no caps. Checkout and the webhook answer 404. |
stripe |
Stripe, with the three STRIPE_* keys. |
fake |
The development provider (below). Refused in production. |
The API refuses to start on a bad value, and names what is wrong:
- With
stripe, the API needs all three Stripe keys. - The worker needs
STRIPE_SECRET_KEYandSTRIPE_PRICE_PRO, to change seat counts. It verifies no webhook, so it never receivesSTRIPE_WEBHOOK_SECRET. BILLING_GRACE_DAYSmust be a whole number from 0 to 90, andBILLING_TRIAL_REQUIRES_CARDmust betrueorfalse. Unset or empty means the default.
tests/compose-api-env.test.ts checks that Compose passes every billing variable to both services,
the webhook secret to the API only.
Test mode moves no money. Test keys start with sk_test_, and test cards such as
4242 4242 4242 4242 always pay.
In the Stripe dashboard, switch to test mode.
Create a product "Household" with a monthly price of $4.00 and a yearly price of $40.00, both in US dollars. Copy the monthly price id into
STRIPE_PRICE_PROand the yearly one intoSTRIPE_PRICE_PRO_YEARLY.Copy the test secret key into
STRIPE_SECRET_KEY.Forward webhooks to the local API:
stripe login stripe listen --forward-to http://app.localhost/api/webhooks/billing \ --events customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failedCopy the
whsec_…secret thatstripe listenprints intoSTRIPE_WEBHOOK_SECRET.Set
BILLING_PROVIDER=stripe.Start the stack again, so the API and the worker read the new values:
deno task devSet up the customer portal once (Settings, Billing, Customer portal), or Stripe refuses to open it. Allow customers to cancel "at the end of the billing period", not immediately. Allow them to renew a cancelled subscription: that is how an owner undoes a cancellation.
Set failed payments to cancel the subscription: Settings, Billing, Subscriptions and emails, "Manage failed payments for subscriptions", then "cancel the subscription" when all retries fail.
Step 9 matters. A cancelled subscription lets the owner check out again; one left unpaid blocks a
new checkout with 409 ALREADY_SUBSCRIBED. The group drops to Free after the grace period either
way (failed payments).
To test, open a group's Billing tab as its owner and press Start 14-day free trial.
A plan sells a year too once its entry in PLANS has a yearlyAmount, in the currency's smallest
unit: 4000 is $40.00. Seed's own Pro has none, so it is sold monthly only. With a yearly price,
the Billing tab offers a Monthly/Yearly choice, /pricing shows both prices and the saving, and
the admin panel names each subscription's period.
- Set
yearlyAmounton the plan inPLANS. - In Stripe, add a yearly price to the same product. Copy its id into
STRIPE_PRICE_PRO_YEARLY. Withstripe, the API and the worker refuse to start when Pro has a yearly price and the variable is empty, or when the variable is set and Pro has none. - In the customer portal's settings, list both prices of the product, so an owner can switch period there. Let a switch happen at once, with proration: Stripe then charges or credits the difference, and the Billing tab's hint ("Stripe shows what you pay today before you confirm") stays true.
- Turn on Stripe's reminder email about upcoming renewals, under Settings, Billing, Subscriptions and emails. A year is long enough for an owner to forget the charge.
- Decide whether the yearly price includes tax before you sell it: a Stripe price's tax behaviour cannot change once the price has been used.
The period an owner picks only chooses which price the checkout opens. The plan and its period are
read from the price Stripe reports in the webhook, so a subscription is billed and stored for what
Stripe charges. A checkout for a period the plan does not sell is refused with 400
PERIOD_NOT_OFFERED.
A webhook for a price no plan maps to, such as a price added in Stripe but not to the settings,
changes no group's subscription. The API logs billing event bills a price no plan maps to with the
event and price ids, and keeps no record of the event. Add the price to the settings, then resend
the event from Stripe's dashboard: it applies like any other. The development provider
always knows a yearly Pro price (price_fake_pro_yearly, or STRIPE_PRICE_PRO_YEARLY), so a signed
event for it is read as yearly there.
In the Stripe dashboard, in live mode, create the same product and price, and set up the portal and failed payments as in steps 8 and 9 above.
Add the webhook endpoint
https://<DOMAIN>/api/webhooks/billingwith the same five events.In
infra/envs/.env.prod, setBILLING_PROVIDER=stripe, the live secret key, the endpoint's signing secret and the live price id.Deploy:
deno task deploy
Keys live in the environment only. The tracked infra/envs/.env.example holds empty placeholders.
With ENV=dev and no BILLING_PROVIDER, the API runs a provider that calls no network:
- A checkout sends the browser straight back, as if paid. The plan changes only when a webhook arrives.
- The portal sends the browser back to the group's Billing tab.
- Webhooks are verified exactly as Stripe's are, with the public secret
whsec_template_development_only. The Pro price isprice_fake_pro, unlessSTRIPE_PRICE_PROis set.
Never expose such a server to the internet. Anyone can sign a webhook with that public secret. Production refuses the development provider, so the secret cannot sign a real event.
The e2e test drives an upgrade this way. It posts a recorded customer.subscription.created,
signed with that secret, to the webhook (e2e/fixtures/billing.ts).
- Plans and limits: change the price or add a plan.
- Trials, failed payments and cancelling.
- Billing for operators.