Skip to content
BoringStack
GitHub

Billing

5 min read

Stripe without platform lock-in

Billing is optional. When BILLING_ENABLED=false, the billing route group returns 404 and the API does not instantiate Stripe. When it is true, the env validator requires the Stripe secret key, webhook secret, and price IDs before the app listens.

404

when billing is disabled

raw

webhook body verification

SQL

idempotency claim

The template ships the subscription spine, not a pricing strategy. It wires Stripe Checkout, Stripe Customer Portal, plan persistence, webhooks, audit events, and redirect safety. Forks can rename plans or add tiers once the product shape is real.

sequenceDiagram
  participant UI as UI
  participant API as API
  participant DB as Postgres
  participant Stripe
  UI->>API: POST /api/v1/billing/stripe/checkout-session
  API->>API: verify auth_token cookie + resolve active account
  API->>API: allowlist successUrl + cancelUrl against FRONTEND_URL origin
  API->>DB: upsert Free / Pro plans from STRIPE_PRICE_ID_*
  API->>Stripe: create checkout session (account.stripe_customer_id)
  API->>DB: update account.stripe_customer_id when first needed
  API-->>UI: { url }
  UI->>Stripe: redirect browser to hosted checkout

The customer portal follows the same pattern: authenticated user, returnUrl allowlisted against FRONTEND_URL, Stripe returns a hosted URL.

flag

Billing is feature-flagged

Local apps boot without Stripe credentials. Production billing only boots when required env vars are present.

plans

Plans come from env

STRIPE_PRICE_ID_FREE and STRIPE_PRICE_ID_PRO upsert default plan rows without manual SQL.

cookie

Customer routes are protected

Checkout, portal, and plan reads use the same cookieAuth OpenAPI contract as the rest of the API.

return

Redirect URLs are allowlisted

Stripe-hosted flows may only return to the configured FRONTEND_URL origin.

webhook

Raw body verification

The webhook route passes the exact request payload to Stripe signature verification.

idempotent

Postgres-backed idempotency

Every Stripe event id is claimed in the same transaction as the side effect.

Billing HTTP surface
Endpoint
Auth
Purpose
GET /api/v1/billing/plans
cookie
List configured plans.
POST /api/v1/billing/stripe/checkout-session
cookie
Create a Stripe Checkout session for the active account.
POST /api/v1/billing/stripe/portal-session
cookie
Create a Stripe Customer Portal session for the active account.
POST /api/v1/billing/stripe/webhooks
Stripe signature
Receive Stripe events using the raw request body.
sequenceDiagram
  participant Stripe
  participant API
  participant DB as Postgres
  Stripe->>API: POST raw payload + Stripe-Signature
  API->>API: constructWebhookEvent(payload, signature)
  API->>DB: INSERT billing.stripe_webhook_events(event_id) ON CONFLICT DO NOTHING
  alt first delivery
    API->>DB: apply subscription side effect in same transaction
    API-->>Stripe: 200 received
  else duplicate delivery
    API-->>Stripe: 200 already processed
  end

Handled events:

  • checkout.session.completed: creates or updates billing.account_plans for the account and plan in session metadata.
  • customer.subscription.updated: maps the active Stripe price id back to a local plan and updates the account plan; also tracks past_due, unpaid, paused, canceled, incomplete, trialing, and active for the feature resolver.
  • customer.subscription.deleted: marks the row revoked so the resolver falls back to the Free plan.
  • invoice.paid / invoice.payment_failed: status transitions for the active plan row.

Unknown event types are logged at debug level and ignored. Late or out-of-order deliveries are tolerated because every status transition is keyed by (account_id, stripe_event_received_at).

toggle

BILLING_ENABLED

Turns the route group on; false means all billing paths return 404.

sdk

STRIPE_SECRET_KEY

Used by the Stripe SDK for Checkout, Portal, and webhook construction.

webhook

STRIPE_WEBHOOK_SECRET

Used to verify Stripe-Signature on raw webhook payloads.

plans

STRIPE_PRICE_ID_FREE / PRO

Seeds and keeps the built-in plan rows aligned with Stripe.

src/api/billing/ and src/clients/postgres/schema/billing.schema.ts on GitHub.

Read-only psql snippets for “what’s the subscription state right now?” questions. Run inside the app database (docker compose exec postgres psql -U app -d app).

-- Active subscriptions grouped by plan.
SELECT plan_id, status, count(*)
FROM billing.account_plans
WHERE revoked_at IS NULL
GROUP BY 1, 2
ORDER BY 1, 2;
-- Accounts on the Pro plan, with when each started + last status transition.
SELECT a.name, ap.status, ap.created_at, ap.updated_at
FROM billing.account_plans ap
JOIN app.accounts a ON a.id = ap.account_id
WHERE ap.plan_id = 'pro'
AND ap.revoked_at IS NULL
ORDER BY ap.updated_at DESC;
-- Webhook deliveries in the last 24 hours by event type.
SELECT event_type, count(*)
FROM billing.stripe_webhook_events
WHERE created_at > now() - interval '24 hours'
GROUP BY 1
ORDER BY 2 DESC;
-- Failed-payment accounts that need attention.
SELECT a.name, ap.plan_id, ap.status, ap.updated_at
FROM billing.account_plans ap
JOIN app.accounts a ON a.id = ap.account_id
WHERE ap.status IN ('past_due', 'unpaid', 'incomplete')
AND ap.revoked_at IS NULL
ORDER BY ap.updated_at;
-- Confirm idempotency works: every webhook event_id should appear exactly once.
SELECT event_id, count(*)
FROM billing.stripe_webhook_events
GROUP BY 1
HAVING count(*) > 1;