Billing & Stripe Integration
Operator + developer guide for PrivateRouter's M5 Stripe integration.
PrivateRouter charges customers via Stripe Checkout — one-time top-ups for prepaid credits, and recurring monthly subscriptions for plans. This document covers the architecture, required environment, common operations, and known pitfalls.
Cross-reference: see admin-guide.md for the broader admin panel + audit log model and api-quickstart.md for end-user API integration.
Architecture
(admin can also cancel)
│
▼
user → /pricing → POST /api/billing/checkout/subscription
→ POST /api/billing/checkout/topup
→ POST /api/billing/portal
│
▼
┌──────────────────┐
│ Stripe Checkout │
│ (hosted page) │
└──────────────────┘
│ pay
▼
┌──────────────────┐
│ Stripe servers │
└──────────────────┘
│
redirect ──┐ │ POST /api/billing/webhook
▼ ▼
/billing/success ┌──────────────────┐
(UX only; │ webhook handler │ — verifies HMAC
polls balance) │ idempotent on │ then dispatches
│ event.id (PK) │ to per-type handler
└──────────────────┘
│
▼
credit_transactions
subscriptions
accounts.plan_id
The webhook is the single source of truth for credit grants. The redirect-page polling is just UX — it never credits.
Required environment variables
| Variable | Required? | Example | Notes |
|---|---|---|---|
STRIPE_SECRET_KEY | Required for Stripe mode | sk_test_… / sk_live_… | https://dashboard.stripe.com/apikeys |
STRIPE_WEBHOOK_SECRET | Required for webhook | whsec_… | One per registered endpoint |
STRIPE_PUBLISHABLE_KEY | Optional | pk_test_… | Currently unused server-side; reserved for future client.js usage |
BILLING_SUCCESS_URL | Optional | https://privaterouter.com/billing/success?session_id={CHECKOUT_SESSION_ID} | {CHECKOUT_SESSION_ID} is interpolated by Stripe |
BILLING_CANCEL_URL | Optional | https://privaterouter.com/billing?canceled=true | |
ALLOW_MOCK_TOPUP | Optional | false | When true, the M2 mock /api/billing/topup stays enabled even in Stripe mode. Use only for tests. |
When neither STRIPE_SECRET_KEY nor STRIPE_WEBHOOK_SECRET is set, the API runs in dev mode:
- All
/api/billing/checkout/*and/api/billing/portalendpoints return 503 "Stripe is not configured". /api/billing/webhookreturns 503.- The legacy M2 mock
/api/billing/topupcontinues to work so smoke tests and dev work without round-tripping Checkout.
Setting STRIPE_SECRET_KEY alone (without the webhook secret) gives you Checkout sessions, but webhooks won't fire — so credits won't actually be granted. Always configure both keys together.
One-time setup
1. Create Stripe Products and Prices
For each subscribable plan in your plans table (Starter, Pro, Developer, Team — but not Free), create a corresponding Stripe Product with a recurring monthly Price:
- Visit https://dashboard.stripe.com/test/products (Test mode toggle in the upper-left corner).
- Add product:
PrivateRouter Pro, recurring, $20 USD/month. - Copy the Price ID (starts with
price_). - Update the plan row:
UPDATE plans
SET stripe_price_id_monthly = 'price_1NPRO12345abcDEF'
WHERE name = 'pro';
Repeat for each paid plan. The pricing page button auto-flips from "Coming soon" (disabled) to "Subscribe — $X/mo" (active) the moment the price ID is populated.
The free plan stays stripe_price_id_monthly = NULL and shows "Get started" → /signup.
2. Register the webhook endpoint
In production:
- Visit https://dashboard.stripe.com/test/webhooks → Add endpoint
- URL:
https://privaterouter.com/api/billing/webhook(or your own domain) - Listen to only these events (selecting everything inflates noise):
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deletedcharge.refunded
- Copy the signing secret (starts with
whsec_) and set asSTRIPE_WEBHOOK_SECRETin the environment.
3. Local dev with the Stripe CLI
The Stripe CLI relays live test-mode events to your laptop, so you can develop the webhook handler without a public URL:
# One-time:
stripe login
# Each dev session:
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Stripe CLI prints a whsec_… — set it as STRIPE_WEBHOOK_SECRET in .env
# and restart the API container so the new env is picked up.
Trigger test events from another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger customer.subscription.deleted
Test cards (Stripe test mode only)
| Card number | Behavior |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 9995 | Declined: insufficient_funds |
4000 0027 6000 3184 | Requires authentication (3DS challenge) |
4000 0000 0000 0341 | Attaches but fails on first invoice |
Any future expiry, any 3-digit CVC, any 5-digit zip.
Full reference: https://stripe.com/docs/testing#cards
Customer + subscription lifecycle
First payment
- User clicks Subscribe or Buy $25 credits on the dashboard.
- Frontend POSTs
/api/billing/checkout/topup(or/checkout/subscription). - Backend:
- Loads the user's
accountsrow. - If
account.stripe_customer_idisNULL, callsstripe.Customer.create(email, metadata={user_id})and persists the returnedcus_…id. - Creates the Checkout session and returns
{checkout_url, session_id}.
- Loads the user's
- Frontend redirects the browser to
checkout_url.
The customer id is reused for every subsequent payment by the same user.
Subscription states
| State | Source | Meaning |
|---|---|---|
incomplete | initial Stripe state | Checkout started but first payment hasn't cleared |
trialing | Stripe (if a trial is configured on the Price) | Active, no charge yet |
active | checkout.session.completed mode=subscription, or invoice.paid | Healthy. Credits granted. |
past_due | invoice.payment_failed | Renewal failed. No credit grant. Stripe retries automatically. |
canceled | customer.subscription.deleted | Subscription is over. We revert account.plan_id to the free plan. |
Cancellation flow
- User cancels via the Stripe Billing Portal (Manage subscription button on
/billing) → Stripe setscancel_at_period_end=true→ we mirror that locally → at period end, Stripe firescustomer.subscription.deleted→ handler reverts the account to the free plan. - Admin cancels via
/admin/billing→ Cancel button → POST/api/admin/billing/subscriptions/{id}/cancel→ API callsstripe.Subscription.modify(cancel_at_period_end=True)→ audit log row written → user keeps service until the period ends.
We do not immediately revoke service when an admin cancels — the customer paid for the month. Period-end honors the contract.
Idempotency model
The webhook is replay-safe by design. Stripe will retry failed deliveries with exponential backoff for up to 3 days, and may send the same event multiple times during outages. Two layers protect us:
Layer 1: stripe_events table
Every webhook is recorded keyed on Stripe's event.id (e.g. evt_3Nq…). The table's primary key is event.id itself — so a duplicate insert raises IntegrityError, the handler short-circuits, and the response is 200 {"received": true, "replayed": true}.
event.id → PK on stripe_events
on conflict → skip dispatch
Layer 2: (reference_type, reference_id) on credit_transactions
Each grant carries a unique reference back to its origin:
| Origin | reference_type | reference_id |
|---|---|---|
| Topup payment | stripe_session | Stripe Checkout session id (cs_…) |
| Subscription signup grant | subscription | Stripe subscription id (sub_…) |
| Invoice renewal grant | invoice | Stripe invoice id (in_…) |
| Refund | charge | Stripe charge id (ch_…) |
Before any grant, the handler runs a SELECT EXISTS(…) on (reference_type, reference_id). If a row exists, the grant is a no-op. This is belt-and-suspenders alongside the stripe_events PK — even if you somehow process the same logical credit twice, the deduplication catches it.
Failure handling
If a handler raises an exception:
- The transaction rolls back.
- The
stripe_eventsrow'sprocessing_erroris stamped (truncated to 1000 chars). - The endpoint returns 500.
- Stripe retries the same event.id with backoff — and since the event row exists but is marked failed, the next attempt re-runs the dispatch (the PK insert succeeds because we already rolled back the original insert? No — the row IS persisted with the error stamp. The retry will hit
stripe_events.idPK conflict →replayed=true→ skip).
Watch out: this means a handler that fails on first attempt with a transient error (e.g. DB connection blip) will not be retried by our code. Stripe's retry will be no-op'd by our PK. To handle this case, we'd need a separate "reprocess failed events" admin tool — currently a manual SQL DELETE FROM stripe_events WHERE id = 'evt_…' AND processing_error IS NOT NULL lets Stripe's next retry re-dispatch.
Why we don't credit on the success redirect
The browser redirect after a successful Checkout (?session_id=cs_…) is advisory only. Customers may:
- Close their browser before the redirect.
- Be on a flaky network and never see it.
- Refresh and trigger a double-grant attempt.
The webhook is the binding event. The success page (/billing/success) polls GET /api/billing/balance every 2s for 30s; when balance increases vs the initial poll, it shows the success state. If 30s pass without a change, the page shows a "still processing — refresh in a minute" fallback. The webhook handler is the authority — never the redirect.
Refund flow
Refunds are admin-initiated only:
- Admin issues refund from the Stripe dashboard (Customer → Charges → Refund).
- Stripe fires
charge.refunded. - Our handler resolves the user via
Account.stripe_customer_id, then inserts a negativecredit_transactionsrow withtype='refund',reference_type='charge',reference_id=ch_…. - User's balance reflects the refund. The balance can go negative if the user already spent the original grant — that's intentional. Admin discretion on whether to clear the negative balance with a
granttransaction.
We do not honor partial refunds gracefully today — amount_refunded is taken at face value. Multi-refund of the same charge will create multiple refund rows (each keyed on charge.id — wait, this is wrong). Pitfall: if Stripe fires charge.refunded twice (e.g. two partial refunds against the same charge), our (reference_type='charge', reference_id=ch_…) dedup will swallow the second one. Workaround: include the refund's amount in the reference key. Filed as a TODO; not blocking M5.
Admin operations (/admin/billing)
- Total MRR — sum of
monthly_price_usdacross allstatus='active'subscriptions. - Active / Past-due / Webhook events stat cards — at-a-glance health.
- Subscriptions table — filter by status, click Cancel per row.
- Stripe events table — filter by event_type or processing_state (pending/processed/failed). Click a row to see the full payload JSON for debugging.
All admin mutating actions write to admin_audit_logs:
| Action | Target |
|---|---|
subscription.cancel | subscription:{id} |
(More to come as the panel grows. The audit log table is read-only to the application — admins can inspect via /admin/audit but cannot delete rows.)
The mock topup endpoint (/api/billing/topup)
The M2 mock endpoint that grants credits without Stripe still exists for backward compatibility with dev workflows + smoke tests.
| Env state | Behavior |
|---|---|
| Stripe not configured | 200 + credits granted (dev mode) |
Stripe configured, ALLOW_MOCK_TOPUP=false (default) | 410 Gone + redirect-message pointing at /api/billing/checkout/topup |
Stripe configured, ALLOW_MOCK_TOPUP=true | 200 + credits granted (overridden for tests) |
Always set ALLOW_MOCK_TOPUP=false in production. The smoke-m4 and smoke-m5 scripts work in either mode and assert the correct behavior for the current configuration.
Balance response shape (M17)
GET /api/billing/balance returns a few derived fields beyond the raw balance, used by the dashboard top-bar BalanceStatus component to render state-aware indicators (green / yellow / amber / red):
balance_usd— current credit balance (most recentcredit_transactions.balance_after_usd).plan_name— current plan name, ornullif no plan attached.monthly_quota_tokens— soft monthly cap from the plan, ornull.burn_rate_7d_usd(M17) — average daily spend over the trailing 7 days. Computed asSUM(|amount|) WHERE type='usage' AND created_at >= now() - 7ddivided by 7. Returns0if the user has no recent usage.days_runway(M17) —balance_usd / burn_rate_7d_usd, capped at 999.nullwhen burn rate is zero (no recent usage, so runway is effectively infinite). The frontend uses thresholds 7d / 1d / $0 to switch tiers.
Polled every 60s by BalanceStatus; the same endpoint is hit by the /billing/success page on a 2s cadence to detect credited topups.
Pitfalls
- The success page redirect alone is NOT proof of payment. Always wait for the webhook before granting credit.
- The first invoice for a new subscription has
billing_reason="subscription_create". Ourinvoice.paidhandler skips the grant in that case because thecheckout.session.completedhandler already granted the signup credits. Renewals usebilling_reason="subscription_cycle"and DO grant. - Stripe amounts are in cents. Divide by 100 for USD
Decimal. We do this at the boundary inhandle_checkout_completedandhandle_charge_refunded. - Stripe timestamps are Unix seconds, not milliseconds. Convert with
datetime.fromtimestamp(ts, tz=timezone.utc). - The webhook endpoint returns 503 if
STRIPE_WEBHOOK_SECRETis unset. This is intentional — we never accept anonymous webhook calls. If you see 503s in production webhook logs, the secret rotated or got dropped from the env. - LiteLLM shares the same Postgres database. The
include_objectfilter inapps/api/alembic/env.pyprevents Alembic autogenerate from emittingDROP TABLEfor LiteLLM's ~80 tables. Don't remove this filter — a future migration would otherwise nuke LiteLLM state onalembic upgrade head. - The Stripe SDK is synchronous. All calls in
app/services/stripe_client.pyare wrapped inasync deffor FastAPI compatibility, but they block the event loop briefly. This is fine at PrivateRouter's scale; revisit if Checkout endpoints become a bottleneck. stripe_customer_idis lazy. New users haveNULLuntil they make their first payment. The/api/billing/portalendpoint returns 404 in that state — show the user a "make a payment first" message instead of breaking the UI.- Partial refunds. The current
charge.refundedhandler keyed on(reference_type='charge', charge.id)will swallow a second partial refund of the same charge. Track this if you start issuing multi-refunds.
Live integration test (gated)
A live-mode test suite lives at apps/api/tests/test_stripe_live.py. All five tests are gated by @pytest.mark.skipif(not os.getenv("STRIPE_LIVE_TEST")) and only run when:
STRIPE_LIVE_TEST=1 STRIPE_SECRET_KEY=sk_test_… pytest tests/test_stripe_live.py -v
These tests hit Stripe's real test-mode API:
- Create a real Customer via
stripe.Customer.create, assert id format, tear down withstripe.Customer.delete. - Idempotency: persisting and re-fetching a customer id reuses it without hitting Stripe again.
- Real topup Checkout session — assert
session.urlstarts withhttps://checkout.stripe.com/c/pay/. - Real subscription Checkout — needs
STRIPE_TEST_PRICE_IDenv var pointing at a real test-mode Price. - Webhook signature with real secret — round-trips a hand-signed payload through
construct_event.
Run these before any production deploy that touches Stripe code.
Smoke test (infra/scripts/smoke-m5.sh)
End-to-end acceptance against the live dev stack:
bash infra/scripts/smoke-m5.sh --keep # reuse running stack
bash infra/scripts/smoke-m5.sh # full tear-down + bring-up
Covers all 15 critical paths in ~2-5 seconds:
- Stack health
- Public
/plansfiltering - Checkout endpoints (with allowlist validation)
- Portal 503/404 paths
- Mock topup gating
- Webhook bad signature → 400 (skipped if
STRIPE_WEBHOOK_SECRETunset) - Webhook signed + replay → idempotent (skipped if
STRIPE_WEBHOOK_SECRETunset) - Admin Stripe panel routes (list + 404)
- Cleanup
Webhook tests auto-skip when the container doesn't have STRIPE_WEBHOOK_SECRET configured. To exercise them, set the secret in .env and recreate the API container with docker compose up -d --force-recreate api.
Production checklist
Before flipping to live mode:
- Switch
STRIPE_SECRET_KEYfromsk_test_…tosk_live_…. - Register a separate webhook endpoint for live mode (with its own
whsec_…). - Re-create Stripe Products and Prices in live mode; update
plans.stripe_price_id_monthlyrows to the new liveprice_…ids. - Verify
BILLING_SUCCESS_URLandBILLING_CANCEL_URLpoint at the production frontend. - Set
ALLOW_MOCK_TOPUP=false(the default — just confirm). - Run the live integration test against the production env:
STRIPE_LIVE_TEST=1 pytest tests/test_stripe_live.py. - Verify a real $1 charge end-to-end with a test card, then issue a refund and verify the negative ledger entry appears.
- Configure Stripe alerts for failed payments + disputes.
- Enable Stripe Tax if applicable (out of scope for M5).