When other services need to tell your app something happened, they use a webhook.
Don't worry — you won't write any webhook code by hand. Your AI tool handles all the technical parts. This guide helps you understand the concepts so you can describe what you want.
Imagine you're waiting for a package. You have two options:
Polling (the bad way)
You walk to the door every 5 minutes and check. Most of the time, nothing's there. You waste hours.
In code: setInterval that hits an API every minute asking "anything new?"
Webhooks (the good way)
The delivery person rings your doorbell when they arrive. You only get up when there's actually something to handle.
In code: Stripe POSTs to your /api/webhooks/stripe endpoint when a payment happens.
Step by step, here's what happens when Stripe sends your app a webhook:
Something happens on Stripe's side (e.g., a customer pays)
Stripe creates a JSON object describing what happened (the "event")
Stripe POSTs that JSON to your webhook URL (e.g., yoursite.com/api/webhooks/stripe)
Your app receives the POST, verifies it's really from Stripe (signature check)
Your app updates its database (e.g., marks the user as paid)
Your app responds with a 200 status to confirm it got the event
A webhook endpoint is just an API route that accepts POST requests. In Next.js, that's a file at app/api/webhooks/[provider]/route.ts.
ADD STRIPE WEBHOOK HANDLER
"Set up a listener so Stripe can tell my app whenever a subscription is created, updated, or cancelled, and whenever an invoice is paid or fails. For each of those events, update the matching customer's subscription status in my Supabase database. Make sure the listener checks that the message is really from Stripe (using the secret Stripe gives me for this), so nobody can fake a subscription. Walk me through how to get that secret from the Stripe dashboard and where to keep it safe in my project."
Without signature verification, anyone could POST to your /api/webhooks/stripe endpoint pretending to be Stripe. They could fake a "payment succeeded" event and unlock paid features for free.
Stripe (and most webhook providers) signs every webhook with a secret only they know. You verify the signature using the same secret. If it doesn't match, you reject the request.
The flow
⚠️ Critical: You must use the raw request body for signature verification. If you parse the JSON first and then re-stringify it, the signature will fail. The Vercel AI SDK / Stripe SDK handle this correctly — but if you write it manually, watch out.
Stripe's servers can't reach localhost:3000 on your laptop. You need a tunnel. The Stripe CLI is the easiest:
Install the Stripe CLI: brew install stripe/stripe-cli/stripe (Mac) or download from stripe.com/docs/stripe-cli
Run: stripe login (opens browser to authorize)
Run: stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the webhook signing secret it prints (whsec_...)
Add it to .env.local as STRIPE_WEBHOOK_SECRET
In another terminal: stripe trigger payment_intent.succeeded — fires a fake webhook to your endpoint
For non-Stripe webhooks (GitHub, Resend), use ngrok instead — same idea, more generic.
Stripe gets the most attention, but lots of services use webhooks. The pattern is always the same: receive POST, verify signature, do something.
GitHub
Events: Push, pull request, issue comment, release published
Use: Trigger deploys, post to Slack, update changelog
Resend
Events: Email delivered, opened, clicked, bounced
Use: Track open rates, mark unreachable users
Clerk / Auth0
Events: User created, user updated, user deleted
Use: Sync user data to your own database
Calendly
Events: Meeting booked, canceled, rescheduled
Use: Add meetings to your CRM
Typeform
Events: Form submitted
Use: Save responses to your database
Vercel
Events: Deploy started, deploy ready, deploy failed
Use: Notify your team, run smoke tests
Signature verification fails on every request
You're parsing the body before verifying. Read the raw request body first, then verify, then parse.
Webhook works locally, fails in production
Different webhook secret per environment. Each Stripe webhook endpoint has its own secret — don't reuse the dev one in production.
Webhook fires twice for the same event
Stripe retries on any non-200 response. Make your handler idempotent: check if you already processed this event ID before doing the work.
Webhook is slow and Stripe times out
Acknowledge the webhook quickly (return 200 in under 5 seconds), then do the heavy work in a background queue.
You push code to GitHub. You want your staging site to auto-deploy. Vercel handles this if you deploy on Vercel — but if you're using a different host, you can build it yourself with a webhook. GitHub POSTs to your endpoint, your endpoint triggers the deploy.
Build this with AI
"Build a GitHub webhook handler at /api/webhooks/github. When my main branch receives a push, the webhook should trigger a deploy via my hosting provider's API. Verify the GitHub signature using GITHUB_WEBHOOK_SECRET. Only react to push events on the main branch — ignore branches and pull requests. Send me a Slack message with the commit message and author when the deploy starts."
LOG EVERY WEBHOOK FOR DEBUGGING
"Add a webhook_logs table to my database. Every webhook my app receives (from any provider) should be logged with: provider, event type, raw body, signature status, processing status, and timestamp. Build a /admin/webhooks page where I can see and replay any webhook."
MAKE WEBHOOKS IDEMPOTENT
"In my Stripe webhook handler, before processing each event, check if I've already handled this event ID (store handled IDs in a webhook_events table). If I have, return 200 without doing anything. This prevents duplicate processing if Stripe retries."
QUEUE WEBHOOKS FOR BACKGROUND PROCESSING
"My webhook handler is doing too much work and timing out. Refactor it: when a webhook arrives, save it to a webhook_queue table and immediately return 200. Then have a separate background worker process the queue every minute."