Stripe integration
Auth Service uses the Stripe API version 2026-05-27.dahlia (src/services/stripe.ts). Two billing mechanics coexist and are deliberately independent:
- Prepaid credit wallet — a euro balance the user tops up and spends through the LiteLLM gateway. Top-ups are one-off Stripe Checkout sessions in
paymentmode. - Paid subscription plans — per-product plans; a metered feature's usage is billed by Stripe at the end of the billing period.
Auth-service owns the Stripe catalogue: Products, Prices and Meters are created, updated and archived from the admin API (decision D9), never by hand in the Dashboard. Stripe ids are never supplied by a caller: a plan's Product is always provisioned by auth-service, and a request carrying a Stripe id is refused, so no plan can be attached to a Product it does not own (deleting a plan archives its Product). The sections below state what is wired today; future work is tracked in the billing programme plan.
Configuration
STRIPE_SECRET_KEY=sk_live_... # Secret API key (sk_test_… in test mode)
STRIPE_WEBHOOK_SECRET=whsec_... # Signing secret of THIS webhook endpoint
- Only these two values are needed. The publishable key (
pk_…) is not used: payments happen on Stripe's own Checkout page, there is no Stripe.js in the frontend. - Both are environment-only (decision D2); there is no admin UI for them.
- Without
STRIPE_SECRET_KEY, Stripe is silently disabled: the catalogue calls become no-ops (the plan rows still exist, without Stripe ids) and the wallet falls back to manual top-ups. - The signing secret is per endpoint and per mode (test/live). A
whsec_…from a test endpoint does not validate live events.
What is synced today
| Stripe object | Trigger in auth-service | Behaviour | Status |
|---|---|---|---|
| Product (one per plan) | Plan created through POST /api/admin/applications/:appId/plans | Created, named <AppName> — <PlanName>, metadata.applicationId | ✅ |
| Plan updated (name/description) | Renamed / re-described | ✅ | |
| Plan deleted | Archived (active: false), never deleted | ✅ | |
| Application created | Its default free plan gets its Product too (best-effort) | ✅ | |
| Application renamed | Its plans' Products are renamed | ✅ | |
| Application deleted | Its plans' Products/Prices are archived and its Meters deactivated | ✅ | |
| Price (one per price tier) | Price created (POST …/plans/:planId/prices) | Created on the plan's Product; a metered price also gets a Meter | ✅ |
| Price renamed | Stripe nickname updated (amount/currency/interval/feature are immutable: a change archives and creates a new row) | ✅ | |
| Price deleted | Archived (active: false) | ✅ | |
| Meter (one per application + feature) | First metered price of a feature | Created (event_name = app_<slug>__<featureKey>, aggregation sum, mapped by stripe_customer_id) | ✅ |
| Last price of that feature removed | Deactivated | ✅ | |
| Metered usage reported by an application | Recorded idempotently, aggregated per UTC month and capped (403 BIL_003 past the limit), then pushed as a Stripe meter event; a failed push stays pending and is retried | ✅ | |
| Customer | User registers, or first paid flow | Created eagerly, best-effort, idempotent (a Stripe incident never blocks sign-up); pnpm billing:reconcile creates the missing ones | ✅ |
| Subscription | Plan assigned (admin grant, default plan, self-service) | Created or updated for every plan, the free one included: a plan with no price gets a 0 EUR monthly Price, and a plan change swaps the subscription item. Paid changes follow the rules below. The Billing Portal is opened from the profile | ✅ |
| Invoice | Any invoice.* event | Cached in stripe_invoices (owner from the Stripe Customer, then metadata; application from the subscription, then metadata). A stale event never overwrites a newer state. Read by the application API below | ✅ |
| Wallet top-up | POST /api/user/wallet/checkout | One-off Checkout payment with invoice_creation enabled: the webhook credits the wallet, and the receipt invoice is cached (no application) | ✅ |
Stripe webhook endpoint
POST /api/webhooks/stripe
The route receives the raw body and verifies the Stripe-Signature header before processing. Do not put authentication middleware in front of it: Stripe sends no session cookie.
Required endpoint settings
| Setting | Value | Why |
|---|---|---|
| Payload format | Snapshot | The handlers read fields of event.data.object (subscription status/items, invoice parent/lines/periods, checkout/charge amounts). Only the completion of a subscription Checkout also calls stripe.subscriptions.retrieve. A thin payload would carry only ids and the handlers would silently ignore the event. |
| API version | 2026-05-27.dahlia | The webhook payload is shaped by the endpoint's version, not by the SDK's apiVersion. The handlers rely on dahlia shapes (e.g. current-period bounds on the subscription item, invoice.parent.subscription_details). Picking a newer version without aligning the code can move or rename fields. |
| Signing secret | whsec_… → STRIPE_WEBHOOK_SECRET | Verified on every delivery. |
Events to subscribe
Required today (the wallet and the subscription entitlement):
| Event | Effect |
|---|---|
customer.subscription.created | Applies the full Stripe state to the matching user subscription: plan (from the Stripe price), isActive derived from the status (active/trialing → true, every other status → false), expiry and period bounds |
customer.subscription.updated | Same as customer.subscription.created |
customer.subscription.deleted | Sets isActive = false and records the status and cancellation date; the row is kept |
invoice.finalized | Caches the invoice, with its hosted link and PDF (available once finalized) |
invoice.updated | Refreshes the cached invoice (status, amounts) |
invoice.voided | Refreshes the cached invoice as void |
invoice.paid | Caches the invoice as paid; for a subscription invoice, also reactivates the subscription and stores the period bounds (the expiry becomes the invoice's period end) |
invoice.payment_succeeded | Same as invoice.paid (legacy alias; subscribing to one of the two is enough) |
invoice.payment_failed | Caches the invoice; dunning failure (subscription_create/subscription_cycle only) → isActive = false |
checkout.session.completed | Subscription Checkout: links the new subscription. Any other Checkout: credits a paid wallet top-up |
checkout.session.async_payment_succeeded | Credits a paid wallet top-up, for delayed payment methods |
charge.refunded | Takes back the refunded share of a wallet top-up |
charge.dispute.created | Takes back the remaining credit of a disputed top-up |
Every event whose type starts with invoice. refreshes the invoice cache, whichever of the listed invoice events it is. Events of a subscription that was replaced by a newer one are ignored (see Plan changes).
Any other event type is acknowledged with 200 and not processed, so selecting all events also works, at the cost of noise. customer.updated is not handled.
Stripe Dashboard setup
- Developers → API keys → copy the secret key into
STRIPE_SECRET_KEY(sk_test_…in test mode,sk_live_…in live mode). - Developers → Webhooks → Add endpoint.
- Endpoint URL:
https://auth.example.com/api/webhooks/stripe. - Payload format: Snapshot; API version:
2026-05-27.dahlia(if the dropdown only offers a newer one, alignsrc/services/stripe.tsand this page first). - Select the events from the table above (or "all events").
- Open the endpoint → Signing secret →
whsec_…→ setSTRIPE_WEBHOOK_SECRET, then restart auth-service. - Send a test event from the endpoint page and check the
stripe-webhook: received eventlog line.
In local development, stripe listen --forward-to localhost:<port>/api/webhooks/stripe prints a CLI-specific whsec_…: use that one locally, not the Dashboard endpoint secret.
Plan changes
A plan change never grants a paid plan before Stripe agrees. Four cases:
| From | To | What happens | The plan is granted when |
|---|---|---|---|
| any | a free plan | Applied at once; the Stripe subscription's item is swapped to the free price | immediately |
| a live paid subscription | a paid plan | Changed in place (switchSubscriptionPlan): Stripe prorates it. The request answers pending | its customer.subscription.updated event |
| a free subscription (or none) | a paid plan | Opened as a Stripe Checkout in subscription mode, answered as checkoutUrl | its checkout.session.completed event (below) |
| a live paid subscription | a second paid checkout | Refused (409 SUB_007): a checkout never creates a second paid subscription | — |
When a checkout completes it replaces the free subscription: the paid subscription becomes the one linked to the user and the application, and only then is the free one cancelled. Stripe keeps sending events for a subscription that was replaced, and those events are ignored (subscription updates, deletions and invoices alike), so a free subscription that ends later cannot downgrade or deactivate the paid one. The same rule covers every event path.
Administrators stop a user's subscription at the end of its period, and resume it, from the user's page; the flag is written at once and confirmed by Stripe's update event. Immediate cancellation is not offered here: it would end access without a refund decision.
Reconciliation and operator tooling
pnpm billing:reconcile compares the database with the Stripe account and reports every drift. It is the recovery path after a Stripe outage, a database restore, a manual change in the Dashboard, or an upgrade.
| Command | Mode | Effect |
|---|---|---|
pnpm billing:reconcile --check | audit | Reads both sides, writes nothing. Exit code 1 when drift or pending meter events exist, 0 when clean. |
pnpm billing:reconcile | repair (default) | Creates missing Customers, Products and Prices, adopts or relinks subscriptions, re-applies the Stripe subscription state and pushes pending meter events. A subscription Stripe confirms as deleted is only reported. Exits with code 1 when drift remains that needs an operator decision. |
pnpm billing:reconcile --recreate-missing | repair + recreate | Everything above, plus recreates a deleted subscription for an active local row. Inactive/canceled rows are never re-subscribed. |
pnpm billing:reconcile --cancel-superseded | repair + cleanup | Everything above, plus cancels a replaced subscription, but only when the local replacement is confirmed active or trialing. |
pnpm billing:reconcile --json | any | Prints the report as JSON instead of the human output. |
What a run checks:
- Customers — every user has a Stripe Customer; a local link to a deleted Customer is reported for manual review (it is never recreated blindly, because its subscriptions would be orphaned).
- Catalogue — each plan's Product and each price row's Price exist, are active, belong to the right Product, and carry the expected name/metadata; a metered price's Meter is active.
- Subscriptions — the linked subscription exists (checked by id, not only through a customer listing), its plan matches the row, and its status, period and cancellation flag match the local mirror. An unlinked row adopts any non-cancelled subscription for its
(user, application)pair, includingpast_due/unpaid, instead of creating a second one. - Superseded and orphan subscriptions — a subscription replaced by a newer one is reported and only cancelled with
--cancel-superseded, and only when the replacement is confirmed active/trialing. A subscription without a confirmed local replacement is reported, never cancelled, because a human may have created it on purpose. A deleted subscription is not recreated unless--recreate-missingis set, and never for an inactive row. - Meter outbox — meter events Stripe has not acknowledged are counted in
--checkand pushed in repair mode.
Nothing is ever deleted from Stripe: Products and Prices are archived, and a subscription is only cancelled after a live replacement is confirmed. All reads are paginated account-wide (customers.list, subscriptions.list) rather than one call per user or customer.
Security properties
- Secrets stay in the environment.
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETare read byconfig.tsand never returned by the admin API, logged or stored. Without the signing secret the webhook answers503; without the secret key every Stripe call is disabled. - Webhook fail-closed. The raw body is verified with the endpoint's signing secret before any database write. A missing or invalid signature answers
400 APP_001with a generic message; the event is not claimed, so a valid retry is still processed. Duplicate deliveries are acknowledged without re-applying state. - Idempotency. Customer and subscription creation use deterministic Stripe idempotency keys; a Checkout session is keyed to a short window so a double click cannot open two subscriptions; metered reports use
(applicationId, idempotencyKey)and the event id as the Stripe meter identifier. - No untrusted links. Stripe invoice URLs are stored and served only when they start with
https://; the delegated pages apply the same rule, so ajavascript:or plain-http link can never reach anhref. - Tenant isolation. The application read API takes the application from the
client_credentialstoken, never from a request parameter, and requires thebilling:readscope. The delegated pages check the application'sbilling_modeand the user's active access before rendering anything. The profile reads only the caller's own invoices and usage. - No sensitive details in errors.
ApiError.detailsonly carries validation field names; secrets, tokens, card data and Stripe payloads are never echoed.
Metered billing
An application reports usage with POST /api/consumption ({ applicationId, userId, key, value, idempotencyKey? }). When key prices a metered feature of the user's active plan (a metered price on that plan), auth-service:
- enforces the hard cap (
usage_limit,-1= unlimited) on the per-UTC-month aggregate — over it the call is refused with403 BIL_003and nothing is recorded; - records the report idempotently (
(applicationId, idempotencyKey); a random key is used when the caller sends none) and increments the period aggregate; - pushes a Stripe meter event (
billing.meterEvents.create) so Stripe bills the quantity on the next invoice. A push that fails (Stripe down, subscription not ready) leaves the event pending;pnpm billing:reconcileand the background retry push it later, and the event id makes the push idempotent.
Keys that are not metered fall through to the free consumption counters, unchanged.
pnpm billing:reconcile --check audits the outbox and the rest of the billing state; plain pnpm billing:reconcile pushes the pending meter events and repairs what an outage missed (see Reconciliation and operator tooling).
Credit top-ups
The same Stripe account can sell prepaid credit for the wallet. Two modes:
Online (Stripe configured). POST /api/user/wallet/checkout with { "amountCents": 1000 } returns a Stripe Checkout URL. The amount must be within WALLET_TOPUP_MIN_EUR / WALLET_TOPUP_MAX_EUR. The checkout is created in euros with Stripe Adaptive Pricing enabled: the customer sees and pays in their local currency, Stripe converts, and the credit granted is always the euro amount. The webhook then credits the wallet, and a refund or a dispute takes the credit back (a reversal may leave the wallet in debt; a dispute later won is not restored automatically — give the credit back with an administrator grant).
Manual (no STRIPE_SECRET_KEY). GET /api/user/wallet/topup-options reports enabled: false and POST …/checkout answers 503 WAL_006. The administrator issues an invoice outside Stripe, then records the credit with POST /api/admin/wallets/users/:userId/topups (superadmin), giving the invoice number as reference.
Invoices and the application read API
Every invoice.* event refreshes a read cache (stripe_invoices), so the profile and applications never call Stripe to list invoices. Subscription invoices belong to the application of their subscription; a wallet top-up receipt belongs to the user only.
An application reads its own users' billing with a client_credentials token carrying the billing:read scope (granted by a superadmin in the application's machine scopes):
GET /api/app/billing/users/:userId/subscription— the plan and its Stripe state;GET /api/app/billing/users/:userId/usage— metered usage of the current month against the cap;GET /api/app/billing/users/:userId/invoices?limit=&cursor=— the invoices, newest first, keyset-paginated.
The application is taken from the token, never from the request: an application cannot read another application's subscription, usage or invoices, and a user's wallet top-up receipts are never listed there. The full shapes are in the API reference.
The profile shows the same invoices (every application and wallet top-ups) with their status and links, through GET /api/user/billing/invoices, and the metered usage of the current period per application through GET /api/user/billing/usage.
Not handled: tax collection (Stripe Tax).
Admin API: check if Stripe is configured
GET /api/admin/services
{
"stripe": true,
"compat": { "profile": "...", "tokenAuthMethod": "...", "signingAlg": "..." },
"providers": {
"google": { "enabled": false, "clientId": "..." }
}
}
stripe is true when STRIPE_SECRET_KEY is set. The response also carries the compatibility settings (compat, abridged above) and the enabled flag and non-secret clientId of each social provider (google, github, linkedin, microsoft, apple); only google is shown above.