Docs / Billing / Webhooks
Webhooks
Subscription state in Keel is driven by webhooks, not by the checkout response. The route at app/api/webhooks/stripe/route.ts is permanently wired to Stripe regardless of which provider BILLING_PROVIDER selects, so a buyer mid-migration between providers can keep every webhook URL live at once.
Signature verification
The route reads the request body with request.text(), never request.json(). Stripe's signature is computed over the exact bytes received, and re-serializing parsed JSON would produce a byte-different string that fails verification. It hands the raw body and headers to stripeProvider.verifyWebhook(), which calls stripe.webhooks.constructEventAsync() with STRIPE_WEBHOOK_SECRET. An invalid signature (or a missing secret) returns { ok: false }, and the route responds 400.
Events and how they map to state
On a verified event, verifyWebhook normalizes it into a provider-agnostic NormalizedBillingEvent (lib/billing/types.ts) and processWebhookEvent in lib/billing/sync.ts applies it:
customer.subscription.created/updated, mapped tosubscription.synced, upserts thesubscriptionsrow (status, plan, interval, period end,cancelAtPeriodEnd). A transition intopast_due/unpaidtriggers a dunning email; a transition intocanceledtriggers a cancellation email.customer.subscription.deletedsets the row's status tocanceled.checkout.session.completedwithmode: "payment"andmetadata.kind === "credits"grants credits; subscription checkouts are ignored here and synced from thecustomer.subscription.*events instead.invoice.paidmaps topayment.succeededand sends a receipt email.
There is deliberately no invoice.payment_failed handler. Stripe fires it alongside a status flip to past_due, and handling both would double-send the dunning email, so sync.ts derives dunning from the status transition alone.
Idempotency
Every provider guarantees only at-least-once delivery, so the same event can arrive twice. processWebhookEvent inserts into billing_webhook_events (composite primary key (provider, event_id)) with onConflictDoNothing inside the same transaction as the mutation. If the insert returns no row, this delivery was already processed and the handler acks and skips. Because the de-dupe row and the mutation share one transaction, a mutation that throws rolls the de-dupe row back too, so a retried delivery of a failed event is correctly reprocessed. Credit grants add a second guard: the credit_purchases.provider_checkout_id unique constraint gates the balance increment independently.
Why a 500 on failure
If processing throws, the route returns 500, not 200, so Stripe retries. A transient database error must never leave a real subscription or credit change silently unapplied.
Local development
Point Stripe at your local route with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a signing secret (whsec_...); put it in STRIPE_WEBHOOK_SECRET. Trigger events by running real test checkouts, or with stripe trigger checkout.session.completed.