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-signature header is verified with STRIPE_WEBHOOK_SECRET.
  • Lemon Squeezy: the x-signature header is an HMAC of the body, compared in constant time with one made from LEMON_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.sql creates the subscriptions_delete trigger that posts to http://host.docker.internal:3000/api/db/webhook with the development secret.
  • In production, the seed is not applied, so create the webhook yourself in the Supabase dashboard (Database, then Webhooks): table subscriptions, event DELETE, URL https://<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.