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
- Add credits posts
amount_centsand returns a Stripe Checkout URL. The org’s Stripe customer is minted lazily at first checkout and stored on the organization row; the session carriesmetadata.organization_id, which is how the webhook finds the org again. checkout.session.completedhits/v1/webhooks/stripe, signature-verified againstSTRIPE_WEBHOOK_SECRET. A bad signature is a 403; anything verified but uninteresting is acknowledged so Stripe stops retrying. Async payment methods credit oncheckout.session.async_payment_succeededinstead.charge.refundedclaws the credits back.- Usage debits the ledger when a request or a session finishes.
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-sessionPaymentIntent — 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.
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.completedcheckout.session.async_payment_succeededcharge.refunded
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: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 carriestxcd_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 acrosscheckout.session.completedandasync_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.
What is metered, and what is not
Speech models taggedtier="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.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 offbilling_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.
comp_usage entries carrying the
would-be cost — measured, never charged.
Replay safety
Stripe retries deliveries, so the ledger carries a partial unique index onexternal_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 leastMINIMUM_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 threeSTRIPE_PRICE_*ids must be in the production secret store, not just local.envfiles. Use live-mode keys (sk_live_).- Create the webhook endpoint pointing at
https://<api-host>/v1/webhooks/stripewith 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
purchaseentry. (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.