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.

PlanBusinessesSeatsMCP calls / monthIncludes
Free11—every dashboard and channel, the Terminal
Starter132,000always-on operator, MCP (connect an AI client)
Pro51010,000the above + client reports, client portal
Agency255050,000the 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

VariableWhat
STRIPE_SECRET_KEYsk_live_… (or sk_test_…)
STRIPE_WEBHOOK_SECRETfrom step 3 — whsec_…
STRIPE_PRICE_STARTER_MONTHLYthe Starter monthly price id
STRIPE_PRICE_PRO_MONTHLYthe Pro monthly price id
STRIPE_PRICE_AGENCY_MONTHLYthe Agency monthly price id
STRIPE_PRICE_STARTER_ANNUALthe Starter annual price id
STRIPE_PRICE_PRO_ANNUALthe Pro annual price id
STRIPE_PRICE_AGENCY_ANNUALthe 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.

  1. Stripe dashboard → Developers → Webhooks → Add endpoint
  2. URL: https://<api-host>/webhooks/stripe
  3. Events:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_failed — dunning: the banner, the owner email and the alert
    • invoice.paid — clears it
    • customer.subscription.trial_will_end — the "your trial ends in 3 days" mail (see Free trial)
    • invoice.upcoming — optional; logged and otherwise ignored
  4. Copy the signing secret into STRIPE_WEBHOOK_SECRET and 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_due subscription),
  • 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.

  1. Stripe dashboard → Settings → Tax → turn on Stripe Tax.
  2. Set the origin address (the business's own head-office address).
  3. Give each product a tax code (SaaS — business use is the usual one) or set a preset default for the account.
  4. 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

  1. Sign up a fresh account → /settings/billing shows Free, one business slot, a 200-call MCP allowance and no countdown.
  2. GET /billing/plans (public, no auth) returns four plans with real amounts in prices.month / prices.year. Nulls there mean Stripe did not answer for that price id — wrong id, wrong mode, or the key lacks access.
  3. Check out into Pro monthly with card 4242 4242 4242 4242. Within a couple of seconds /settings/billing shows Pro, five business slots and the client report entitlements.
  4. 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.

PlanProductMonthlyAnnual (10 × monthly)
Starterprod_V9UAhtnAkLMsEHprice_1U9BCWP82eSJup44WIVCkP1U — $49price_1UJ5P2P82eSJup44dhR3aT8v — $490
Proprod_V9UBsnn5tjEHvoprice_1U9BD8P82eSJup44NzTthA4R — $149price_1UJ5Q5P82eSJup44NAPlS7Z5 — $1,490
Agencyprod_VJitkg3eLTS5x6price_1UJ5RaP82eSJup442gt6L2zQ — $1,999price_1UJ5SUP82eSJup44WqyRBDGY — $19,990
  • API env (Railway @marketing-os/api): the monthly ids ride the legacy unsuffixed names STRIPE_PRICE_STARTER|PRO|AGENCY, and the annual ids are in STRIPE_PRICE_*_ANNUAL. GET /billing/plans answers all six.
  • Customer Portal: the account's default configuration (bpc_1UJ5Y9P82eSJup44QU30fiX8), which the API uses because it passes no configuration. 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 subscriptions row flips, then cancel and refund in the dashboard. Portal cancels take effect at period end, so for an immediate canceled row, 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: 7 and payment_method_collection: 'always', so the customer enters a card but is charged nothing. The subscription starts trialing and the organization gets the plan's entitlements straight away (trialing grants, like active).
  • 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_ending in lifecycle_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 — the trial_will_end event 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.trialUsedAt the first time it sees a trialing subscription; once set, Checkout omits trial_period_days and 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 saysWhat the customer gets
invoice.payment_failedeverything, plus a banner, an owner email and a billing_payment_failed alert
status past_dueeverything, plus the banner
invoice.paidthe banner goes away
unpaid, canceled, incomplete_expired, pausedfree-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 standing billing_payment_failed notification 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.paid older 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.