Back to docs

Billing & Stripe

Plans, top-ups, webhooks

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

VariableRequired?ExampleNotes
STRIPE_SECRET_KEYRequired for Stripe modesk_test_… / sk_live_…https://dashboard.stripe.com/apikeys
STRIPE_WEBHOOK_SECRETRequired for webhookwhsec_…One per registered endpoint
STRIPE_PUBLISHABLE_KEYOptionalpk_test_…Currently unused server-side; reserved for future client.js usage
BILLING_SUCCESS_URLOptionalhttps://privaterouter.com/billing/success?session_id={CHECKOUT_SESSION_ID}{CHECKOUT_SESSION_ID} is interpolated by Stripe
BILLING_CANCEL_URLOptionalhttps://privaterouter.com/billing?canceled=true
ALLOW_MOCK_TOPUPOptionalfalseWhen 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/portal endpoints return 503 "Stripe is not configured".
  • /api/billing/webhook returns 503.
  • The legacy M2 mock /api/billing/topup continues 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:

  1. Visit https://dashboard.stripe.com/test/products (Test mode toggle in the upper-left corner).
  2. Add product: PrivateRouter Pro, recurring, $20 USD/month.
  3. Copy the Price ID (starts with price_).
  4. 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:

  1. Visit https://dashboard.stripe.com/test/webhooks → Add endpoint
  2. URL: https://privaterouter.com/api/billing/webhook (or your own domain)
  3. Listen to only these events (selecting everything inflates noise):
    • checkout.session.completed
    • invoice.paid
    • invoice.payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted
    • charge.refunded
  4. Copy the signing secret (starts with whsec_) and set as STRIPE_WEBHOOK_SECRET in 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 numberBehavior
4242 4242 4242 4242Success
4000 0000 0000 9995Declined: insufficient_funds
4000 0027 6000 3184Requires authentication (3DS challenge)
4000 0000 0000 0341Attaches 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

  1. User clicks Subscribe or Buy $25 credits on the dashboard.
  2. Frontend POSTs /api/billing/checkout/topup (or /checkout/subscription).
  3. Backend:
    • Loads the user's accounts row.
    • If account.stripe_customer_id is NULL, calls stripe.Customer.create(email, metadata={user_id}) and persists the returned cus_… id.
    • Creates the Checkout session and returns {checkout_url, session_id}.
  4. Frontend redirects the browser to checkout_url.

The customer id is reused for every subsequent payment by the same user.

Subscription states

StateSourceMeaning
incompleteinitial Stripe stateCheckout started but first payment hasn't cleared
trialingStripe (if a trial is configured on the Price)Active, no charge yet
activecheckout.session.completed mode=subscription, or invoice.paidHealthy. Credits granted.
past_dueinvoice.payment_failedRenewal failed. No credit grant. Stripe retries automatically.
canceledcustomer.subscription.deletedSubscription 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 sets cancel_at_period_end=true → we mirror that locally → at period end, Stripe fires customer.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 calls stripe.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:

Originreference_typereference_id
Topup paymentstripe_sessionStripe Checkout session id (cs_…)
Subscription signup grantsubscriptionStripe subscription id (sub_…)
Invoice renewal grantinvoiceStripe invoice id (in_…)
RefundchargeStripe 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:

  1. The transaction rolls back.
  2. The stripe_events row's processing_error is stamped (truncated to 1000 chars).
  3. The endpoint returns 500.
  4. 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.id PK 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:

  1. Admin issues refund from the Stripe dashboard (Customer → Charges → Refund).
  2. Stripe fires charge.refunded.
  3. Our handler resolves the user via Account.stripe_customer_id, then inserts a negative credit_transactions row with type='refund', reference_type='charge', reference_id=ch_….
  4. 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 grant transaction.

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_usd across all status='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:

ActionTarget
subscription.cancelsubscription:{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 stateBehavior
Stripe not configured200 + 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=true200 + 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 recent credit_transactions.balance_after_usd).
  • plan_name — current plan name, or null if no plan attached.
  • monthly_quota_tokens — soft monthly cap from the plan, or null.
  • burn_rate_7d_usd (M17) — average daily spend over the trailing 7 days. Computed as SUM(|amount|) WHERE type='usage' AND created_at >= now() - 7d divided by 7. Returns 0 if the user has no recent usage.
  • days_runway (M17) — balance_usd / burn_rate_7d_usd, capped at 999. null when 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". Our invoice.paid handler skips the grant in that case because the checkout.session.completed handler already granted the signup credits. Renewals use billing_reason="subscription_cycle" and DO grant.
  • Stripe amounts are in cents. Divide by 100 for USD Decimal. We do this at the boundary in handle_checkout_completed and handle_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_SECRET is 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_object filter in apps/api/alembic/env.py prevents Alembic autogenerate from emitting DROP TABLE for LiteLLM's ~80 tables. Don't remove this filter — a future migration would otherwise nuke LiteLLM state on alembic upgrade head.
  • The Stripe SDK is synchronous. All calls in app/services/stripe_client.py are wrapped in async def for 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_id is lazy. New users have NULL until they make their first payment. The /api/billing/portal endpoint returns 404 in that state — show the user a "make a payment first" message instead of breaking the UI.
  • Partial refunds. The current charge.refunded handler 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:

  1. Create a real Customer via stripe.Customer.create, assert id format, tear down with stripe.Customer.delete.
  2. Idempotency: persisting and re-fetching a customer id reuses it without hitting Stripe again.
  3. Real topup Checkout session — assert session.url starts with https://checkout.stripe.com/c/pay/.
  4. Real subscription Checkout — needs STRIPE_TEST_PRICE_ID env var pointing at a real test-mode Price.
  5. 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 /plans filtering
  • Checkout endpoints (with allowlist validation)
  • Portal 503/404 paths
  • Mock topup gating
  • Webhook bad signature → 400 (skipped if STRIPE_WEBHOOK_SECRET unset)
  • Webhook signed + replay → idempotent (skipped if STRIPE_WEBHOOK_SECRET unset)
  • 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_KEY from sk_test_… to sk_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_monthly rows to the new live price_… ids.
  • Verify BILLING_SUCCESS_URL and BILLING_CANCEL_URL point 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).