Keel

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 to subscription.synced, upserts the subscriptions row (status, plan, interval, period end, cancelAtPeriodEnd). A transition into past_due/unpaid triggers a dunning email; a transition into canceled triggers a cancellation email.
  • customer.subscription.deleted sets the row's status to canceled.
  • checkout.session.completed with mode: "payment" and metadata.kind === "credits" grants credits; subscription checkouts are ignored here and synced from the customer.subscription.* events instead.
  • invoice.paid maps to payment.succeeded and 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:

bash
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.

Edit this page on GitHub →Last updated Jul 22, 2026