Skip to content

Backend

Stripe Webhooks on Netlify — Signature Verification, Idempotency, and Retry Logic

All articles
🪝 🔐

Most webhook code is subtly broken. Signature verification fails silently when you parse the body first.

Stripe webhooks are mission-critical for e-commerce SaaS. A payment succeeds, Stripe fires a `checkout.session.completed` event to your server, and you provision the user in the database. If your webhook handler breaks or duplicates, the entire checkout flow breaks. Developers build webhooks wrong in three consistent ways: verifying signatures on parsed request bodies (the raw body is what gets signed, not the JSON), losing retry idempotency when Stripe retries after a timeout, and not handling request buffering in Netlify functions where the body doesn't auto-buffer. Velocity X's buy-once product flow uses Payment Links → this exact webhook pattern. Here's the real implementation: signature verification on raw bytes, idempotency keys logged to Supabase, retry-safe handlers, and how to test locally.

Why Stripe Webhooks Break in Production

The most common bug: you read the request body as JSON, verify the signature against that JSON object, and the verification fails. The signature is computed on the *raw UTF-8 bytes* that Stripe sent. The moment you `JSON.parse()`, you've created a new string — whitespace changes, key ordering varies, and the signature no longer matches. This works in local testing if your test harness sends the same formatted JSON Stripe would, but production logs show 403 Unauthorized silently in the webhook history, and you don't realize your handler never ran.

The second break: Stripe retries webhooks. If your handler takes too long or your server returns a 5xx error, Stripe retries after exponential backoff (5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours). Without idempotency tracking, the same webhook fires twice, and you provision the user twice, charge them twice, or create duplicate entries. Idempotency means tracking the event ID and skipping re-processing.

The third: Netlify functions don't auto-buffer request bodies like Express does. If you access `event.body` directly, you might get `null`. The body streams in, and you need to explicitly read it as raw bytes before parsing.

Netlify Function Template: Correct Pattern

Here's the function that handles Velocity's checkout.session.completed webhook. It reads the raw body first, verifies the Stripe signature on those exact bytes, logs an idempotency record, and returns early if the event was already processed.

// netlify/functions/webhook-stripe.ts
import { createClient } from '@supabase/supabase-js';

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
);

export default async (event: any) => {
  // CRITICAL: read body as raw bytes FIRST, before any parsing
  const rawBody = event.body;

  if (!rawBody) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'No body' })
    };
  }

  // Verify signature on raw bytes
  const sig = event.headers['stripe-signature'];
  let stripeEvent;

  try {
    stripeEvent = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    console.error('Signature verification failed:', err.message);
    return {
      statusCode: 403,
      body: JSON.stringify({ error: 'Signature verification failed' })
    };
  }

  // Log idempotency: skip if we already processed this event
  const { data: existing } = await supabase
    .from('webhook_events')
    .select('id')
    .eq('stripe_event_id', stripeEvent.id)
    .single();

  if (existing) {
    console.log('Event already processed:', stripeEvent.id);
    return {
      statusCode: 200,
      body: JSON.stringify({ received: true })
    };
  }

  // Now process the event
  if (stripeEvent.type === 'checkout.session.completed') {
    const session = stripeEvent.data.object;

    try {
      // Provision user in Supabase based on session metadata
      const { data, error } = await supabase
        .from('users')
        .update({
          is_subscriber: true,
          stripe_customer_id: session.customer,
          provisioned_at: new Date().toISOString()
        })
        .eq('email', session.customer_email);

      if (error) throw error;

      // Log successful idempotency record
      await supabase.from('webhook_events').insert({
        stripe_event_id: stripeEvent.id,
        event_type: stripeEvent.type,
        processed_at: new Date().toISOString(),
        user_email: session.customer_email
      });

      console.log('Webhook processed:', stripeEvent.id);
      return {
        statusCode: 200,
        body: JSON.stringify({ received: true })
      };
    } catch (err: any) {
      console.error('Processing error:', err.message);
      // Return 5xx so Stripe retries
      return {
        statusCode: 500,
        body: JSON.stringify({ error: 'Processing failed' })
      };
    }
  }

  // Other event types: acknowledge receipt
  return {
    statusCode: 200,
    body: JSON.stringify({ received: true })
  };
};

Idempotency Table Schema

The `webhook_events` table is your idempotency ledger. It tracks which Stripe events have been processed so retries don't duplicate user provisioning.

create table webhook_events (
  id uuid primary key default gen_random_uuid(),
  stripe_event_id text unique not null,
  event_type text not null,
  user_email text not null,
  processed_at timestamp default now(),
  created_at timestamp default now()
);

-- Index for fast idempotency lookups
create index idx_webhook_events_stripe_id on webhook_events(stripe_event_id);

Every webhook logs its `stripe_event_id` here. Before processing, query this table with the event ID. If a row exists, Stripe already delivered this event (or a retry is arriving). Return 200 OK immediately. This pattern prevents duplicate provisioning even if your handler runs slow or crashes mid-processing.

Local Testing with Stripe CLI

Testing webhooks locally is non-negotiable. Install the Stripe CLI, forward events to your local function, and verify the signature verification works before deploying.

# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Log in to your Stripe account
stripe login

# Forward checkout.session.completed to your local function
stripe listen --forward-to localhost:8888/.netlify/functions/webhook-stripe --events checkout.session.completed

# In another terminal, trigger a test event
stripe trigger checkout.session.completed

# Check logs in your Netlify dev server (should see "Webhook processed")

The CLI shows you the exact payload Stripe sends, including the raw signature header. If your function logs "Signature verification failed," double-check that `STRIPE_WEBHOOK_SECRET` matches the signing secret from the Stripe dashboard (CLI gives you a different secret for local testing — use that one). The test event fires to your function with the correct signature; if it fails here, it will fail in production.

Why This Matters for Velocity

Velocity's buy-once model uses Stripe Payment Links. User lands on the pricing page, clicks "Buy," lands on a Stripe-hosted checkout, and completes payment. Stripe returns the customer to a success page, but the user isn't actually provisioned until the webhook fires. If the webhook breaks, the user has paid but can't use the product. Silent signature failures (returning 403 to Stripe while logging nothing visible to the user) are the worst — Stripe marks the webhook as failed after 8 retries, and the user is left hanging. Logging, signature verification on raw bytes, and idempotency tracking turn this from a production nightmare into a predictable, debuggable flow. Read more about user provisioning and security at RLS policies in Supabase.

Six FAQs

What if Stripe's signature verification fails?

Return 403 immediately and log the error. Stripe records this as a failed delivery. After 8 consecutive failures over 24 hours, Stripe disables the webhook and sends you an email. Check your logs for "Signature verification failed," confirm your `STRIPE_WEBHOOK_SECRET` is correct, and redeploy. Once you fix it, manually retry the event from the Stripe dashboard.

How long can my webhook handler take?

Stripe waits 5 seconds for a 2xx response. Take longer, and Stripe treats it as a timeout and retries. Netlify function timeout is 26 seconds. Keep handlers under 3 seconds: read the event, check idempotency, insert a record or async task, and return. If you need to do heavy lifting (send 50 emails, rebuild a dashboard), queue it to a background job service and return immediately.

Can I test webhooks in development without Stripe CLI?

Yes, but don't. Write a test function that constructs a mock event and calls your handler directly. This catches logic bugs but not signature verification bugs. The Stripe CLI is free and takes 2 minutes to set up — use it. You'll catch silent signature failures before production.

What if the user already has a Stripe Customer ID?

Check for it in the session metadata. When you create the Payment Link, pass `customer_data[email]` so Stripe uses an existing customer or creates one. Log the `session.customer` ID to your database. On retry, check if the user already has `stripe_customer_id` set before updating. Prevents duplicate customer records.

Should I process other webhook types?

Only process what you need. `checkout.session.completed` is the critical one for provisioning. `charge.failed` is useful for alerting. `customer.subscription.deleted` if you have recurring billing. Ignore the rest. More events = more edge cases = more bugs. Start minimal.

How do I know if a webhook is stuck?

Check the Stripe dashboard → Developers → Webhooks → Endpoint details. You'll see delivered, failed, and pending delivery counts. If pending is high, your endpoint is slow or timing out. If failed is high, you're returning non-2xx. Check your function logs (Netlify Functions dashboard) and your `webhook_events` table. Query for events that arrived but weren't inserted — those failed mid-processing.

The Bottom Line

Stripe webhooks are a three-step pattern: verify the signature on raw bytes (not parsed JSON), track idempotency with an event ID logged to the database, and return 200 OK or 5xx to let Stripe know whether to retry. Silent failures (returning 403 to Stripe while your user watches a loading spinner) are the silent killer of checkout flows. Raw body → signature → idempotency check → process → log → return. Test locally with Stripe CLI before deploying. Once this is in place, you can build payment-driven SaaS features with confidence. Ready to ship a buy-once product? Check Aidxn Design SaaS pricing for implementation partnerships.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.