ScaleRocket/Web

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_SECRET starts with whsec_. Get it from the Stripe dashboard when creating the webhook endpoint.
  • If verification fails, return 400 immediately. 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

  1. Go to Developers > Webhooks.
  2. Click Add endpoint.
  3. Enter your endpoint URL:
    https://<project-ref>.supabase.co/functions/v1/stripe-webhook
  4. Select events to listen to:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed
  5. Copy the Signing secret (whsec_...).
  6. 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_failed

The Stripe CLI provides a temporary webhook secret for local testing. Use it in your supabase/.env:

STRIPE_WEBHOOK_SECRET=whsec_test_xxx

Verifying 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.

On this page