Webhook Security
Stripe webhook signature verification, idempotency, error handling, and local testing.
Overview
The stripe-webhook Edge Function receives events from Stripe whenever a payment, subscription, or invoice changes. Since this endpoint is publicly accessible, it must verify that requests genuinely come from Stripe.
Stripe Signature Verification
Every Stripe webhook request includes a stripe-signature header. The Edge Function verifies this signature using your webhook secret:
// supabase/functions/stripe-webhook/index.ts
import Stripe from "https://esm.sh/stripe@14?target=deno";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
apiVersion: "2023-10-16",
httpClient: Stripe.createFetchHttpClient(),
});
const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;
Deno.serve(async (req) => {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("No signature", { status: 400 });
}
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Signature verified -- process the event
// ...
return new Response(JSON.stringify({ received: true }), { status: 200 });
});Key points:
- Read the body as raw text (
req.text()), not JSON. Parsing to JSON before verification breaks the signature check. - The
STRIPE_WEBHOOK_SECRETstarts withwhsec_. Get it from the Stripe dashboard when creating the webhook endpoint. - If verification fails, return
400immediately. Do not process the event.
Idempotency
Stripe may send the same event multiple times (e.g., on retries). Your handler must be idempotent -- processing the same event twice should not cause issues.
Strategy: Use the event ID
// Check if we've already processed this event
const { data: existing } = await supabaseAdmin
.from("webhook_events")
.select("id")
.eq("stripe_event_id", event.id)
.single();
if (existing) {
// Already processed, skip
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
// Process the event
await handleEvent(event);
// Record that we processed it
await supabaseAdmin
.from("webhook_events")
.insert({ stripe_event_id: event.id, type: event.type });Strategy: Upsert instead of insert
For simpler cases, use upsert so duplicate events just overwrite:
await supabaseAdmin
.from("subscriptions")
.upsert({
stripe_subscription_id: subscription.id,
user_id: userId,
status: subscription.status,
plan_id: planId,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}, {
onConflict: "stripe_subscription_id",
});Error Handling
Handle errors gracefully to avoid losing events:
Deno.serve(async (req) => {
// ... signature verification ...
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
} catch (error) {
console.error(`Error processing ${event.type}:`, error);
// Return 500 so Stripe retries the event
return new Response(
JSON.stringify({ error: "Processing failed" }),
{ status: 500 }
);
}
});Return codes:
200: Event processed successfully. Stripe will not retry.400: Bad request (invalid signature). Stripe will not retry.500: Processing failed. Stripe will retry with exponential backoff (up to 3 days).
Setting Up the Webhook
In the Stripe Dashboard
- Go to Developers > Webhooks.
- Click Add endpoint.
- Enter your endpoint URL:
https://<project-ref>.supabase.co/functions/v1/stripe-webhook - Select events to listen to:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- Copy the Signing secret (
whsec_...). - Set it in Supabase:
pnpm supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_xxx
Testing Webhooks Locally
Using Stripe CLI
# Install Stripe CLI and login
stripe login
# Forward events to your local Supabase
stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhook
# In another terminal, trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failedThe Stripe CLI provides a temporary webhook secret for local testing. Use it in your supabase/.env:
STRIPE_WEBHOOK_SECRET=whsec_test_xxxVerifying in the Stripe Dashboard
After deploying, check Developers > Webhooks > [your endpoint] to see:
- Recent event deliveries
- Response codes and bodies
- Retry attempts for failed deliveries
Done reading? Mark this page as complete.