Webhooks
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: An API call going the OTHER way — instead of your code asking a service “did anything happen?”, the service POSTs to a URL you registered the moment something does.
In plain English
Most API interactions you’ve seen so far are client-initiated: your code calls Stripe, GitHub, Anthropic. You ask; they answer.
A webhook flips the direction. Instead of you asking the service repeatedly (“is there a new payment yet? is there a new payment yet?”), you give the service a URL on YOUR server and tell it: “when something happens, POST to this URL.”
The service stores your URL. The next time the event happens — a payment succeeds, a PR is opened, a calendar event is created — the service makes an HTTP POST to your URL with a JSON payload describing what happened.
It’s an API call going IN to your system instead of OUT.
Real examples:
- Stripe webhook — your
/api/webhooks/stripeendpoint gets POSTed when a payment succeeds, a subscription renews, a card fails. - GitHub webhook — your
/api/webhooks/githubendpoint gets POSTed when someone opens a PR, pushes to a branch, leaves a comment. - Calendly webhook — when someone books a meeting, you get a POST.
- Supabase webhook — when a row is inserted in a specific table, Supabase POSTs to your endpoint.
In each case, the third-party service is acting as the “client” and your server is the “API.” You wrote a tiny REST endpoint that only this specific service is supposed to call.
Webhooks are the standard pattern for “I want to react to events in someone else’s system” — far better than polling.
Why it matters
Three concrete reasons webhooks are everywhere:
-
Realtime. A payment succeeds at 14:32:01; your webhook fires at 14:32:01.4. You learn about it before the user finishes saying “did it work?” Compare with polling every 30 seconds: average detection latency 15 seconds, worst case 30.
-
Efficient. Polling burns API rate limits checking when nothing happened. A webhook fires once per actual event. The third-party service does the work; you only run code when there’s something to do.
-
Composable. Many small webhooks form a workflow. “Stripe → email service → CRM → analytics” — each step a webhook. Zapier, Make, n8n, and Pipedream exist almost entirely to chain webhooks.
The trade-offs: webhooks require a publicly accessible URL (a webapp at a real domain — they can’t reach localhost), they’re harder to debug (the producer is out of your control), and they need careful handling of retries, ordering, and security.
How a webhook flow actually works
The setup, one-time:
1. You register a webhook URL with the service:
"Send Stripe payment events to https://stmarkbible.com/api/webhooks/stripe"
2. The service stores the URL + which events you want
(and gives you a "signing secret" so you can verify webhooks are really from them)
The lifecycle, every time an event happens:
3. Event happens at the service (payment succeeds at Stripe)
↓
4. Service constructs a POST request:
POST https://stmarkbible.com/api/webhooks/stripe
Headers: stripe-signature: t=1719..., v1=abc123...
Body: { "type": "payment_intent.succeeded", "data": { ... } }
↓
5. Your endpoint receives the POST
↓
6. You VERIFY the signature using the signing secret
(Reject if forged)
↓
7. You handle the event (update database, send email, etc.)
↓
8. You respond 200 OK quickly (< 30 seconds)
↓
9. Service marks the webhook as delivered.
If you returned non-2xx, or timed out, service RETRIES with backoff
(often for hours or days).
The contract is “we POST; you 200 fast or we retry.” Make peace with this.
A concrete example: a Stripe webhook in Next.js
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) return new NextResponse("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return new NextResponse("Invalid signature", { status: 400 });
}
// Verified! Now handle the event.
switch (event.type) {
case "payment_intent.succeeded":
const intent = event.data.object;
await markOrderPaid(intent.metadata.order_id);
break;
case "customer.subscription.deleted":
await deactivateSubscription(event.data.object.id);
break;
// ...
}
return NextResponse.json({ received: true });
}Five things to notice:
- We read the body as text first, not JSON. Stripe’s signature is computed over the raw bytes; parsing first would corrupt it.
- We verify the signature before doing anything. Without verification, anyone who knows the URL can fake events.
- We switch on event type to handle different events.
- We return 200 fast. Heavy work should be moved to a background job, with the webhook just queueing it.
- We set the route to dynamic (Next.js usually statics POST routes, which is wrong here). In modern App Router this is implicit, but it’s a common gotcha.
Verifying webhooks — the security-critical step
A webhook endpoint is a public URL. Anyone can POST to it. Without verification, they can fake events.
Standard verification: the service signs the payload with a secret (shared between them and you), includes the signature in a header. Your code recomputes the signature using the same secret + payload and compares.
The math is HMAC-SHA256 (or similar). Stripe’s library does it:
const event = stripe.webhooks.constructEvent(rawBody, signatureHeader, signingSecret);
// Throws if invalidGitHub’s pattern is similar:
const computed = crypto
.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const provided = req.headers.get("x-hub-signature-256")?.replace("sha256=", "");
const valid = crypto.timingSafeEqual(
Buffer.from(computed),
Buffer.from(provided!)
);
if (!valid) return new Response("Invalid", { status: 401 });Two non-obvious details:
- Use
timingSafeEqual— comparing two strings character-by-character with===is vulnerable to timing attacks. Real crypto libraries provide constant-time compare. - Verify the RAW body — not the parsed JSON. Parsing reorders keys, normalizes whitespace, and can lose data. The signature is over exact bytes.
Idempotency — webhooks WILL be delivered multiple times
A core promise of webhook systems: “at least once” delivery. Networks fail. Your server might 200 the response but die before persisting. The service can’t know whether you handled it, so it retries.
Your endpoint must be idempotent: processing the same event twice has the same effect as processing it once.
Standard pattern: record event IDs in a database; check before processing:
const event = await verifyWebhook(req);
// Have we seen this event already?
const seen = await db.processedWebhookEvents.findById(event.id);
if (seen) return NextResponse.json({ received: true, deduped: true });
// Process it
await handleEvent(event);
// Record that we've seen it
await db.processedWebhookEvents.create({ id: event.id, processedAt: new Date() });
return NextResponse.json({ received: true });Hardening: use a transaction or unique constraint to handle the race where two retries arrive simultaneously and both pass the “seen” check.
This is why every mature webhook system gives each event a stable ID. Use it.
Reliability — handle failures gracefully
A few patterns for production webhook handlers:
Respond fast, work asynchronously
If processing takes > 5 seconds, the connection might time out (Vercel function limits, network issues). Move work to a background job:
export async function POST(req: NextRequest) {
const event = await verifyAndParse(req);
// Queue the actual work
await jobQueue.add({ type: "process_stripe_event", event });
return NextResponse.json({ received: true }); // Returns in ~50ms
}The job queue (Vercel Queues, Inngest, Trigger.dev, plain Postgres-backed tables) processes the event later with retries, observability, dead-letter handling.
Tolerate out-of-order delivery
A “payment succeeded” event might arrive after the “refund issued” for the same payment. Don’t assume order. Either rely on event timestamps + ordering logic, or use the latest state from the upstream API.
Reject quickly on bad input
Don’t try to “be helpful” by retrying internally. If the signature is invalid, return 401 fast. If the payload is malformed, return 400. Let the service give up; it won’t keep retrying 4xx responses (vs 5xx, which it will).
Log everything
Webhooks are often the only signal that something happened. Log: event id, type, timestamp received, processing time, outcome. When something is wrong, these logs are your only diagnostic.
Local development — webhooks can’t reach localhost
A real Stripe server can’t POST to http://localhost:3000. You need a public URL.
The tools:
- Stripe CLI —
stripe listen --forward-to localhost:3000/api/webhooks/stripeopens a tunnel; events to your test account get forwarded. - ngrok — generic HTTP tunneling.
ngrok http 3000gives you ahttps://abc123.ngrok-free.appURL that forwards to your local server. - Cloudflare Tunnel —
cloudflared tunnel --url localhost:3000does the same, free. - Vercel Preview URLs — for testing in production-like conditions; each PR gets its own URL.
For Stripe specifically, the Stripe CLI is the smoothest path. For other services that don’t provide their own CLI, use ngrok or Cloudflare Tunnel.
Common webhook event types
Every service designs its own. A few patterns:
| Service | Common event types |
|---|---|
| Stripe | payment_intent.succeeded, customer.subscription.created, invoice.payment_failed, charge.refunded |
| GitHub | pull_request.opened, push, issue_comment.created, workflow_run.completed |
| Supabase | INSERT, UPDATE, DELETE on specific tables |
| Calendly | invitee.created, invitee.canceled |
| Slack | Slash commands, interactive message clicks (technically webhooks dressed up as something else) |
| Linear | Issue created/updated, project status changes |
Each service has docs listing every event type, sample payloads, and signature verification instructions. Skim before integrating.
Webhooks vs polling vs real-time pub/sub
Three ways to learn about events happening elsewhere:
| Approach | How it works | Best for |
|---|---|---|
| Polling | Periodically call the service’s API to check for new events | Simple cases; services without webhooks; very low frequency |
| Webhooks | Service POSTs to your URL when events happen | Standard case; medium-volume events; loose coupling |
| Pub/Sub / Realtime | Persistent connection (websocket, server-sent events) | High-volume, low-latency events; chat; presence |
Webhooks are the middle ground. Cheaper than polling, simpler than realtime. The dominant choice for “react to events in a third-party system.”
Common gotchas
-
The endpoint must be public. Webhooks can’t reach localhost. For development, use a tunneling tool (Stripe CLI, ngrok, Cloudflare Tunnel).
-
Verification is non-negotiable. A public webhook URL with no signature verification is an open door for anyone to spoof events.
-
Read body as text BEFORE parsing. Signatures are computed over raw bytes. Parsing first breaks verification. In Next.js:
await req.text()notawait req.json(). -
Webhook delivery is at-least-once. Be idempotent. Track event IDs; dedupe.
-
Return 2xx fast. Heavy work goes to background jobs. Otherwise you risk timeouts, retries, and inconsistent state.
-
Non-2xx response = retry. Most services retry with exponential backoff for hours or days. Don’t let a transient bug become a flood of retries.
-
Don’t return 5xx for application errors. A 5xx tells the service “I had trouble, try again.” For “I parsed it and don’t want it,” return 200 or 400 — not 500.
-
Order is not guaranteed. Events can arrive out of sequence. Don’t write code that assumes A always comes before B.
-
Replay attacks are real. A captured webhook payload (with valid signature) can be replayed. Verify timestamps (Stripe includes one) and reject if too old.
-
Signing secrets must be in env vars. Never commit them. Never log them. Rotate periodically.
-
Multiple webhooks per service can collide. Production and preview environments each need their own webhook endpoint AND their own signing secret. Mixing them up sends production events to preview.
-
Webhook URLs change between environments.
localhostfor dev (via tunnel),https://my-app-git-feature.vercel.appfor preview,https://stmarkbible.comfor production. Each registered separately. -
The service’s IP addresses may not be fixed. Don’t IP-allowlist webhook senders unless you really need to and the service publishes IP ranges.
-
Some webhooks send huge payloads. Stripe events with deeply nested objects can be 100KB+. Make sure your handler doesn’t load them all into memory if you don’t need to.
-
Webhook deliveries can be lost. Even with retries, services eventually give up. Most provide a “missed webhooks” or “events” API to reconcile. Use it as a backstop.
-
Don’t use webhooks for security-critical flows. Don’t grant access based solely on “I received a webhook.” Verify state via a separate API call before granting privileges.
-
The order of registration matters. Register the webhook FIRST, then start the flow that would generate events. Otherwise early events are lost.
-
Content-Typeis occasionally wrong. Some webhook senders sendapplication/x-www-form-urlencodedeven when the payload looks like JSON. Handle both, or check the docs. -
Local logs lie. If your laptop falls asleep during a webhook test, the service retries — and you might see “duplicate event” warnings that aren’t really a code bug.
-
Webhooks WILL die at the worst possible time. A small bug in your handler can swallow events silently. Set up monitoring (Sentry on errors, healthcheck on event throughput) so you notice within minutes.
-
Test mode vs live mode are separate webhook flows. Stripe test events go to a test webhook URL with a test signing secret. Live events go to a live URL with a live secret. Easy to mix up.
See also
- What is a backend? 🟩 — webhooks are inbound to your backend
- APIs — the big picture 🟩 — webhooks are the inverted direction
- REST APIs 🟩 — webhook receivers are typically REST endpoints
- Serverless functions 🟩 — common runtime for webhook handlers
- Edge functions 🟥 — sometimes useful for fast verification before main processing
- Environment variables 🟩 — signing secrets live here
- HTTPS 🟩 — webhook URLs must be HTTPS in production
- OWASP Top 10 🟩 🟦 — broader security context
- JWT 🟩 — alternative to signed webhooks for service-to-service trust
- Glossary: Webhook, HMAC, Idempotency
Sources
- Stripe — Receive webhooks — the canonical reference
- GitHub — About webhooks
- MDN — Server-sent events — alternative to webhooks
- Standard Webhooks spec — community attempt at standardizing the headers and verification across providers
- ngrok — local-development tunneling
- Cloudflare Tunnel docs — alternative tunneling