TL;DR: A webhook is an HTTP POST request a third-party service sends to your server when something happens — a payment completes, a user signs up, a GitHub push occurs. You build a URL endpoint that receives and processes those events. Always verify the webhook signature to confirm the request is genuine.

Why AI Coders Need to Know This

Most real apps integrate with external services: Stripe for payments, GitHub for CI/CD triggers, Twilio for SMS, SendGrid for email delivery confirmations, Shopify for order events. All of these use webhooks to push events to your app in real time.

The alternative — polling — means your server asks "anything new?" every few seconds. Polling is inefficient, delayed, and can hit API rate limits. Webhooks are push-based: the event arrives at your server the moment it happens. When a Stripe payment succeeds, Stripe calls your webhook endpoint within seconds. Your app knows immediately, without polling.

Real Scenario

Prompt I Would Type

Add Stripe payment processing to my Next.js app.
When a payment succeeds, update the user's subscription status in my database.
Handle the case where the user closes the browser after paying but before
the success page loads — the subscription should still activate.

This is exactly the problem webhooks solve. The success page redirect is unreliable — the user could close the browser, lose connection, or have JavaScript disabled. The Stripe webhook fires server-to-server, independently of the browser, guaranteed.

What AI Generated

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { db } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// STRIPE_WEBHOOK_SECRET is different from STRIPE_SECRET_KEY
// Get it from: Stripe Dashboard → Webhooks → your endpoint → Signing secret

export async function POST(request: Request) {
  const body = await request.text();
  // Must use raw body (text), not parsed JSON
  // Stripe's signature verification requires the exact raw bytes

  const signature = headers().get('stripe-signature');
  if (!signature) {
    return Response.json({ error: 'Missing signature' }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    // Verify the webhook came from Stripe (not a fake request to your URL)
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Process the event
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.metadata?.userId;  // you set this when creating the session

      if (userId) {
        await db.user.update({
          where: { id: userId },
          data: {
            subscriptionStatus: 'active',
            stripeCustomerId: session.customer as string,
            subscriptionId: session.subscription as string,
          }
        });
        console.log(`Activated subscription for user ${userId}`);
      }
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      // Find user by stripeCustomerId and deactivate
      await db.user.updateMany({
        where: { stripeCustomerId: subscription.customer as string },
        data: { subscriptionStatus: 'cancelled' }
      });
      break;
    }

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Return 200 immediately — Stripe will retry if you return an error
  return Response.json({ received: true });
}

Understanding Each Part

The Webhook Flow

  1. User pays on Stripe's checkout page
  2. Stripe processes the payment (server-side, not in browser)
  3. Stripe sends a POST request to your webhook URL: https://yourapp.com/api/webhooks/stripe
  4. Your endpoint receives the event, verifies the signature, and processes it
  5. Your endpoint returns 200 OK to tell Stripe "got it"
  6. Stripe considers the delivery successful

Signature Verification — Why It Matters

Your webhook URL is a public HTTP endpoint. Anyone who discovers it can send POST requests to it. Without signature verification, a malicious actor could send a fake "payment.succeeded" event and get a free subscription. Stripe signs every webhook with your signing secret — your endpoint verifies the signature before doing anything. This is non-negotiable.

Raw Body vs Parsed JSON

Webhook signature verification requires the exact raw bytes of the request body. If you parse the body as JSON first (request.json()), the formatting may change — spaces removed, key order altered — and the signature check fails. Always use request.text() and pass the raw string to the verification function.

Idempotency — Handling Duplicate Events

Stripe (and most webhook providers) may deliver the same event more than once if your endpoint was slow or returned an error. Your handler should be idempotent — safe to run twice with the same data. Using upsert instead of create, or checking if an action already happened before doing it, prevents double-charging or double-provisioning.

The 200 Response Rule

Return 200 quickly. If your endpoint takes more than 30 seconds to respond (or returns any non-2xx status), Stripe marks the delivery as failed and schedules a retry. Long-running operations (sending emails, generating PDFs) should be queued as background jobs — acknowledge the webhook immediately, process asynchronously.

What AI Gets Wrong About Webhooks

⚠️ Skipping signature verification is a critical security vulnerability. An unsecured webhook endpoint can be abused to trigger actions in your app with fake events.

1. Missing Signature Verification

The most dangerous mistake. AI sometimes generates webhook handlers that process events without verifying the signature — trusting the payload at face value. Always call the provider's verification method (stripe.webhooks.constructEvent(), github.webhooks.verify(), etc.) before touching the event data.

2. Parsing JSON Before Verification

Using request.json() in a Next.js route handler parses the body before you can verify the Stripe signature. The signature is computed over the raw bytes — any transformation breaks it. Use request.text() and pass the raw string.

3. Synchronous Heavy Processing

AI-generated webhook handlers sometimes do slow work synchronously — sending emails, generating reports, calling other APIs — before returning 200. This risks timeout and retries. Return 200 first, then enqueue the work.

4. Not Handling Retries (Duplicate Events)

AI-generated handlers often assume each event arrives exactly once. In reality, webhooks can be retried. Use Stripe's event ID (event.id) to deduplicate: store processed event IDs and skip if you've seen one before.

How to Debug Webhooks with AI

Use the Stripe CLI to forward webhooks to your local server: stripe listen --forward-to localhost:3000/api/webhooks/stripe. This gives you a local webhook secret and forwards real test events. Paste any failed event payload and your handler code into Claude: "This webhook is returning 400. Here's the event payload and handler — what's wrong?" Signature failures and raw body issues are the most common causes.

For GitHub webhooks, use gh webhook forward --repo=owner/repo --events=push --url=http://localhost:3000/api/webhooks/github.

What to Learn Next

Frequently Asked Questions

A webhook is a way for one service to notify another service when something happens, by sending an HTTP POST request to a URL you provide. Instead of your app constantly asking 'did anything change?' (polling), the external service pushes the event to you as soon as it occurs. It's like subscribing to notifications instead of refreshing your inbox.

With a regular API, you initiate the request — you ask the server a question and get a response. With a webhook, the external service initiates the request — it sends you a notification when something happens. APIs are pull (you ask); webhooks are push (they tell). They're often used together: your app calls an API to start an action, then receives a webhook when the action completes.

Use ngrok or the Stripe CLI / GitHub CLI to create a tunnel from the internet to your local server. ngrok gives you a public URL like https://abc123.ngrok.io that forwards to localhost:3000. Most webhook providers (Stripe, GitHub, Twilio) also have CLI tools that forward webhooks to your local server without exposing it publicly.

Webhook providers sign their payloads with a secret key and include the signature in a header. Your endpoint should verify this signature before processing the event — if the signature doesn't match, the request didn't come from the real provider. Without verification, anyone who discovers your webhook URL can send fake events.

Return a 200 OK response quickly — ideally within 5-30 seconds depending on the provider. If your endpoint is slow or returns an error, the provider will retry the webhook (sometimes dozens of times), causing duplicate processing. Acknowledge receipt immediately with 200, then process the event asynchronously in a background job.