MillionSend Docs

Billing (hosted deployments)

How plans, Stripe Checkout, and the webhook fit together on a hosted MillionSend 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 and topics are unlimited everywhere, and so are contacts from Starter up. Sending domains are capped at 3 on Free and 10 on Starter, unlimited above. At either cap the API answers 403 plan_limit_reached ("Your plan allows up to 1000 contacts") and the dashboard shows the same sentence. A team adds up to 10 domains an hour, or 100 on Scale (DOMAIN_CREATES_PER_HOUR); the count restarts on every full hour (UTC), and past it POST /domains answers 429 rate_limit_exceeded with retry-after. Deleting a domain doesn't give its place in the hour back. Inboxes are capped at 3 on Free and 25 on Starter, unlimited above (see Inboxes).

A person owns up to 3 teams on Free, 5 on Starter, 10 on Pro and 15 on Scale, by the best plan among the teams they own (PLAN_TEAM_LIMIT); membership in someone else's team doesn't count. On Free, every team the person created in the last 30 days (TEAM_CREATE_WINDOW_DAYS) counts as well, deleted ones included, so deleting a team frees no place. Past the cap the dashboard refuses a new team and says why.

RungPlanPriceIncludedCapOverage per 1,000
freeFree$0100per day—
starterStarter$91,500per day—
pro_100kPro$20100,000per month$0.30
pro_200kPro$35200,000per month$0.30
scale_500kScale$75500,000per month$0.25
scale_1mScale$1401,000,000per month$0.20
scale_1_5mScale$2001,500,000per month$0.18
scale_2_5mScale$3302,500,000per month$0.16

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. A scheduled send counts against the UTC day it goes out: its own, or today for a scheduled_at already past (it sends at once). Rescheduling it to another day counts it against that day too, while the day it leaves keeps its count; if that day is full, the email parks until the drain finds room.

On Free the cap belongs to the person, not the team: every Free team one person created shares one daily count, kept by its creator (teams.pool_owner_id, recorded when the team is created) in free_pool_usage, so it holds 100 a day with the same 50% tolerance however many teams they have. Each team's own usage_counters keep counting for its metrics. A deleted team's sends stay counted, and the quota notices go out once for all of them. The same teams share one bounce and complaint record (see Guardrails), which also counts the bounces and complaints that arrive after a team is deleted, each complaint dated by the day its email was sent. Teams created before the creator was recorded get it when the worker starts: the user named in their team.created audit row, otherwise their earliest owner. A Free team then brings its counts from the last 7 days into the shared ones, so its bounce and complaint record and its day's count carry over.

On Free, mail to providers that report spam complaints also goes out to at most 10 such recipients at once, then 10 an hour, across every Free team the person created (FREE_BURST, kept per creator in free_pool_bursts). Complaints arrive minutes to days after a send, so a pause can act on the first ones before the day's mail has all left. Every such recipient counts, Bcc included. An email waiting its turn stays queued until the time it booked and checks its team's plan again at least once an hour, so if the team leaves Free it goes at the next check; an email that reaches its turn more than an hour late, because a pause held it or it was rescheduled, books a new turn. Turns go in the order mail reaches the worker, so an email sent after a broadcast waits behind the broadcast's mail. Recipients at Gmail, iCloud and T-Online, which report no complaints, and at the SES mailbox simulator never count, so mail only to them is never slowed, and neither is account mail.

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 with overage off or paused for a past-due payment; the period renews on <date>"); nothing parks through the API. Broadcasts still park their overflow as queued_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 same 429 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.

While a renewal payment is past due, overage pauses whatever the switch says: the team keeps its included volume through Stripe's retries, and sends past it are refused or park as with overage off until the failed invoice is paid. The payment-failed mail, the billing page and the quota mails say so, and the paid invoice releases parked broadcasts within minutes.

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.

On every plan, a sending domain under 30 days old also has its own daily cap (see Domains). A plan change never lifts it, and mail it holds parks as queued_quota without counting against the plan until the drain releases it.

Received mail

Teams that receive mail are counted once per message, not per recipient, in usage_counters.received and usage_counters_hourly.received and, on a monthly plan, usage_periods.received. What it costs comes from receivingRule in packages/core/src/receiving/rcpt.ts:

  • Free: received mail spends the day's sending counter (usage_counters.accepted, and the creator's shared free_pool_usage count), so sends and received mail share the cap and its 50% tolerance across every Free team that person created, and the quota notices count both. At the ceiling the MX defers new mail with 452 4.2.2 until midnight UTC.
  • Starter: free up to twice the daily cap (3,000) per UTC day, counted apart from sends; past it, 452.
  • Pro, Scale: free up to twice the included volume per period. Past that, every tenth received message adds one email to usage_periods.accepted, which the overage cron bills like a send. Past the allowance, received mail is deferred once accepted reaches the period's ceiling: 5× the volume with overage on, the volume itself with it off or paused for a past-due payment.

The MX asks at every RCPT TO, so a team out of room is deferred before the message is sent; the sender retries. Self-hosted instances count received mail and never cap it.

Inboxes

Inboxes cost nothing themselves; what they receive is counted as above. The plan caps how many a team keeps and how fast it creates them, from PLAN_INBOX_LIMIT and INBOX_CREATES_PER_HOUR in packages/core/src/inboxes/limits.ts:

PlanInboxesCreated per hourCatch-allShared addresses
Free310NoReply only
Starter2560YesStart conversations
Pro, ScaleUnlimited600YesStart conversations
  • At the inbox cap, POST /inboxes answers 403 validation_error with Resend's message, "You have reached the inbox limit of your plan. Upgrade to add more.", and the dashboard disables Create inbox with the same advice. Past the hourly rate it answers 429 rate_limit_exceeded with retry-after. The rate counts inboxes created, so deleting them doesn't reset it.
  • Catch-all receiving (receiving_catch_all on a domain) needs Starter or above: on Free, turning it on answers 403 plan_limit_reached ("Catch-all receiving needs a paid plan"). It stays on through a downgrade, and every domain that received mail before inboxes existed was switched on.
  • Addresses on the shared domain start conversations on a paid plan, where the instance allows it, and only reply inside their threads on Free. The plan is read at every send: after a downgrade a stored open rule reads as reply-only, without being rewritten, and applies again after an upgrade. Shared inboxes count toward the inbox cap until they're deleted, by you or after 90 days unused.
  • GET /usage reports the cap as limits.inboxes (null when unlimited), and the billing card shows it as "6 of 25".
  • Screening received mail for prompt injection keeps its own count (usage_counters.inbound_judged), apart from the samples of outbound content monitoring, under the same daily values: 600 per team and 50,000 per instance, plus 200 per inbox. Received mail never spends the outbound sampling caps.

Self-hosted instances have no inbox cap or creation rate, catch-all is open to every domain, and every team counts as paid for shared addresses: the operator's settings decide what they send.

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, …), carrying metadata.millionsend_rung = <rung> plus the plan, included volume, period and overage rate.
  • One meter, event name emails_over_quota, summing value per stripe_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" }).

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, paused while teams.plan_status is past_due.

The flow

  1. 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.

  2. 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.

  3. Stripe delivers checkout.session.completed, customer.subscription.* and invoice.* to POST /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 writes teams.plan, plan_quota, current_period_start, current_period_end, stripe_overage_item_id, pending_rung and move_up_pending_until.

  4. Switching rung happens in the dashboard (billing.changePlan), and the direction decides when:

    • Up waits for its own payment: the subscription's items are updated — the plan item to the new rung's price, the metered item re-priced for a monthly rung after its usage is reported — with proration_behavior: always_invoice and payment_behavior: pending_if_incomplete, so the prorated difference and the overage so far are invoiced and charged at once, and the new volume applies only once that invoice is paid. When the bank declines or asks for authentication the rung stays as it is, though a move down scheduled before is already dropped: Stripe holds the update for 23 hours, the dashboard sends the customer to the invoice's hosted page, and paying it there applies the move (the webhook writes the row); otherwise Stripe voids the invoice. Another move up, or a team delete, voids a waiting invoice before it reports anything, so that invoice cannot be paid later. 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) flips overage_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).

  5. 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.

Deleting a team cancels its subscription at once with a final invoice (invoice_now): the period's overage is reported first, and the invoice bills it with any proration still pending. Stripe keeps that invoice a draft for about an hour, so a meter event still being processed lands on it, and retries a failed charge on its usual schedule. The unused time is not refunded. A team suspended for fraud is the exception: its subscription ends with no final invoice, since everything it paid in the last 120 days is refunded.

A team cannot buy or move up while it is suspended (for any reason) or holds an open Trust & safety flag that an operator opened or reopened, whatever its kind: billing.checkout, a move up in billing.changePlan and switching overage on answer FORBIDDEN (upgrades_held), and the billing page says upgrades are unavailable, without saying why. An automatic flag holds nothing: it only asks an operator to look, and the content monitor's hold on abuse is a suspension. Reinstating a team clears the flag its suspension opened and leaves any other open flag as it is. Moving down, keeping the current rung, switching overage off and Manage billing (where cancelling lives) stay open. The configuration --portal provisions offers no plan switch at all; the account's default configuration may, so set STRIPE_PORTAL_CONFIG.

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:

  1. the price's metadata.millionsend_rung;
  2. the price's lookup key (millionsend_<rung>_monthly);
  3. 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 statusplan, plan_quotaplan_status
active, trialingThe rung's plan and included volume (plan_quota is null on a daily rung). Unknown price: logged, nothing changes.same
past_dueUnchanged (payment grace; Stripe keeps retrying); overage pauses until the invoice is paidpast_due
unpaid, canceled, incomplete, incomplete_expired, anything elsefree, nullunpaid / 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 200 so 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.reconcile re-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_id and pending_rung (the rung of a pending schedule's last phase when it differs from the current one) and move_up_pending_until (when Stripe drops a move up still waiting on its payment) 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:

  1. the row pins the counter the event will advance to: pending_overage = to where reported_overage = from and no pin is set (a row another run pinned first is skipped);
  2. the meter event goes out with identifier <team>:<period start>:<from>:<to> (the period start as epoch milliseconds) and value to − from;
  3. 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). While a move up waits on its payment, the team's running period is skipped (an ended period's invoice cannot wait): Stripe bills nothing reported on the old price between the request and the payment, so the wait's sends are reported once the move applies (against the new volume) or once Stripe drops it unpaid after 23 hours (against the old one). The same report runs before the switch turns off, before a move up (sends made under the old rung settle at its rate), before a team is deleted (its cancellation carries a final invoice) and when the metered item leaves the subscription; the last three run during a wait too, since each ends it. Stripe drops a meter event it has not counted by the time a move up re-prices the metered item, and counting takes about a minute, so a move up first compares the period's reported overage with Stripe's meter summaries: while more than 1% of the volume (at least 1,000 emails) is still uncounted, or a step the meter rejected still waits to be sent again, billing.changePlan refuses with CONFLICT (overage_in_flight) and the dashboard asks to try again in a minute. Each attempt first reports what was sent since the last report, so while a team sends more than that margin each minute, the move waits for sending to slow. A team delete refuses the same way when the meter rejects the report it makes, since the cancel would delete the rows that owe it.

Environment

IS_CLOUD=true requires all of these at boot (the process refuses to start otherwise):

VariablePurpose
STRIPE_SECRET_KEYStripe API secret key (sk_test_… / sk_live_…).
STRIPE_WEBHOOK_SECRETSigning secret of the endpoint pointed at /api/billing/webhook (whsec_…).
STRIPE_PORTAL_CONFIGOptional. Customer Portal configuration id (bpc_…); unset uses the account default.
APP_BASE_URLPublic dashboard URL; Checkout and Portal return to {APP_BASE_URL}/settings/billing.
KMS_KEY_IDAWS 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
FlagEffect
--webhook-urlFind-or-create the webhook endpoint for this URL with exactly the events the handler consumes. Omit for local development.
--portalFind-or-create the Customer Portal configuration and print its id for STRIPE_PORTAL_CONFIG.
--app-urlDashboard origin: the portal's default return URL becomes <app-url>/settings/billing. Omit for Stripe's default.
--move-legacyMove every subscription still on a pre-ladder price to its rung (Pro 100K, Scale 500K) at once, without proration, adding the rung's metered item; a discount on the subscription stays. Idempotent.
--dry-runRead 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_monthly and millionsend_scale_monthly are 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 (millionsend.com/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 map to Scale 500K (plan_quota 500000) and pro teams to Pro 100K (100000); 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/webhook

stripe 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.

On this page