Billing (hosted deployments)
How plans, Stripe Checkout, and the webhook fit together on a hosted MepMail deployment, and how to provision a Stripe account for it.
Billing exists only when IS_CLOUD=true. A self-hosted instance has no
plans, no send caps, no Billing tab, and no webhook route — it never needs a
Stripe key. This page is for operating a hosted deployment.
Plans
One ladder, cheapest first. Free and Starter cap sends per UTC day; Pro and
Scale include a monthly volume per Stripe billing period and can bill overage
past it. Free holds up to 1,000 contacts; segments, topics and contacts are
unlimited from Starter up. Sending domains: 1 on Free, 10 on Starter, 25 on
Pro and unlimited on Scale. At either cap the API answers
403 plan_limit_reached ("Your plan allows up to 1000 contacts")
and the dashboard shows the same sentence.
| Rung | Plan | Price | Included | Cap | Overage per 1,000 |
|---|---|---|---|---|---|
free | Free | $0 | 100 | per day | — |
starter | Starter | $9 | 1,500 | per day | — |
pro_100k | Pro | $29 (launch offer; $20 grandfathered) | 110,000 | per month | $0.35 |
pro_200k | Pro | $55 | 220,000 | per month | $0.32 |
scale_500k | Scale | $129 | 550,000 | per month | $0.29 |
scale_1m | Scale | $239 | 1,100,000 | per month | $0.26 |
scale_1_5m | Scale | $349 | 1,650,000 | per month | $0.24 |
scale_2_5m | Scale | $549 | 2,750,000 | per month | $0.22 |
The ladder is PLAN_RUNGS in packages/core/src/plans.ts; Checkout, the
dashboard, GET /usage and the account mails all read it from there. The
CLI's migration report keeps a copy in packages/cli/src/report.ts (it runs
standalone against any instance), so a ladder change is mirrored there by hand.
A team row carries plan and, on a monthly plan, plan_quota (the included
volume it bought); together they name the rung.
Daily caps (Free, Starter)
The counter is the UTC day. Sends keep passing up to 50% past the cap before
parking, so a busy day is not cut off at the cap; emails over that ceiling
park as queued_quota and the 15-minute quota.drain job releases them after
midnight UTC. The API answers 429 daily_quota_exceeded only when the parked
backlog is full. Owners hear quota.warning at 80% of the cap,
quota.reached at the cap and quota.paused when parking begins, once per
UTC day; a plan upgrade releases parked mail within minutes.
Monthly volumes (Pro, Scale)
The counter is the Stripe billing period — the usage_periods table, keyed by
the team's current_period_start — with no tolerance. What happens at the
included volume depends on the overage switch in Billing, on by default
(the customer turns it off there):
- Overage off: the API refuses with
429 monthly_quota_exceeded("Monthly sending quota exceeded; turn on overage in Billing or wait for the period to renew on<date>"); nothing parks through the API. Broadcasts still park their overflow asqueued_quota, and the drain re-checks it against the period each run: it goes out when the period renews, when overage is turned on, or when the plan moves up. - Overage on: sends past the included volume are reported to a Stripe
meter and billed per 1,000 at the rung's rate on the next invoice (see
the overage cron) — up to a hard cap of 5× the
included volume (
OVERAGE_HARD_CAP), so a runaway integration or a stolen key can never run up an open-ended bill. At that cap the API refuses with the same429 monthly_quota_exceeded("Monthly sending quota exceeded: sends stop at 5 times the included volume even with overage on; the period renews on<date>") until the period renews.
Owners hear quota.warning at 80% and quota.reached at 100% of the included
volume, once per period; the reached mail says whether sends now bill overage
or are refused. There is no quota.paused on monthly plans. A scheduled send
counts against the period it is accepted in.
The Stripe model
- One product per paid plan (Starter, Pro, Scale), found again by
metadata.millionsend_plan. - One recurring price per rung, lookup key
millionsend_<rung>_monthly(millionsend_pro_100k_monthly, …), carryingmetadata.millionsend_rung = <rung>plus the plan, included volume, period and overage rate. - One meter, event name
emails_over_quota, summingvalueperstripe_customer_id. - One metered overage price per monthly rung, lookup key
millionsend_<rung>_overage, on that meter, priced per 1,000 emails rounded up (transform_quantity: { divide_by: 1000, round: "up" }).
The millionsend_ prefix in these keys and metadata is historical; it stays so
that prices and subscriptions created before the rename keep resolving.
Prices are found by lookup key, never by price id, so the same build runs
against any Stripe account (test or live) with no per-environment price
configuration. A subscription on a monthly rung carries the rung's price and
its metered price as a second item from Checkout on (the item's id is stored
in teams.stripe_overage_item_id); the metered item bills only what the
worker reports, so the customer's overage switch is a plain row flag,
teams.overage_enabled, which is what every send surface reads.
The flow
-
An owner or admin opens Settings → Billing and picks a rung. The server creates the Stripe Customer for the team (once, stored on the team with
metadata.team_id) and redirects to Stripe Checkout for that rung's price. -
Checkout collects payment, address, and tax id (automatic tax is on). Stripe redirects back to
/settings/billing. The redirect changes nothing — the page just polls for a few seconds. -
Stripe delivers
checkout.session.completed,customer.subscription.*andinvoice.*toPOST /api/billing/webhook. The handler verifies the signature on the raw body, records the event id (duplicates are acknowledged and ignored), re-fetches the subscription from Stripe, and only then writesteams.plan,plan_quota,current_period_start,current_period_end,stripe_overage_item_idandpending_rung. -
Switching rung happens in the dashboard (
billing.changePlan), and the direction decides when:- Up applies now: the subscription's items are updated — the plan item to the new rung's price, the metered item re-priced for a monthly rung or dropped for a daily one after its usage is reported — with the prorated difference charged at once (the switch applies only when that payment succeeds; a declined card keeps the current plan), and the webhook that follows re-applies the same state. Sends already accepted inside the old volume are marked settled on the period row, so the new rung never bills them as overage.
- Down applies at the period end, with no proration and no refund: a
Stripe subscription schedule is created from the subscription (or the
pending one reused) with two phases — the current items until
current_period_end, then the new rung's items — and the plan row does not move until the webhook applies the phase change. Until then the billing page shows "Moves to X on<date>" with a Keep<current>button: choosing the current rung releases the schedule, and so does a later move up.
The overage switch (
billing.setOverage) flipsoverage_enabled; off reports what is still unreported first. A subscription from before the ladder has no metered item; switching overage on adds it (off and back on, since the switch starts on). -
Manage billing opens the Stripe Customer Portal for the payment method, invoices, tax id and cancel at period end (the portal asks for a cancellation reason). Plan changes are not offered there: Stripe's portal cannot update a subscription with more than one item, and a monthly rung has two.
The plan columns are written from a subscription fetched from Stripe — by the webhook handler and by the two dashboard procedures above — never from a redirect, a client call, or an event payload taken at face value.
Entitlement rules
The rung is derived from the subscription re-fetched from Stripe at webhook time, so out-of-order deliveries converge on Stripe's current state. The subscription's non-metered item names the rung, tried in this order:
- the price's
metadata.millionsend_rung; - the price's lookup key (
millionsend_<rung>_monthly); - the product's
metadata.millionsend_plan, landing on that plan's first rung — this is how the two prices sold before the ladder resolve (millionsend_pro_monthly→pro_100k,millionsend_scale_monthly→scale_500k).
| Re-fetched subscription status | plan, plan_quota | plan_status |
|---|---|---|
active, trialing | The rung's plan and included volume (plan_quota is null on a daily rung). Unknown price: logged, nothing changes. | same |
past_due | Unchanged (payment grace; Stripe keeps retrying) | past_due |
unpaid, canceled, incomplete, incomplete_expired, anything else | free, null | unpaid / canceled / incomplete / canceled |
Additional rules:
- A non-entitling status for a subscription other than the one stored on the team is ignored, so a superseded subscription ending never revokes the current one.
- Events for a customer no team owns, or event types the handler does not
consume, are logged and answered
200so Stripe stops retrying them. - Stripe being unreachable or a database failure throws; the event row rolls back and Stripe's retry is processed normally.
billing.reconcilere-fetches every subscribed team's subscription from Stripe once a day, and once more each time the worker boots: a deploy that restarts the process while an event is mid-flight is caught up at once instead of hours later. A plan the reconcile moves is reported to the owners as the webhook would have.stripe_customer_id,stripe_subscription_id,current_period_start,current_period_end,stripe_overage_item_idandpending_rung(the rung of a pending schedule's last phase when it differs from the current one) are stored alongside the plan. A metered item priced for another rung is re-pointed to the rung's metered price as it is applied.
The overage cron
billing.overage runs in the worker every 10 minutes. For every period row
of a team with a metered item that has more sends past the included volume
than the meter already knows about (accepted − included − reported_overage),
it sends one meter event per team and period, in three statements so a crash
at any point costs nothing:
- the row pins the counter the event will advance to:
pending_overage = towherereported_overage = fromand no pin is set (a row another run pinned first is skipped); - the meter event goes out with identifier
<team>:<period start>:<from>:<to>(the period start as epoch milliseconds) and valueto − from; - the row catches up:
reported_overage = to, pending_overage = null.
A crash between the last two leaves the pin, so the next run re-sends the
same to under the same identifier and Stripe drops it as a duplicate; a
Stripe failure leaves the pin for the next run too. With overage off nothing
passes the included volume, so there is nothing to report; with it on nothing
passes 5× the volume, so a period bills at most four volumes of overage.
Usage of a period that already ended is stamped one second inside that
period, where Stripe invoices it (the invoice stays a draft for about an hour
after the period closes; rows older than 35 days can no longer be metered and
are logged).
The same report runs before the switch turns off, before a move up (sends
made under the old rung settle at its rate) and when the metered item leaves
the subscription, so nothing unbilled is lost.
Environment
IS_CLOUD=true requires all of these at boot (the process refuses to start
otherwise):
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe API secret key (sk_test_… / sk_live_…). |
STRIPE_WEBHOOK_SECRET | Signing secret of the endpoint pointed at /api/billing/webhook (whsec_…). |
STRIPE_PORTAL_CONFIG | Optional. Customer Portal configuration id (bpc_…); unset uses the account default. |
APP_BASE_URL | Public dashboard URL; Checkout and Portal return to {APP_BASE_URL}/settings/billing. |
KMS_KEY_ID | AWS KMS key for tenant secrets (hosted mode encrypts with KMS instead of MASTER_ENCRYPTION_KEY). |
Provisioning a Stripe account
One idempotent script creates everything the API can create. Amounts come from the ladder, not from flags:
STRIPE_SECRET_KEY=sk_test_… pnpm --filter @millionsend/billing provision \
--webhook-url https://app.example.com/api/billing/webhook \
--portal --app-url https://app.example.com| Flag | Effect |
|---|---|
--webhook-url | Find-or-create the webhook endpoint for this URL with exactly the events the handler consumes. Omit for local development. |
--portal | Find-or-create the Customer Portal configuration and print its id for STRIPE_PORTAL_CONFIG. |
--app-url | Dashboard origin: the portal's default return URL becomes <app-url>/settings/billing. Omit for Stripe's default. |
--move-legacy | Move every subscription still on a pre-ladder price to its rung (Pro 110K, Scale 550K) at once, without proration, adding the rung's metered item; a discount on the subscription stays. Idempotent. |
--dry-run | Read the account and print what would be written, without writing. |
What it does, and why re-running is safe:
- Products are found by
metadata.millionsend_plan(starter/pro/scale), created with the Stripe Tax code for SaaS business use. - The meter is found by its event name,
emails_over_quota. - Prices are found by lookup key. A changed amount in the ladder creates
a new price, moves the lookup key onto it, and archives the old one;
existing subscriptions keep their old price (still resolved by its
metadata), new checkouts get the new one. Metadata alone is refreshed in
place. Prices are
tax_behavior: exclusive. - Legacy prices
millionsend_pro_monthlyandmillionsend_scale_monthlyare archived, not deleted: subscriptions still on them keep working, resolved to the plan's first rung through the product's metadata, until each is moved; only new checkouts stop seeing them. - Webhook endpoint is found by URL; drifted event lists are re-synced.
The signing secret is printed once, at creation — Stripe never returns
it again. To rotate it, roll it in the dashboard (Developers → Webhooks →
the endpoint → Roll secret) and copy the new value into
STRIPE_WEBHOOK_SECRET. - Portal configuration is found by metadata and its settings refreshed:
the features (invoice history, payment method, customer details including
tax id, and cancel at period end with a cancellation reason collected;
subscription updates are off, see the flow), the business
profile's terms and privacy links (
mepmail.dev/terms,/privacy) and, with--app-url, the return URL.
Test and live are separate Stripe accounts: run once with each key.
Dashboard-only checklist
The script ends by printing these; the API cannot do them:
- Stripe Tax: enable it and add tax registrations for the jurisdictions you sell in (Settings → Tax). Checkout enables automatic tax, which fails without this.
- Business profile: legal name, support email/URL, and the statement descriptor customers see on card statements (Settings → Public details).
- Branding: logo, icon, and colors for Checkout, the portal, invoices, and emails (Settings → Branding).
- Customer emails: successful-payment receipts and failed-payment notices (Settings → Emails).
- Legacy subscriptions: a subscription on an archived price keeps it; move each one to its rung's price from the subscription page (no proration, at period end).
Migrating an existing deployment
Migration 0035_pricing_ladder adds the starter plan value, the teams
columns plan_quota, current_period_start, stripe_overage_item_id,
overage_enabled (default true) and pending_rung, and the usage_periods
table (accepted, reported_overage, pending_overage). Existing scale
teams get plan_quota 500000 and pro teams 100000 — volumes that match no
rung today, so each resolves to its plan's first rung (Scale 550K, Pro 110K);
current_period_start is backfilled as current_period_end − 1 month. Their
subscriptions stay on the legacy prices, resolved through the
product's metadata, until provision --move-legacy (or a manual update)
moves them; the first sync of such a subscription (the worker reconciles at
boot) adds the rung's metered item, so overage bills from the first period
after the deploy. The period counter starts
empty: sends accepted before the migration count against the day they were
sent, not against the period.
Local testing
Run the dashboard with IS_CLOUD=true and the test-mode secret key, then
forward Stripe's events to it with the Stripe CLI:
stripe listen --forward-to localhost:3009/api/billing/webhookstripe listen prints a whsec_… secret of its own — put that in
STRIPE_WEBHOOK_SECRET for the local process (no --webhook-url needed
when provisioning). Use the card 4242 4242 4242 4242 in Checkout, and
stripe trigger customer.subscription.deleted to exercise a downgrade. The
webhook route answers 404 when IS_CLOUD is not true, 400 on a bad
signature, and 200 for anything it has verified.