Guide
Stripe webhooks with Supabase Edge Functions: a working setup for Lovable and Bolt apps
Short answer: a Stripe webhook on a Supabase Edge Function needs three things to work. (1) Switch off Supabase's JWT check for that one function (verify_jwt = false), because Stripe doesn't send a Supabase login token.¹ (2) Verify Stripe's signature yourself using the raw request body (await req.text()) with constructEventAsync.² ³ (3) Use the signing secret for that exact endpoint and mode, because test and live secrets are different.³ Get those right and most "webhook not working" problems disappear.
Why this matters
Checkout is the easy part. The webhook is how your app knows a payment succeeded, so it can unlock the account, send the receipt or mark the order paid. Stripe's docs give a concrete example: a customer's bank confirming a payment happens after checkout, and only a webhook tells you.³ If the webhook silently fails, people pay and get nothing.
The symptoms, decoded
Stripe shows every delivery attempt under Workbench → Webhooks → (your endpoint) → Event deliveries.³ (Workbench replaces the old Developers Dashboard, so older tutorials' screenshots won't match.)
| What Stripe shows | What it usually means in a Supabase/Lovable/Bolt app |
|---|---|
| 401 | Supabase rejected the request before your code ran, because JWT verification is still on for the function.¹ |
| 400 + "No signatures found matching the expected signature" | The body was parsed or changed before verification, or you're using the wrong whsec_ secret (e.g. the test secret in live mode, or the CLI secret in production).³ |
| 3xx | Your endpoint URL redirects (http→https, or root→www). Stripe treats any redirect as a failure, so register the final URL.³ |
| 404 / 405 | Wrong URL or function name, or the endpoint doesn't accept POST.³ |
| Timed out | You're doing slow work before replying. Return 2xx fast.³ |
| TLS error | A certificate problem on a custom domain. Stripe needs TLS 1.2+.³ |
Step-by-step setup
This follows Supabase's official Stripe webhook example.²
1. Create the function
supabase/functions/stripe-webhooks/index.ts:
import Stripe from 'npm:stripe@^22'
import { withSupabase } from 'npm:@supabase/server@^1'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
// Needed to use the Web Crypto API in Deno
const cryptoProvider = Stripe.createSubtleCryptoProvider()
// Stripe proves who it is with its signature, so deploy with verify_jwt = false.
export default {
fetch: withSupabase({ auth: 'none' }, async (req) => {
const signature = req.headers.get('Stripe-Signature')
// Use the RAW body. Parsing JSON first breaks signature verification.
const body = await req.text()
let event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
undefined,
cryptoProvider
)
} catch (err) {
return new Response(err.message, { status: 400 })
}
// Handle only the events you subscribed to
switch (event.type) {
case 'checkout.session.completed':
// TODO: look up the order/user and mark it paid (see step 5)
break
}
return Response.json({ ok: true })
}),
}
The parts that matter: await req.text() (the raw body) and constructEventAsync with createSubtleCryptoProvider(). Edge Functions run on Deno, where Stripe's async verification is the one that works.² The synchronous constructEvent you'll see in Node tutorials doesn't work there.
2. Turn off JWT verification for this function only
In supabase/config.toml:¹
[functions.stripe-webhooks]
verify_jwt = false
This makes the function publicly callable, which is why the signature check in step 1 is not optional.¹ Leave JWT verification on for every other function.
Lovable users: some people on r/lovable report the JWT setting flipping back on after redeploys (user reports, not confirmed by Lovable). If deliveries suddenly start returning 401, check this first.
3. Set the secrets (never in code)
With the Supabase CLI:⁴
supabase secrets set STRIPE_API_KEY=sk_test_... STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
In Lovable (Cloud) or Bolt, use the tool's secrets/environment settings instead of pasting keys into chat or code. Use a restricted Stripe key with only the permissions the function needs.
4. Deploy and register the endpoint
supabase functions deploy stripe-webhooks
Your endpoint URL is https://<project-ref>.supabase.co/functions/v1/stripe-webhooks.
In Stripe: Workbench → Webhooks → Create an event destination → Your account, pick the events (start with checkout.session.completed), choose Webhook endpoint, paste the URL, then reveal the whsec_ signing secret and set it as STRIPE_WEBHOOK_SIGNING_SECRET.³
⚠️ Test and live are separate. Each endpoint has its own secret, and the same URL has a different secret in test and live mode.³ When you go live, create the live endpoint, then update both
STRIPE_API_KEYandSTRIPE_WEBHOOK_SIGNING_SECRET.
5. Update your database safely
The webhook runs on the server, so it may use the Supabase secret key to write (for example, marking an order paid). That key bypasses Row Level Security, so it must never appear in browser code.⁵ Supabase's docs show reading it inside an Edge Function from the injected SUPABASE_SECRET_KEYS:⁵
import { createClient } from 'npm:@supabase/supabase-js@2'
const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL')!, secretKeys['default'])
Two rules from Stripe's best practices:³
- Expect duplicates. Stripe can deliver the same event more than once, so store event.id and skip events you've already processed.
- Don't rely on order. Events can arrive out of order. If you need the latest state, fetch the object from the Stripe API.
6. Test before real money moves
Locally, run the function without JWT checks and forward Stripe events to it with the Stripe CLI:¹ ³
supabase functions serve stripe-webhooks --no-verify-jwt
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhooks
stripe trigger checkout.session.completed
stripe listen prints its own whsec_ secret for local testing. Use that one locally, and don't confuse it with your deployed endpoint's secret.³
Go-live checklist
- [ ]
verify_jwt = falseon the webhook function only - [ ] Signature verified with the raw body
- [ ] Live-mode endpoint created; live
sk_/rk_key and livewhsec_set - [ ] The endpoint URL is final (no redirects)
- [ ] Duplicate events are ignored (
event.idstored) - [ ] Your handler returns
2xxquickly - [ ] One real low-value live payment tested end to end, then refunded
FAQ
Why do I get a 401 from my Supabase function when Stripe calls it?
Supabase Edge Functions require a valid JWT by default, and Stripe doesn't send one. Set verify_jwt = false for the webhook function.¹
Why does signature verification fail even though the secret looks right?
Usually either the body was parsed before verification (use await req.text()), or the secret belongs to a different endpoint or mode, or to the Stripe CLI.³
Do I need webhooks if I use Stripe Payment Links? If your app needs to do something after payment (unlock features, provision an account), yes. If you only sell something you fulfil by hand, Stripe's emails and Dashboard may be enough.
Does Stripe retry failed webhooks? Yes. In live mode Stripe retries for up to three days with exponential back-off. In a sandbox it retries three times over a few hours.³
Not sure yours is right? The £39 Launch Check checks for Stripe test mode on your live site, secret keys in your frontend and (with a public repo) webhook handlers that skip signature verification. It tells you what to fix in plain English, with copy-paste prompts for Lovable, Bolt or Cursor. We never ask for your keys or account access.
Sources
- Supabase docs, Function configuration (
verify_jwt,--no-verify-jwt): https://supabase.com/docs/guides/functions/function-configuration - Supabase, Handling Stripe Webhooks example: https://supabase.com/docs/guides/functions/examples/stripe-webhooks · code: https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts
- Stripe docs, Receive Stripe events in your webhook endpoint: https://docs.stripe.com/webhooks
- Lovable docs, Deploying and hosting outside Lovable (
supabase secrets set): https://docs.lovable.dev/tips-tricks/external-deployment-hosting and Supabase CLI docs: https://supabase.com/docs/reference/cli/start - Supabase docs, API keys: https://supabase.com/docs/guides/api/api-keys