Billing
Stripe Checkout + Billing Portal. Plan state mirrored to organizations.plan_status.
Plans
From src/lib/billing/plans.ts:
| Plan | Price | Emails/mo | SMS/mo | Overage |
|---|---|---|---|---|
free |
$0 | 3,000 | 500 | hard limit |
payg |
$5 + usage | 0 included | 0 included | email $0.0004 ($0.40/1k), SMS $0.0075 |
pro |
$39 | 50,000 | 5,000 | email $0.0003 ($0.30/1k), SMS $0.0056 |
enterprise |
custom | ∞ | ∞ | — |
Pro's reprice (from the 2026-11 period)
Pro stays $39/month; from usage period 2026-11 (PRO_REPRICE_FIRST_PERIOD)
it includes 50,000 emails and 5,000 SMS segments, and its overage is metered
about 25% below Pay as you go ($0.30 vs $0.40 per 1,000 emails, $0.0056 vs
$0.0075 per segment). Every period before that keeps the terms it was sold
under — 10,000 / 1,000 at Pay as you go's rates (PRO_LEGACY_TERMS).
That split is enforced, not just documented. PLANS holds only today's terms;
everything that measures or costs a period asks usageTermsFor(plan, period)
with that period's key — report-overage and settlement
for a closed month, the plan-allowance alerts, the spend cap, GET /v1/usage
and the billing page's usage bars for the open one. So a month that closed
before the reprice is billed above 10,000 emails even when the cron runs
afterwards, and is never re-priced to the bigger allowance.
The rate reaches Stripe through its own prices. A Stripe price is a per-unit rate, and a meter bills each event through whatever price reads it on the day the event lands — so changing the price on the shared meter would have re-priced the closed month the cron meters on the 1st. Instead each rate has its own price and its own meter — a rate line:
| Line | Who bills on it | Meter env | Price env |
|---|---|---|---|
standard |
Pay as you go; Pro before 2026-11 | STRIPE_{EMAIL,SMS}_OVERAGE_METER_EVENT |
STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_ID |
pro |
Pro from 2026-11 | STRIPE_PRO_{EMAIL,SMS}_OVERAGE_METER_EVENT |
STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_ID |
report-overage meters a period on the line its priced plan carried then, so
October's Pro overage reported on 1 November still lands on the standard meter
at the old rate, while November's lands on the Pro meter. A Pro subscription
started before the reprice is opened (and repaired) with both lines' prices
— a metered item with no usage invoices nothing, and the two read different
meters, so nothing bills twice. A repriced Pro month whose Pro meter or price is
unset is never metered on the standard line instead (that would bill
Pay as you go's rate): it is left unclaimed and re-tryable, and either gap
fails the run — an unset meter as skipped_no_meter, an unset Pro price beside
a priced standard line as skipped_unbillable / line_price_unset (half-
configured, so never mistaken for the quiet flat-rate skip). Each line's meter
event name is also checked against the event_name of the meter its price
reads; a mismatch is refused as meter_event_mismatch rather than billed at
the other line's rate.
Which is cheaper. Pro is not cheaper merely because you fill its allowance:
50,000 emails cost $25 on Pay as you go against Pro's $39. On email alone Pro
becomes cheaper above 190,000 emails a month; on SMS alone above 4,533
segments; mixed traffic crosses over sooner. /pricing computes these from the
rate table (proBreakEven in plan-terms.ts).
enterpriseis no longer sold. Pay as you go is the self-serve tier above Pro. The id remains valid — existing contracts sit on it, and a comped owner resolves to it — it is simply not offered.
Pay as you go
A small platform fee plus per-unit metering with no included allowance. The two
channel limits are 0, not Infinity, and the distinction is load-bearing:
report-overage bills max(0, used - limit) and skips an org whose overage is
zero, so Infinity would silently drop every PAYG org out of the billed set.
Zero is an allowance, never a cap. Only free is hard-capped
(evaluateUsageLimit), so a PAYG send is always allowed and always billed.
Those 0s stay internal to PLANS, enforcement and the overage cron —
GET /v1/usage never ships one. A public limits value is a cap, and PAYG
has none, so both channels come back as null: plan: "payg" with
limits: { "email": null, "sms": null }. Shipping the raw sentinel read as a
breached quota to every client that gates on used < limit — the SDKs, the
remaining-budget recipe, any used/limit bar — on the one tier where no send is
ever refused. plan is what says why there is no cap: metered from the first
unit here, unmetered on enterprise.
Resource caps are Pro's, deliberately not Enterprise's Infinity: usage is what this tier uncaps, and usage is metered — IP pools and team seats cost real money, so they stay bounded on a self-serve tier. The two exceptions are phone numbers and dedicated IPs, where Pay as you go holds fewer than Pro (10 vs 25 numbers, 2 vs 5 IPs) — see resource charges.
Which tier a subscription is comes from its flat price
(STRIPE_PAYG_PRICE_ID vs STRIPE_PRO_PRICE_ID, resolved by
src/lib/billing/plan-price.ts). The metered prices are per rate line, not
per tier — Pay as you go shares the standard line with pre-reprice Pro (see
above). A subscription whose
price matches neither resolves as Pro: that is the behaviour from before this
tier existed, and it under-bills rather than over-bills. Checkout stamps the tier
into the session metadata, but checkout.session.completed writes the tier off
the session's subscription, read live — the metadata goes stale the moment the
customer switches that subscription between Pro and PAYG.
Default on signup: free.
SMS is metered per segment. The
SMS/mofigure is a segment allowance, not a message count — a long SMS spans multiple 140-byte segments and is counted against the cap (and Pro overage) per segment. See usage-limits.md.
Resource charges
Usage is not the only cost. A leased number bills at AWS every month whether it
sends or not, a 10DLC campaign carries a monthly carrier fee, and a dedicated IP
is billed per IP per month. These are charged on Pro and Pay as you go, on top
of usage, from src/lib/billing/resource-prices.ts:
| Resource | Price | Counted |
|---|---|---|
| Phone number, US/CA long code or 10DLC | $1.50 / month | phone_numbers, kind = 'number', type = 'longcode' |
| Phone number, toll-free | $3.00 / month | type = 'tollfree' |
| Phone number, any other country | $1.50 / month | (only US/CA are provisionable today) |
| 10DLC brand registration | $10.00 once | the month the brand was filed (created_at) |
| 10DLC campaign | $15.00 / month | campaigns.kind = '10dlc', not failed |
| Dedicated IP | $35.00 / month per IP | ip_pools.ip_count (the quantity requested), summed, clamped to the plan's dedicatedIps cap |
Dedicated IPs are billed on what the customer asked for. Pools are created
in SES's MANAGED scaling mode, which owns the IP count: AWS reports 0 for the
24–48h a pool provisions and may scale past the request later. ip_count is set
at create and the ip-pool-warmup cron no longer rewrites it (it logs
cron.ip_pool_warmup.ip_count_drift when AWS's count differs). The billed
quantity is further clamped to the period plan's dedicatedIps cap across the
org's pools, oldest first, so AWS's scaling can never bill past what the plan
sells. Every writer applies the same org-wide clamp: a pool deleted mid-month is
priced beside the pools still held (so an older pool keeps its place in the
queue), and the IPs a released pool already put on the ledger for a period count
against the cap the survivors are clamped to — by the cron, a later release and
churn settlement alike. A downgraded org deleting one of its pools is therefore
billed at most the cap for the month, not the cap once per writer. The released
pool's IP count is read back from its ledger line as cents ÷ today's IP price
(the ledger stores no quantity), and every status counts — a failed line is
re-fired by the retry cron rather than dropped. So a price change mid-period,
or a released-pool line Stripe keeps refusing, leaves the survivors clamped
lower than what was actually invoiced: the error only ever under-bills.
No proration. A resource held at any point in a calendar month (UTC) is
billed for that whole month: one leased on the 31st costs the month, and so does
one released on the 1st. created_at is the only timestamp these rows carry and
a released row is hard-deleted, so there is nothing finer to prorate from.
Never billed: Free (no subscription to bill — it is capped at 1 number, 0
dedicated IPs, 1 10DLC brand and 1 10DLC campaign instead), Enterprise (a contract), a comped
owner, a test-key resource (simulated test-reg-… provider id), an alphanumeric
sender ID (not a number), a short code (not on the price list — not
provisionable through the API), a toll-free campaign (toll-free verification
carries no monthly fee), and a registration that never reached the registry.
Eligibility is per period, by planForPeriod — the same rule overage uses.
Caps. Absolute, per plan, enforced at create with the usual 402
PLAN_RESOURCE_LIMIT (checkResourceLimit, kinds phoneNumbers,
dedicatedIps, brands and campaigns), counting live resources only:
| Plan | Phone numbers | Dedicated IPs | 10DLC brands | 10DLC campaigns |
|---|---|---|---|---|
free |
1 | 0 | 1 | 1 |
payg |
10 | 2 | ∞ | ∞ |
pro |
25 | 5 | ∞ | ∞ |
enterprise |
∞ | ∞ | ∞ | ∞ |
Brands and campaigns are capped on Free only: a paid plan is billed each registration, so its count is bounded by the bill (and the hourly provisioning limiter), while Free is never billed and was otherwise unbounded. One of each is enough to send US long-code SMS end to end — the feature stays open on Free, only its volume is capped. Both caps are per org, not per tenant.
Free creation budget. Numbers, brands and campaigns are hard-deleted on
release, so a held-count cap alone let a Free org lease, release and re-lease
(or file, delete and re-file) indefinitely, each a real provider charge
Sendoka absorbs. Free therefore also allows 3 live number leases, 3 live
brand filings and 5 live 10DLC campaign filings per rolling 30 days (campaigns get
two more because a rejected one can only be re-filed, never corrected in place), released
ones included (FREE_CREATION_BUDGET in src/lib/api/resource-limit.ts,
counted from phone_number.provisioned / brand.created /
campaign.created audit rows, written before the create answers, the
reconcile cron's toll-free lease included — test-key simulations,
alphanumeric sender IDs and toll-free verifications excluded). Over it the create answers the same 402 PLAN_RESOURCE_LIMIT:
Free plan allows 3 new phone numbers per 30 days, released ones included (3/3 used). Upgrade for more. Paid plans are billed per resource and have no
budget. brands
counts every brand row whose registration id is not a test key's test-reg-…
— including a brand filed only to back a toll-free verification, which is the
same billed filing — except a failed row with no registration id at all (the
filing errored and the reconcile cron gave up: never billed, not PATCHable).
campaigns counts 10DLC rows the same way, minus every failed one, which
billing skips too (toll-free verifications are neither billed nor capped).
POST /v1/brands, POST /v1/campaigns (live keys, 10DLC only) and their
dashboard twins check them ahead of the provisioning limiter.
These caps, like phoneNumbers, are read-then-write: the count is taken,
then the billed (non-idempotent) registry call is made, then the row is
inserted. Two concurrent creates from one Free org can both see the slot free
and both file. The hourly provisioning limiter (10/hr/org) is the only other
bound, so the worst case is a handful of unbilled filings an hour, which
report-overage then lists under resources.over_cap_free. A per-org lock
would have to span the provider call and every early return on four routes;
it is not taken.
POST /v1/phone-numbers (live keys; a test key leases nothing and is not held
to it) and the dashboard create check phoneNumbers; IP-pool create checks
dedicatedIps against the whole ip_count; assigning a domain to a pool is
refused while the org is over its IP cap (a downgrade), and unassign and pool
delete stay open so it can get back under. IP_POOL_MAX_IPS_PER_ORG is now an
optional operator kill-switch that can only lower a finite plan cap; unset adds
none there. Enterprise and comped owners (dedicatedIps: ∞, never billed for
IPs) keep the old ceiling of 2 while it is unset; setting it raises or
lowers theirs.
Downgrades keep what they hold — reported, not billed. Caps bind at create,
and nothing releases a resource when an org drops to Free. A Pro org that leases
25 numbers and 5 IPs and then cancels keeps them on Free, where nothing is
charged. report-overage lists every org whose closed period resolved to Free
while it held more numbers, IPs, 10DLC campaigns or brands (filed that month)
than Free allows under resources.over_cap_free
(and cron.overage.free_over_cap_holdings, with the monthly cost going
unbilled), so ops can release them or bill by hand. It does not fail the run.
Releasing or auto-billing them is a pending product decision.
How it reaches Stripe. Invoice items with an explicit amount in USD — no
Stripe Price to create. Three writers, one ledger:
report-overage(1st–5th) bills the closed month for every resource still held, as pending invoice items on the org's subscription (the same onepickTargetchooses for the metered prices); they ride its next invoice. Orgs with no overage are included. No subscription → nothing claimed (a churned customer was billed by settlement); two live subscriptions →unbillable, retried and loud.- Release — every DELETE of a number, campaign, brand or IP pool (v1 and
dashboard) bills that row for the current month, and the previous one if the
1st's run has not yet, before the row is deleted. Without this a resource
created and released inside one month was free. Best effort: logged
(
billing.resources.release_unbilled), never blocks the release. One deliberate carve-out:discardLeasedNumber(src/lib/api/tollfree-legacy.ts) releases and deletes a toll-free number leased inside a request that then failed — the lease that lost the bind race, one whose bind threw, or one whose campaign create never committed — without billing it. The customer never saw that number (no response named it, no webhook fired) and the request answered with the winner's number or an error, so a charge would be for something they could not have used. Sendoka absorbs the provider's cost for those seconds. - Settlement (cancel, org delete) puts the current month's lines, and any unbilled previous-month ones, on its own invoice — see below.
Each line is an overage_reports row with channel = resource:{kind}:{id} and
value = cents, claimed with the same INSERT … ON CONFLICT … WHERE status = 'failed' as overage, so no writer bills a line twice. reported rows carry the
invoice item (ii_…) or invoice (in_…) id. A definite Stripe refusal releases
the row to failed; an unknown outcome leaves it pending for a human, like
overage. Every report-overage firing retries failed resource rows from the
ledger row alone (retryFailedResourceLines — kind and id from the channel,
cents from value), which is the only way a line refused at release time is
ever billed: its resource row is gone. Resource rows are listed apart from
overage in the run's response (resources.unresolved, not unresolved_prior),
so a backlog of them cannot crowd overage out of that list.
Ending subscriptions. A subscription set to cancel at period end
(cancel_at_period_end or a dated cancel_at — what the portal's cancel flow
does) raises no further invoice, so a pending item attached to it would never be
collected. Writers 1 and 2 therefore put lines for such a customer on a
standalone invoice of their own (draft, pending_invoice_items_behavior: "exclude", finalized), not on the subscription. Items attached before the
cancel are swept on every customer.subscription.deleted
(sweepOrphanedResourceItems): re-created on one finalized invoice, then the
stranded originals deleted and the ledger row pointed at the new invoice. The
sweep runs whether or not the customer still holds another live subscription
— it only touches items whose subscription is no longer live — so items left
on a subscription that died beside a second one are collected too.
Wall clock. The cron checks its deadline before every claim; lines it does
not reach are resources.deferred, untouched, and the run reports complete: false (500). A page whose resource pass throws is caught and counted
(resources.page_errors, 500) — overage metering for later pages carries on.
Rollout. RESOURCE_BILLING_FIRST_PERIOD (YYYY-MM) is the first month
charged. Unset, nothing is billed — the price list must not reach back over
months in which it was not published. The cron reports
resources.off: "epoch_unset" (a warning, not a 500).
Not covered by the spend cap. spend_cap_cents bounds metered usage;
resource charges are bounded by the caps above instead.
Residual gap. The orphan sweep runs once per customer.subscription.deleted
and lists one page (100) of the customer's pending items. A sweep that failed
(logged billing.resources.orphan_*) is not retried automatically; reconcile
from overage_reports (resource:%, ii_…) against Stripe's pending items.
Spend cap
An owner-set monthly ceiling on metered cost, in USD cents:
organizations.spend_cap_cents. null (the default) is no cap; 0 is a valid
kill switch that refuses every metered send.
What it measures. Month-to-date metered cost, computed exactly the way
report-overage bills the month: max(0, used − allowance) per channel at the
plan's overage rate, both read for the current period through usageTermsFor()
— so a Pro month before the reprice
is costed on the old allowance and rates (meteredCostCents() in
src/lib/api/usage-limit.ts).
On Pro that is overage only — the included allowance is free and stays free; on
Pay as you go every unit is metered from the first — except in the month a Free
org upgrades, which keeps Free's allowance exactly as report-overage does
(currentAllowancePlan() in src/lib/api/plan-history.ts), so the cap, its
alerts and the billing page's "spent" figure all match the invoice. Free and
Enterprise carry no overage rates, so their metered cost is always $0 and a cap
never binds there.
Enforcement. Inside the existing limit-check helpers (enforceUsageLimit,
checkBatchUsage), so every v1 send path — single, batch, audience blast — is
gated with no route wiring. The one exception is the dashboard blast
(/api/internal/audiences/{id}/send), which calls checkUsageLimit /
evaluateBatchUsage directly instead of going through checkBatchUsage and so
wires the cap by hand — the same carve-out its per-tenant cap already needed.
A live send whose units would push month-to-date
metered cost strictly past the cap is refused with 402 SPEND_CAP_EXCEEDED (standard error envelope, type: "rate_limit_error");
batches are measured by their whole unit span (SMS in segments). The send that
lands exactly on the cap is the last one allowed, and a send with no metered
cost — a Pro org inside its included allowance — is never refused, because it
cannot move the number the cap protects. Test-environment sends are exempt like
every other usage gate. Cost of the gate: the plan-cap query reads both
channels' usage rows off the same index (at most one extra row), plus one
primary-key SELECT on organizations for the cap value and, once a cap is
set, the org's org_plan_events rows, paid only on metered tiers.
At fire time. The enqueue gate reads the period counters, which a row
sitting in scheduled does not advance — so a ramp or a blast accepted under
the cap would otherwise fire and meter straight past it, and a cap set to 0
mid-ramp would stop nothing. The send-scheduled cron therefore seeds a per-org
spend budget for every batch it claims (the same evaluateSpendCap arithmetic,
over a projected copy of the counters that every reservation in the batch
advances) and defers any row that would cross it: back to scheduled,
scheduled_at advanced from the row's own slot by whole 60-minute intervals, a
scheduled status event on the first deferral only, no message.failed.
Deferred rather than canceled because the cap is a ceiling the owner can raise;
0 pauses every metered row an hour at a time until it moves. The first
deferral in a batch fires the same 100% spend_cap alert an enqueue-time
refusal does — a ramp accepted before the cap started binding never crossed a
counter, so this is the only notification the owner gets. See
scheduled-sends.md.
The dashboard resend (/api/internal/messages/{id}/resend) runs the same
enforceUsageLimit gate as the v1 routes, in the units its clone will meter,
so it answers 402 with the spend-cap message too (unwrapped into the internal
{ error } envelope, with the code alongside).
Setting it. Owners only, on /overview/settings/billing (the card also
shows month-to-date metered spend) or via PATCH /api/internal/billing with
{"spend_cap_cents": <int ≥ 0 | null>} — the field must be explicitly present
(an empty body is a 422, never an accidental un-capping), null removes the
cap, and the write is audited as org.spend_cap_updated. Enforcement reads the
column per send, so changes take effect immediately.
Usage alerts (80% / 100%)
Crossing 80% or 100% of (a) the plan allowance per channel or (b) the spend cap
emits one usage.alert webhook and one email to the org
owner. Evaluation runs after every successful live usage increment, and a
refusal fires the 100% alert too — the increment that would have crossed the
line never happens, so an org whose counter can only overshoot (a spend cap a
batch can only leap past) would otherwise never hear that its sends are now
refused.
A refusal only announces when the line is genuinely reached: the channel's
counter sits at or past its allowance, or one more unit on that channel would be
refused by the cap as well (SpendCapDecision.capReached). A send refused just
for being too big for what remains is not a crossing — a 100/3,000 org
refused a 5,000-item batch, or a $50 cap with 10¢ spent refusing a 200k blast.
Announcing those would mail a self-contradictory figure ("at 100% … has used 100
of its 3,000") and take the ledger key below, silencing the real crossing for
the rest of the period.
Dedup is exclusively the usage_alerts ledger: emitters
INSERT … ON CONFLICT DO NOTHING against the unique
(org, period, channel, kind, threshold) key and only the caller whose row was
actually inserted announces, so an alert fires at most once per key per period —
concurrent sends racing past a threshold cannot double-fire, and moving the cap
does not re-fire thresholds already recorded for the period. Plan-allowance rows
carry the allowance in the ledger channel (email:50000; the Free allowance
keeps the bare email / sms), so an org that changes plan mid-month is
alerted at 80% and 100% of its new allowance even after crossing the old one. Alert failures are
logged (usage.alert_* events) and never fail the send they rode in on.
Plan-allowance alerts need a finite non-zero allowance (so none on PAYG's
zero-allowance tiers or Enterprise); spend-cap alerts need a positive cap on a
metered tier. For kind: "spend_cap", used/limit in the event are integer
cents.
Upgrade flow
- User clicks Upgrade →
POST /api/internal/billing. - Server finds or creates a Stripe customer (
ensureOrgStripeCustomer: idempotency keyorg-customer-{orgId},metadata.orgIdon the customer) and persistsstripe_customer_idonly while it is still null, so two overlapping clicks cannot leave the org on a customer other than the one its Checkout was opened on. - Creates Checkout Session with the tier's flat price (
STRIPE_PRO_PRICE_ID/STRIPE_PAYG_PRICE_ID) — plus the metered overage prices of every rate line the tier bills through this period and next, when set:STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_IDfor Pay as you go (and for Pro before its reprice),STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_IDfor Pro. That is what makes the meters below actually bill (a meter invoices only through a price on the subscription; the flat price alone leaves every meter event accumulating against the customer for nothing). Metered prices carry noquantity— the reported usage is the quantity. Then:success_url:/overview/settings/billing?upgraded=truecancel_url:/overview/settings/billing
- Returns
{ url }— client redirects.
Inbound webhook — /api/webhooks/stripe
Signature-verified via stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET).
Five events, matched to the org by stripe_customer_id — falling back to
metadata.orgId (session, subscription, else customer) only when no org stores
the customer and the named org holds none or the same one (see
Stripe → Metadata):
| Event | Effect |
|---|---|
checkout.session.completed |
plan_status = the tier the session's subscription holds now (its flat price via planForSubscriptions, not metadata.plan), only while that subscription is still active, trialing or past_due (read live, so a late re-delivery cannot restore a canceled tier or undo a Pro/PAYG switch); an unpriceable subscription writes nothing |
customer.subscription.created |
Re-derive plan_status from the customer's live subscriptions, and trace a resume into an already-settled period (the remainder is billed as a report-overage top-up) |
customer.subscription.updated |
Same — this is how a move between Pro and Pay as you go is announced |
customer.subscription.deleted |
Re-derive — free when nothing billable is left — and, in after(), settle the final usage when no live subscription remains, then sweep the dead subscription's orphaned resource items (always) |
invoice.payment_failed |
Logs an error (TODO: dunning email via own API) |
The three subscription events share one handler and never trust the event's own
status snapshot: Stripe does not guarantee ordering, so the tier is re-derived
from a fresh subscriptions.list. That is what stops a delayed updated
(active) delivered after deleted from restoring a paid plan on a canceled
org, permanently.
Every write goes through recordPlanChange, so organizations.plan_status and
the org_plan_events history move together — the history is what lets
report-overage and settlement price a closed month by the plan actually in
effect then. enterprise is preserved: it is set out-of-band by contract and
the webhook only knows pro / payg / free.
Final usage settlement
report-overage bills a closed month on the 1st, by metering it to a price
on the customer's live subscription. Both halves stop working the moment
that subscription ends: the cron answers no_subscription, claims no ledger
row, and everything sent between the 1st and the cancel is never billed. Left
alone, every Pay-as-you-go cancel got its final month free, and an org deleted
from the dashboard took its unbilled month with it.
settleFinalUsage({ orgId, customerId, reason }) (src/lib/billing/settlement.ts)
bills that tail through a one-off invoice, which needs no subscription.
What it prices. The current period always, and the previous one when the
ledger holds no reported row for it (a cancel on the 2nd can beat the
1st-through-5th cron, or arrive after it answered no_subscription). The
allowance comes from planForPeriod — the plan actually in effect then, not
the free the downgrade is about to write.
Resource charges ride the same invoice. The current month's
resource lines, and any of the previous month's the cron has
not billed, are claimed under the same ledger rule and added as line items — on
org delete the resource rows are hard-deleted right after, so this is the last
writer that can see them. They are priced in USD, so with overage prices in
another currency they are left unclaimed and billing.settlement.resource_currency_conflict
is logged. With no overage owed at all, the invoice is raised for the resources
alone, in USD.
At what rate. The unit rate is read off the same Stripe price the monthly
meter bills through — the period's rate line:
STRIPE_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pay as you go and pre-reprice Pro,
STRIPE_PRO_{EMAIL,SMS}_OVERAGE_PRICE_ID for Pro from 2026-11 — whose
unit_amount_decimal is in cents and is fractional (email is 0.04¢ a send on
the standard line). A leaving Pro customer settling October and November
together gets each month on its own terms: October above 10,000 at the
standard price, November above 50,000 at the Pro price. Not PLANS[...].overage, which
plans.ts states is a display value. Two things follow, both deliberate:
- A channel with no configured price is skipped and left unclaimed, exactly
as
report-overageleaves it. Unset is the supported flat-rate deployment that bills no overage at all; invoicing it here — on the way out, and retroactively for two months — would charge a customer for something they had never once been billed monthly. The exception is a price unset on the period's line while another line prices the same channel (line_price_unset): half-configured, so it is logged as an error — the tail is still forfeited. - A tiered price, or one carrying
transform_quantity, is refused (price_not_per_unit, logged) rather than approximated. The approximation would be an over-bill, and Stripe prices are immutable, so a rate change mints a new price and repoints the env var while the plan matrix stays put.
The invoice is raised in the price's own currency, checked against the
currency Stripe has pinned on the customer. It does not read the billing card's
month-to-date figure, which is still the spend cap's meteredCostCents
estimate off the plan matrix; where a deployment's Stripe rate and its plan
matrix disagree, the invoice follows Stripe.
The floor. Below Stripe's minimum charge — 50 cents — nothing is invoiced.
A finalized invoice under it does not collect a small amount, it fails to
collect: the payment is rejected as amount_too_small, the invoice sits open,
dunning starts over a few cents, and the ledger rows are already terminal so
nothing revisits it. A Pro org a few hundred emails past its allowance is the
common churn case, so the tail is written off (waivedCents) and the period is
still closed.
Why it cannot double-bill. It claims overage_reports rows with the same
INSERT … ON CONFLICT (org_id, period, channel) DO UPDATE … WHERE status = 'failed' statement the cron uses. That shared ledger is the entire guard: a
period settled here reads reported to the cron on the 1st, and a period the
cron already metered is skipped here. A row stuck in pending — an earlier
writer that died before Stripe answered — is never re-fired by either.
Outcomes.
| Situation | What happens |
|---|---|
| Overage owed | Draft invoice → one line item per (period, channel) → finalized with auto_advance: true; the ledger rows close as reported carrying the in_… id |
| Nothing owed, or under 50¢ | No invoice. The rows still close as reported — the period is settled, and the cron must not revisit it for a customer who has left |
| Free plan, comped owner, unrecorded upgrade | Nothing claimed, nothing billed (planForPeriod refuses) |
| No overage price for the channel | Nothing claimed, nothing billed — the flat-rate deployment, same answer the cron gives |
| Stripe is unreachable | Claims are released to failed (re-claimable) and the error is thrown. A stranded draft cannot charge on its own — auto_advance is off until finalize — and is deleted; when Stripe's state cannot be read back the rows are left pending for a human |
| Stripe rejects — a 400 or a 403 | blocked is set, the claims are released, billing.settlement.blocked is logged and nothing is thrown. A currency Stripe will never accept for this customer, a deleted customer, or a restricted key without write on Invoices answers identically on every retry; thrown, that makes the organization permanently undeletable |
Who calls it. Two paths, and they are safe to both fire for one cancellation because the second finds every row closed:
customer.subscription.deleted, inafter()so no Stripe round-trip sits in front of the webhook's 200 (the route setsmaxDuration = 300for the same reasonreport-overagedoes). A customer left on a paid tier with no subscription is the worse outcome, so the settlement is not left to one attempt: the webhook records a retry row before it settles and clears it on success, and an hourly job retries any settlement that failed or was cut off, with backoff, giving up after 10 attempts. Running it after the downgrade is safe becauserecordPlanChangewrites the flip and thepro → freehistory row in one statement, soplanForPeriodstill holds the highest entitlement of the period and prices the month atpro.deleteOrgWithBilling, before the cascade — see below, where it is not best effort.
Resubscribing inside a settled month. A settled row's value is the
cut-off — the units of the period it accounted for, invoiced or written off —
and the row is never re-opened to failed (the cron would re-meter the whole
month, including the part already invoiced). Instead, both writers bill what
lies above the cut-off, once (src/lib/billing/settlement-ledger.ts):
report-overage, on the 1st, meters the remainder as a top-up underoverage-topup:{org}:{period}:{channel}, taking the row back fromreportedonly from the exact settled state it read. The row then holds the period's whole overage and is final. A customer who never came back (no subscription) is left alone quietly — settlement's invoice was their final bill. A definitely rejected top-up goes back to the settled state, never tofailed.- A second settlement in the same period (cancel, resubscribe, cancel again) invoices the units above the cut-off and moves it to the new invoice.
customer.subscription.created into a settled month logs
billing.settlement.resumed_after_settlement at info as a trace for support;
nothing has to be reconciled by hand.
Deleting an organization
DELETE /api/internal/org (slug confirm plus the account step-up, see
security) runs deleteOrgWithBilling(orgId, actor),
in this order:
listBillableSubscriptionsfor the org'sstripe_customer_id.settleFinalUsage— once per org, not per subscription: usage is counted per org, and closing the ledger makes the webhook each cancel fires a no-op.- Cancel every one of them with
prorate: false, invoice_now: true. The unused remainder of a flat month is not refunded — the customer chose to leave mid-cycle — and the usage half has just been invoiced separately.invoice_nowis for the overagereport-overagealready metered: on a subscription that renews mid-month, last month's meter events sit as pending usage on the current Stripe period, which settlement skips asreportedand a plain cancel never invoices. - Capture the AWS resources the org's rows name (
collectOrgProviderResourcesinsrc/lib/api/org-provider-release.ts), thendeleteOrgCascade. - Release them, best effort: leased numbers, then campaign and brand
registrations, then SES identities, then dedicated IP pools. Never a
simulated (
test-reg-) id, never a platform sender (isSandboxSenderDomain, so the parentsendoka.comidentity is never touched), and never a domain or number another org's row still names. Failures are logged asorg.delete.provider_release_failedwith the handle to clean up by hand, and recorded asprovider_release_failedon the audit row; they never fail the deletion. IP pools are deleted indeleteSesIpPool's strict mode, so a rejectedDeleteDedicatedIpPoollands there too rather than being reported as released (already-gone counts as deleted). A pool never sent to AWS — no credentials, orSES_IP_POOL_DRY_RUN— isprovider_release_skipped. - Clear the Stripe customer's
email,name,phone,description,addressandshipping(unless another org still bills through the same customer). Cleared, not deleted: a deleted customer loses its payment methods, and the settlement andinvoice_nowinvoices still have to collect. Recorded asstripe_customer_anonymized. - Write the
org.deletedaudit row.
Any Stripe failure in 1-3 throws OrgBillingUnreachableError and steps 4-7
never run: the route answers 502 ORG_BILLING_UNREACHABLE and the org is
still there. A Stripe rejection is the exception — see blocked above: the
tail is forfeited with an org.delete.settlement_forfeited alarm and the
deletion proceeds, because a condition that answers the same on every retry
would otherwise leave the owner with an organization they can never delete and a
subscription still running. An org that exists with a live subscription is recoverable; an org
that is gone with a live subscription is a customer being charged with no
dashboard and no portal link to stop it. The one exception is a customer or
subscription Stripe reports as resource_missing — already gone is the outcome
being asked for, and it must not block the deletion forever.
The cascade and the audit row (steps 4 and 7) are in that order deliberately. The route used to write
org.deleted first, and delete from audit_logs where org_id = … took it along
with everything else — the only trace of a deleted org was its absence. The row
now lands after the cascade, filed under the actor's longest-standing surviving
org that they own, because audit_logs.org_id is a NOT NULL FK to an
organization that no longer exists. Owned, not merely joined: the metadata
carries the deleted org's Stripe customer id, its canceled subscription ids, its
invoice ids and what it was charged, and filing that into an org where the actor
is a plain member would put one customer's billing identifiers in a different
tenant's audit log and export. An actor who owns nothing else — or an erasure
path, whose memberships are already gone — gets the org.deleted log line
instead. It carries the same fields, at warn.
The response reports subscriptions_canceled, invoices and
amount_charged_cents; the danger-zone confirm text warns about the charge
before it happens and the success toast names the amount, so it is not a
surprise line on a card statement.
GDPR account erasure with delete_owned_orgs (DELETE /api/internal/user)
goes through deleteOrgWithBilling too. The stranded-signup reaper
(src/lib/auth/signup-reap.ts) calls deleteOrgCascadeReleasingProviders —
the cascade plus steps 4-5 — because its orgs never finished signing up and
therefore never held a stripe_customer_id, but can still have added a domain.
Billing portal
Two entry points on /overview/settings/billing:
- Manage billing in Stripe — standard
createBillingPortalSession. - Cancel subscription (paid plans only) —
createCancelPortalSessionopens the portal withflow_data.subscription_cancelpre-focused on the org's active subscription. Falls back to the standard portal if no active subscription is found.
Both are Server Actions in src/app/overview/settings/billing/actions.ts, and a
Server Action is a public POST endpoint whatever the page rendered. Each one
calls requireOwnerSession() itself and re-reads stripe_customer_id for the
session's active org at call time, so a replay from a demoted or removed
owner, or from another org, bounces back to the billing page without minting a
portal. Never close over render-time org data in a new billing action.
API version
stripe.apiVersion: "2026-03-25.dahlia" — pinned in stripe.ts.