Billing setup (Stripe)
The runbook a deployment follows before it can take money: the plan ladder, the Stripe products and prices, the webhook, and the environment it all reads.
Everything a deployment needs before it can take money. Written as a runbook — do these in order, once per Stripe mode (test, then live).
Nothing here is required to run the product. With no Stripe configuration at all the API boots, every organization lives on the free plan, and the plan cards read "contact us" instead of a price. What is missing without it is the ability to upgrade.
The plans
The ladder lives in packages/shared/src/schemas/billing.ts (BILLING_PLANS).
It is the only place slots, seats, MCP allowances and entitlements are defined —
and it deliberately contains no prices at all. A displayed price is always
whatever Stripe answers for the configured Price id, which is why a number on a
plan card cannot drift from the number on the invoice.
| Plan | Businesses | Seats | MCP calls / month | Includes |
|---|---|---|---|---|
| Free | 1 | 1 | — | every dashboard and channel, the Terminal |
| Starter | 1 | 3 | 2,000 | always-on operator, MCP (connect an AI client) |
| Pro | 5 | 10 | 10,000 | the above + client reports, client portal |
| Agency | 25 | 50 | 50,000 | the above + agency roll-up, white-label |
Read-only client logins never consume a seat. MCP — the remote endpoint AI
clients connect to, and the tool routes an API key drives — is a paid surface:
free has no allowance and is refused with the plan to upgrade to (ADR 0069).
1. Create the products and prices
In the Stripe dashboard → Products, create one product per paid plan (Starter, Pro, Agency). Give each two recurring prices:
- a monthly price, and
- an annual price.
Set the annual price yourself — the product suggests "two months free" as the default discount (annual = 10 × monthly), and the web pricing table computes and shows the saving from the two prices, so whatever you set is what it advertises.
Copy the six price_… ids.
2. Configure the API
| Variable | What |
|---|---|
STRIPE_SECRET_KEY | sk_live_… (or sk_test_…) |
STRIPE_WEBHOOK_SECRET | from step 3 — whsec_… |
STRIPE_PRICE_STARTER_MONTHLY | the Starter monthly price id |
STRIPE_PRICE_PRO_MONTHLY | the Pro monthly price id |
STRIPE_PRICE_AGENCY_MONTHLY | the Agency monthly price id |
STRIPE_PRICE_STARTER_ANNUAL | the Starter annual price id |
STRIPE_PRICE_PRO_ANNUAL | the Pro annual price id |
STRIPE_PRICE_AGENCY_ANNUAL | the Agency annual price id |
The unsuffixed names from before annual billing — STRIPE_PRICE_STARTER,
STRIPE_PRICE_PRO, STRIPE_PRICE_AGENCY — are still read as that plan's
monthly price, so an existing deployment keeps working without being touched. An interval with no
price id is simply not offered.
WEB_URL must be the customer-facing app URL: Checkout and the Customer Portal
return to ${WEB_URL}/settings/billing, and the MCP quota refusal points there.
3. The webhook
The webhook is the only writer of plan, status, interval,
businessSlots and currentPeriodEnd (ADR 0002). Without it, a customer pays
and nothing changes in the product.
- Stripe dashboard → Developers → Webhooks → Add endpoint
- URL:
https://<api-host>/webhooks/stripe - Events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed— dunning: the banner, the owner email and the alertinvoice.paid— clears itcustomer.subscription.trial_will_end— the "your trial ends in 3 days" mail (see Free trial)invoice.upcoming— optional; logged and otherwise ignored
- Copy the signing secret into
STRIPE_WEBHOOK_SECRETand redeploy.
Signature verification reads the raw body, which the API enables with
rawBody: true. Behind a proxy that rewrites bodies, signatures fail with a 400
and the log line says so.
Locally: stripe listen --forward-to localhost:4000/webhooks/stripe prints a
whsec_… for .env.
4. The Customer Portal
Stripe dashboard → Settings → Billing → Customer portal. Enable:
- update payment method (this is what fixes a
past_duesubscription), - cancel subscription,
- switch plan, listing all six prices,
- customer information → billing address (and tax ids if you sell to businesses) — Stripe Tax computes tax from this address, so a customer who moves needs somewhere to change it.
4a. Stripe Tax
Checkout is created with automatic_tax: { enabled: true } and
customer_update: { address: 'auto' }: Checkout asks for the billing address,
writes it back onto the Stripe customer, and Stripe adds the tax that address
owes. The owner has to switch this on in the dashboard first — a Checkout
with automatic tax against an account without Stripe Tax set up fails to start.
- Stripe dashboard → Settings → Tax → turn on Stripe Tax.
- Set the origin address (the business's own head-office address).
- Give each product a tax code (SaaS — business use is the usual one) or set a preset default for the account.
- Add a registration for every jurisdiction you are registered to collect in. Stripe only charges tax where a registration exists; its threshold monitoring tells you when a new one is due.
Do this in test mode and live mode separately.
Plan switches go through the portal, not a second Checkout — the API refuses a Checkout for an organization that already has a Stripe subscription, because a second one would double-charge.
5. Verify
- Sign up a fresh account →
/settings/billingshows Free, one business slot, a 200-call MCP allowance and no countdown. GET /billing/plans(public, no auth) returns four plans with real amounts inprices.month/prices.year. Nulls there mean Stripe did not answer for that price id — wrong id, wrong mode, or the key lacks access.- Check out into Pro monthly with card
4242 4242 4242 4242. Within a couple of seconds/settings/billingshows Pro, five business slots and the client report entitlements. - Cancel in the portal → the organization returns to Free and keeps working. That is the intended end state: churn is a downgrade, never a lockout.
Production (live mode) status
Recorded 2026-09-23 (MARK-WJBGP2). Stripe stays the source of truth. This is the record of what was configured, not a second copy of the prices. The Stripe account is shared with other KeyesCode products, so filter by product.
| Plan | Product | Monthly | Annual (10 × monthly) |
|---|---|---|---|
| Starter | prod_V9UAhtnAkLMsEH | price_1U9BCWP82eSJup44WIVCkP1U — $49 | price_1UJ5P2P82eSJup44dhR3aT8v — $490 |
| Pro | prod_V9UBsnn5tjEHvo | price_1U9BD8P82eSJup44NzTthA4R — $149 | price_1UJ5Q5P82eSJup44NAPlS7Z5 — $1,490 |
| Agency | prod_VJitkg3eLTS5x6 | price_1UJ5RaP82eSJup442gt6L2zQ — $1,999 | price_1UJ5SUP82eSJup44WqyRBDGY — $19,990 |
- API env (Railway
@marketing-os/api): the monthly ids ride the legacy unsuffixed namesSTRIPE_PRICE_STARTER|PRO|AGENCY, and the annual ids are inSTRIPE_PRICE_*_ANNUAL.GET /billing/plansanswers all six. - Customer Portal: the account's default configuration
(
bpc_1UJ5Y9P82eSJup44QU30fiX8), which the API uses because it passes noconfiguration. It allows invoice history, name/email/address/phone/tax id updates, payment method updates, cancel at period end with a reason, and switching between exactly the six prices above, prorated. - Stripe Tax: active, head office in Florida, default tax code
txcd_10000000. No registrations: the business is not registered with the Florida DOR, and Florida generally does not tax SaaS, so Stripe computes $0 tax everywhere. Add a registration (§4a) once Stripe's threshold monitoring flags a state. - Still open: §5 steps 3–4 have not been run against live mode. One real
Starter monthly checkout on a throwaway organization, confirming the
subscriptionsrow flips, then cancel and refund in the dashboard. Portal cancels take effect at period end, so for an immediatecanceledrow, cancel from the dashboard.
Free trial
Every paid-plan Checkout (Starter, Pro, Agency) starts a 7-day trial run by Stripe — once per organization, ever (ADR 0070).
- Card up front. Checkout sends
subscription_data.trial_period_days: 7andpayment_method_collection: 'always', so the customer enters a card but is charged nothing. The subscription startstrialingand the organization gets the plan's entitlements straight away (trialinggrants, likeactive). - Convert or cancel. At day 7 Stripe charges the card and the webhook flips
the row to
active— no code of ours runs. Cancelling is the Customer Portal (§4): cancelled during the trial, the subscription ends and the organization is back on free with nothing charged. - Reminder. Three days before the end Stripe sends
customer.subscription.trial_will_end; the API mails the organization's owners and admins once per subscription (trial_endinginlifecycle_sends) with the plan, the charge date, the amount from the live Stripe price and a link to Settings → Billing. Double-check in the dashboard: Settings → Billing → Subscriptions and emails → Manage free trial messaging — thetrial_will_endevent goes out 3 days before the trial ends; Stripe's own trial-reminder email is optional on top of ours. - One trial per organization. The webhook stamps
subscriptions.trialUsedAtthe first time it sees atrialingsubscription; once set, Checkout omitstrial_period_daysand charges immediately. Checkout itself writes nothing. - Keep the trial off the Price. Do not set a trial period on the Stripe Price or product — the trial rides on the Checkout Session so the API decides eligibility. Affiliate commission (Rewardful) starts at the first paid invoice, i.e. day 7.
The no-card alternative (not built). To start trials without a card, the
Checkout Session would send payment_method_collection: 'if_required' and
subscription_data.trial_settings.end_behavior.missing_payment_method: 'cancel': a trial that ends with no card on file is cancelled, the webhook
projects customer.subscription.deleted, and the organization falls back to
free. We chose card-up-front because it converts on its own and "cancel" is
the portal that already exists.
Locally: with stripe listen running, check out a paid plan as a fresh
organization, then stripe trigger customer.subscription.trial_will_end (or
advance a test clock) to see the reminder path; a triggered event for a new
test customer logs that it matches no trialing plan.
What happens when payment fails
| Stripe says | What the customer gets |
|---|---|
invoice.payment_failed | everything, plus a banner, an owner email and a billing_payment_failed alert |
status past_due | everything, plus the banner |
invoice.paid | the banner goes away |
unpaid, canceled, incomplete_expired, paused | free-plan limits and entitlements, all data intact |
past_due is carried deliberately: Stripe retries a card for up to about three
weeks by default (Settings → Billing → Revenue recovery → Retries), and
downgrading someone over a hiccup is a worse failure than carrying them through
it. The downgrade is customer.subscription.deleted — what Stripe sends once
the retries run out, if the retry settings end in "cancel the subscription".
The failed-payment episode is projected onto subscriptions.paymentState
(ok | past_due) and pastDueSince:
- The first failure of an episode emails every organization owner
("Update payment method", linking to
/settings/billing). Retries fold into one standingbilling_payment_failednotification rather than a fresh email each time. - The dashboard banner has no dismiss. Admins get an Update payment method button straight into the Customer Portal; everyone else is told to ask one.
- A late
invoice.paidolder than the failure it would clear is ignored — webhooks arrive out of order.
Locally: with stripe listen running, stripe trigger invoice.payment_failed
sends a failing invoice for a brand-new test customer. That customer belongs to
no organization, so expect the API to log that the invoice matches no
organization and ignore it — the line proves the event reached the handler. To
see the banner, the invoice has to bill an organization's own test customer
(its subscriptions.stripeCustomerId).
Invoices in the app
GET /organizations/:orgId/billing/invoices (organization admins) lists the
last 24 invoices straight from Stripe — number, status, amount, hosted page and
PDF — cached for a minute. Nothing about an invoice is stored on our side.
Settings → Billing renders it as the Invoices table.
Something wrong or missing on this page? Tell us and we will fix it.