Webhooks
The payment webhook that records subscriptions and orders, the database webhook that cancels subscriptions, and how to test both locally.
Two routes receive webhooks. Neither needs a signed-in user; both check a signature instead.
Payment webhook: /api/billing/webhook
apps/web/app/api/billing/webhook/route.ts receives events from the provider set in billing.config.ts:
- Stripe: the
stripe-signatureheader is verified withSTRIPE_WEBHOOK_SECRET. - Lemon Squeezy: the
x-signatureheader is an HMAC of the body, compared in constant time with one made fromLEMON_SQUEEZY_SIGNING_SECRET.
A verified event is turned into rows in billing_customers, subscriptions, subscription_items, orders and order_items, written with the service role. Those tables are read-only for users, so this route is the only way a plan changes. If handling fails, the route answers 500 so the provider retries.
The events each provider's handler acts on are listed in Stripe and Lemon Squeezy.
Testing locally with Stripe
pnpm stripe:listen
This runs the Stripe CLI in Docker and forwards events to http://host.docker.internal:3000/api/billing/webhook. It uses your Stripe CLI login stored in ~/.config/stripe. Copy the whsec_... secret it prints into STRIPE_WEBHOOK_SECRET in apps/web/.env.local, restart pnpm dev, and pay with the test card 4242 4242 4242 4242.
The kit has no local forwarder for Lemon Squeezy. To test it locally, expose port 3000 through a tunnel and point a test-mode webhook at it.
Database webhook: /api/db/webhook
Supabase can call a URL when a row changes. The kit uses this for one case: when a row in subscriptions is deleted (for example because its account was deleted), /api/db/webhook asks the provider to cancel that subscription, unless it is already cancelled.
The request must carry the header X-Supabase-Event-Signature with the value of SUPABASE_DB_WEBHOOK_SECRET; the route compares them in constant time.
- Locally,
apps/web/supabase/seed.sqlcreates thesubscriptions_deletetrigger that posts tohttp://host.docker.internal:3000/api/db/webhookwith the development secret. - In production, the seed is not applied, so create the webhook yourself in the Supabase dashboard (Database, then Webhooks): table
subscriptions, eventDELETE, URLhttps://<your-domain>/api/db/webhook, and the header above with your production secret.
To act on other table changes, add a case to packages/database-webhooks/src/server/services/database-webhook-router.service.ts and create the matching webhook.