ADR-004: Billing Integration Strategy
Status: Accepted (Implemented) Owner: @bilal Date: 2025-12-06 Updated: 2026-03-12
Context
Organisations need a payment flow tied to plan selection during onboarding, with subscription management via Stripe.
Original decision was to defer. As of March 2026, Stripe billing is implemented for the Growth (self-serve) motion.
Decision
Use Stripe Checkout for payment collection and Stripe Webhooks for reliable subscription lifecycle management.
Architecture
Onboarding (client)
|
| POST /api/stripe/checkout { plan, orgName, orgType }
v
Server creates Stripe Checkout Session
- metadata: { userId, plan, orgName, orgType }
- success_url: /onboarding?step=success&session_id={ID}
|
v
User pays on Stripe hosted page
|
+---> Stripe redirects browser to success_url
| |
| v
| completeOnboarding(sessionId)
| - Retrieves session from Stripe API
| - Reads plan/name/type from Stripe metadata (NOT client)
| - Verifies payment_status === "paid"
| - Verifies metadata.userId === authenticated user
| - Creates organisation + access (idempotent)
| - Proceeds to import step
|
+---> Stripe fires checkout.session.completed webhook
|
v
/api/stripe/webhook
- Verifies signature (STRIPE_WEBHOOK_SECRET)
- If org exists: attaches Stripe billing fields
- If org missing: creates org from session metadata (safety net)
Security Model
- Stripe session metadata is the single source of truth for plan, org name, and org type.
- The client only provides a
sessionIdon the return path. It cannot influence what plan or org data gets created. - This prevents plan tampering (e.g. paying for Starter but requesting Premium).
completeOnboardingis idempotent — if the webhook already created the org, it returns the existing one.
Webhook Events Handled
| Event | Action |
|---|---|
checkout.session.completed | Create org (if missing) + attach stripeCustomerId, stripeSubscriptionId |
customer.subscription.updated | Update stripeSubscriptionStatus |
customer.subscription.deleted | Set status to canceled, downgrade plan to starter |
Database Schema
-- On organisations table (added via migration 008_stripe_fields.sql)
stripe_customer_id TEXT
stripe_subscription_id TEXT
stripe_subscription_status TEXT -- 'active', 'past_due', 'canceled', etc.Key Files
| File | Purpose |
|---|---|
app/api/stripe/checkout/route.ts | Creates Stripe Checkout Session |
app/api/stripe/webhook/route.ts | Handles Stripe webhook events |
lib/stripe/index.ts | Stripe client singleton, plan helpers |
lib/actions/onboarding.ts | completeOnboarding(sessionId) — creates org from Stripe metadata |
lib/plans.ts | Plan limits (property/tenant caps) from DB |
Webhook Setup
Local development:
stripe login # One-time auth
pnpm stripe:webhook # Forwards to localhost:3000
pnpm stripe:webhook:cf # Forwards to localhost:8787 (Cloudflare preview)The CLI prints a whsec_... signing secret — set it as STRIPE_WEBHOOK_SECRET in .env.
Production (Cloudflare Workers):
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://app.ehq.tech/api/stripe/webhook - Events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted - Copy signing secret →
npx wrangler secret put STRIPE_WEBHOOK_SECRET
Consequences
Positive
- Payment verified server-side before org creation
- No client-side plan tampering possible
- Webhook safety net handles dropped connections, browser crashes, etc.
- Idempotent — safe against retries and race conditions
Negative
- Requires Stripe CLI running during local dev for full flow testing
- Webhook must be configured in Stripe Dashboard for each environment
Related
- PRICING_SPEC — Full pricing tiers, features, and billing infrastructure
- ADR-019 Commercial Motions Architecture — Growth / Acquisition / Partner motions
supabase/migrations/008_stripe_fields.sql— Schema migration