Verify against the raw request body
In a Next.js App Router route handler, read the body with request.text() and pass that string to constructEvent. In Express, mount the webhook route before app.use(express.json()). On Supabase Edge Functions (Deno), use constructEventAsync with Stripe.createSubtleCryptoProvider().
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // raw body, never req.json() here
const sig = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
await handleOnce(event) // idempotent, see below
return new Response('ok', { status: 200 })
}
Register the live endpoint and use its own secret
In live mode, add the endpoint in the Dashboard under Workbench, Webhooks, pointing at your production HTTPS URL. Subscribe it to the events you act on, typically checkout.session.completed, checkout.session.async_payment_succeeded, invoice.paid, customer.subscription.updated and customer.subscription.deleted. Copy that endpoint's whsec_ secret into your production environment, then redeploy so the new value is picked up.
Let Stripe's request reach the handler
Make sure the production environment has both the secret key and the webhook secret, and exit at startup with a clear error if either is missing. On Supabase, turn off platform JWT verification for this one function and rely on the Stripe signature instead.
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
Make the handler idempotent by event id
Record each processed event id in the same transaction as the grant, so a retry or a duplicate becomes a no-op. Keep the handler short and return 2xx before any slow work such as emails or accounting sync. Otherwise the delivery times out and Stripe retries it.
create table stripe_events (
id text primary key, -- evt_...
processed_at timestamptz not null default now()
);
-- in the same transaction as the grant:
insert into stripe_events (id) values ($1)
on conflict (id) do nothing
returning id; -- no row back = already handled, return 200
Read current state instead of trusting event order
When an event arrives, fetch the current Checkout Session or Subscription from the API and decide from that. For Checkout, grant when payment_status is not unpaid. For subscriptions, grant while status is active or trialing, and revoke on canceled or unpaid.
Reconcile on sign-in or on a schedule
Stripe recommends also triggering fulfillment from the success page, because webhooks can be delayed. Beyond that, check the customer's subscription on sign-in or on a schedule, so a single lost event cannot cost a paying customer their access.
const subs = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 10 })
const hasAccess = subs.data.some(s => s.status === 'active' || s.status === 'trialing')