Billing is a prepaid credit ledger. One credit is one cent. Customers top up through Stripe-hosted checkout; runs debit the ledger. Billing is on only when both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set. When either is missing, billing routes return 404 and the credit gate always allows — a development environment with no Stripe config is unaffected.

The loop

  1. Add credits posts amount_cents and returns a Stripe Checkout URL. The org’s Stripe customer is minted lazily at first checkout and stored on the organization row; the session carries metadata.organization_id, which is how the webhook finds the org again.
  2. checkout.session.completed hits /v1/webhooks/stripe, signature-verified against STRIPE_WEBHOOK_SECRET. A bad signature is a 403; anything verified but uninteresting is acknowledged so Stripe stops retrying. Async payment methods credit on checkout.session.async_payment_succeeded instead.
  3. charge.refunded claws the credits back.
  4. Usage debits the ledger when a request or a session finishes.
Top-ups need no preconfigured Stripe product — the checkout session prices the requested amount_cents inline (price_data), so the amount is arbitrary within the request bounds.

Pay as you go

There are no plans. Money in, credits at par, usage debited per minute — one meter, and every charge traceable to the work that caused it. The tiers that used to live here promised a fixed number of credits a month, which is a fixed number of dollars; what those dollars bought moved with the rate card, so the ladder quietly promised 150,000 minutes on the entry tier against hardware that serves 172,800 minutes a month in total. Removing them also removed the machinery they needed: subscription webhooks, proration, converge-to-latest plan state, and a checkout that could open a second subscription on an account that already had one. The welcome grant is the free tier, and it is denominated in minutes (SIGNUP_GRANT_MINUTES, default 1,440 — one day of audio) rather than credits, converted at the rate on the way into the ledger. A grant fixed in credits silently becomes a different product every time the price changes. It does not renew, and that is the point: a daily free day would be 1,440 minutes against one L4’s 5,760 minutes of daily capacity, so four free accounts would hold production permanently, for nothing. Beyond the grant, an account that has never paid is still bounded by the daily free ceilings (SPEECH_FREE_DAILY_*). Buying any credits lifts them for good; from then on the balance is the limit.

Running out, and why there is no auto-recharge

Pay-as-you-go stops dead at zero, so the obvious answer is to charge a saved card when the balance gets low. That is not available here, and the reason is worth writing down before someone tries again. Managed Payments covers Checkout and Payment Links only. An off-session PaymentIntent — the mechanism auto-recharge needs — comes back with managed_payments: {enabled: false}, which means EESI would be merchant of record for it. Manual top-ups would stay Stripe’s. That splits the tax posture down the middle, with the liability landing on the recurring half of revenue, and nothing surfaces it until an audit. (saved_payment_method_options does work, and mode=setup and payment_intent_data.setup_future_usage are both rejected — so the card can be saved; it is the charging that is the problem.) The warning is what replaces it:
  • LOW_BALANCE_WARN_MINUTES (default 60) is a threshold in minutes, not credits, because a balance says what it is worth and not how long it lasts.
  • The console shows the runway on the balance figure and a banner below it.
  • A debit that crosses the threshold enqueues an email, keyed per org per hour so a burst of generations collapses to one job. The flag clears only when the balance climbs back, so a top-up re-arms the warning and a long slide below it stays quiet.
  • A zero rate is not “no runway” — it is a deployment that charges nothing, and it never warns.
Every top-up still goes through normal Checkout, so all money keeps the same tax posture.

What replaced the plan ceilings

Plans carried four limits. Under pay-as-you-go a longer or busier account simply spends more, so the survivors are capacity controls rather than things a customer buys: The per-org configuration row is the escape hatch: support raises one account’s concurrency without a tier existing to raise it into.

The webhook endpoint subscription

A Stripe webhook endpoint only delivers the events it is subscribed to, and nothing logs an event you never receive. The endpoint for /v1/webhooks/stripe must subscribe to exactly:
  • checkout.session.completed
  • checkout.session.async_payment_succeeded
  • charge.refunded
Three, not seven: pay-as-you-go issues no subscriptions and no invoices, so nothing else is billing-relevant. setup_stripe derives the subscription from _HANDLED_EVENT_TYPES in api/services/billing/stripe.py, so re-running it after that tuple grows is what keeps the endpoint honest. An endpoint edited by hand in the dashboard drifts silently: under Polar ours carried two of nine events, and paying customers stayed on the free plan indefinitely.

Provisioning a Stripe account

The event list lives in code, so the Stripe objects mirroring it are created from code too:
It creates the webhook endpoint, pinned to the SDK’s API version, and the portal configuration, then prints the STRIPE_WEBHOOK_SECRET to put in the environment. There are no products or prices to provision: a top-up names its amount inline on the checkout session, so Stripe holds no catalog of ours. Run it once per Stripe mode.

Dev is deliberately unconfigured

platform.dev.eesi.ai shows “NOT CONFIGURED” on purpose, and the fix is not to copy the production Stripe keys into the dev environment. A Stripe webhook endpoint receives every event for its account. Two deployments pointed at one sandbox both receive every purchase, and each resolves metadata.organization_id against its own database — dev and prod run separate Postgres instances whose org ids both start at 1, so org 42 in one is a different company from org 42 in the other. A prod customer topping up would credit prod org 42 correctly and silently credit whoever org 42 happens to be on dev. It runs both directions, and every checkout carries the metadata that does it. Only refunds miss naturally, because they resolve through stripe_customer_id, which won’t match across databases. If dev ever needs billing, give it its own Stripe sandbox — separate account, separate endpoint, separate price ids — rather than a second endpoint on the production sandbox.

Managed Payments and product tax codes

Stripe’s Managed Payments — Stripe acting as merchant of record, handling sales tax, VAT and GST in 80+ countries — is enabled on the account, and it refuses any line item whose product carries no eligible tax code. Every product the platform sells therefore carries txcd_10105002 (“AI as a Service — Cloud Based, Business Use”): setup_stripe stamps it on the the inline top-up product create_checkout prices. Without it both checkout modes fail with a 400 and nobody can pay. Two consequences of Stripe being the merchant:
  • A Managed Payments session carries no payment_intent — Stripe owns it — so a top-up’s ledger key falls back to the checkout session’s own id. That id is stable across checkout.session.completed and async_payment_succeeded, so double delivery still dedupes.
  • A refund keys on a different id and so won’t find that entry. Harmless for top-ups, which credit cash 1:1: the clawback falls back to the cash amount and lands on the same number. Subscription cycles are unaffected — their invoices stay on this account.
Nothing it does is destructive. Existing products and prices are reused, never edited. The webhook signing secret is returned by Stripe only at creation — a re-run against an existing endpoint cannot print it, so read it from the dashboard or roll it there.

What is metered, and what is not

Speech models tagged tier="paid" (all four nur-* speech models) gate on balance and debit the ledger. Rates are env-driven: One rate card covers every timed surface, which is the point: two rate knobs is how the platform once drifted 900× apart, with one surface billing 0.09/minagainstanothers0.09/min against another's 0.001/min and nothing to surface the gap, because each number looked deliberate on its own. DEFAULT_PRICE_PER_SECOND_USD survives as a derived value — unset, it is SPEECH_PRICE_PER_MINUTE_USD / 60 — but nothing prices against it any more. It only decides which line the startup log prints, and setting it to 0 is how a deployment announces that it takes payment and charges nothing. Metered: generated TTS audio seconds (including voice-clone previews), STT duration for every response format (json/verbose_json from the upstream payload; srt/vtt/text from the uploaded file), live-STT and realtime wall-clock seconds — a phone leg among them, from the moment its upstream pipeline connects — and a flat 100-credit fee when a cloned voice is created. The ledger is integer cents, and debits round to the nearest cent — at $0.001/min a cent is 10 minutes of audio, so a short session still rounds to 0 and cost accrues across the sessions that cross whole-cent boundaries. Rounding to zero does not mean recording nothing. _debit used to return early on a zero charge, so an ordinary generation left no ledger row at all and /billing showed an empty ledger to an account that had been working all day. Every unit of usage is now written whether or not it moves the balance, with the exact cost carried in extra.cost_micro_cents — micro-cents, so consumption stays legible three orders of magnitude below what a credit can express. /organizations/usage/breakdown sums those, which is what the Consumption panel on /billing reads. /billing/credits reports price_per_minute_usd, so the console can say what a credit is worth rather than leaving it an abstract unit. Credits are cents; what they buy moves with the card. Deliberately unmetered today: chat completions (nur-llm-v1 — bounded by the per-org throttle instead) and benchmark runs. Organizations that have never purchased additionally carry daily usage ceilings (SPEECH_FREE_DAILY_*); any purchase lifts them permanently. Organizations of emails in BILLING_EXEMPT_EMAILS are never charged — their usage records as zero-amount comp_usage ledger entries carrying the would-be cost, so internal usage stays measurable.

Exemption is about money, not entitlement

Two different questions hang off billing_exempt, and only one of them is its job:
  • Is this organization charged? No. The balance gate, the clone fee and the usage debit all read the flag directly and skip.
  • What ceilings apply? The same ones as everyone else. Pay-as-you-go has no tiers, so concurrency and session limits are capacity controls, and the per-org configuration row is how a single account gets more.
Exempt usage still records, as zero-amount comp_usage entries carrying the would-be cost — measured, never charged.

Replay safety

Stripe retries deliveries, so the ledger carries a partial unique index on external_order_id (where not null) — the payment-intent id for a top-up, or the checkout session’s own id under Managed Payments, where Stripe owns the payment intent and the session carries none. A replayed event is a no-op that still returns 202. Refunds are recorded as refund:{order_id}, so they’re independently unique.

The quota gate

A billable run needs at least MINIMUM_BALANCE_CENTS (10) to start. The gate is deliberately small — it stops zero and negative balances from starting calls; it does not prepay a whole call.

Going to production

  • STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and the three STRIPE_PRICE_* ids must be in the production secret store, not just local .env files. Use live-mode keys (sk_live_).
  • Create the webhook endpoint pointing at https://<api-host>/v1/webhooks/stripe with the event list above, and save a default customer-portal configuration.
  • After deploying, place a minimum live top-up ($5) and confirm the webhook delivers a 202 and the ledger shows a purchase entry. (A 100%-off promotion order won’t do — a zero-amount payment is deliberately ignored.)
  • Refund that order and confirm the clawback.
  • Confirm a billable run debits the ledger.
  • Unset the Stripe env in a scratch environment and confirm billing routes 404 while calls still run.

Granting credits directly

For trials and testing, bypass checkout: