Webhooks Explained: What They Are and When to Use One Instead of an API
Webhooks explained by developers who ship them: what is a webhook, webhook vs API, signature verification, idempotency, retries and when polling still wins.
A client’s Shopify store was three hours behind on order fulfilment. Their middleware polled the Orders API every five minutes, which sounded reasonable until Black Friday, when 40 orders landed in a single minute and the polling job was rate limited into a backoff loop. The fix wasn’t a faster poll. It was deleting the poller and letting Shopify tell us when something happened.
That inversion is the whole idea. Instead of your code asking “anything new?” on a timer, the other system calls you the moment there is. Simple concept, and yet the majority of webhook receivers we inherit on client projects are broken in at least two of the five ways listed below.
Webhooks explained properly, then, including the parts the listicles skip: why you should not trust the payload you were sent, and why answering 200 too late is worse than answering 500 quickly.
- A webhook is a server-to-server HTTP POST that the sender initiates. You own the endpoint, they own the schedule. That reversal is the only real difference from a normal API call.
- Verify the HMAC signature against the raw request bytes before parsing. Parsing JSON and re-serialising it changes the bytes and your signature check will fail for reasons that look like magic.
- Assume duplicates and out-of-order delivery. Every serious provider retries on non-2xx, so “at least once” is the contract, not “exactly once”. Deduplicate on the provider’s event ID.
- Respond in under a second by acknowledging and queueing. GitHub cuts you off at 10 seconds, Slack wants a reply in 3. Doing the work inline is the single most common production failure.
- Webhooks are wrong for reconciliation, for fetching state on demand, and for anything where you need a guaranteed complete history. Pair them with a polling sweep, don’t replace it entirely.
What is a webhook, precisely
A webhook is an HTTP request that a system sends to a URL you registered with it, triggered by an event on their side. There is no special protocol, no new transport, no SDK requirement. It is a POST with a JSON (occasionally form-encoded) body, a handful of headers, and a signature.
The confusion comes from the naming. “Webhook” describes the direction of the call, not the technology. If you expose POST /hooks/stripe, you have written an API endpoint. Stripe is the client. You are the server. Every reflex you have about API design still applies: validate input, return meaningful status codes, log the request ID, keep the handler thin.
Three terms get used interchangeably and shouldn’t be. A webhook is the delivery mechanism. An event is the thing that happened (invoice.payment_failed). A callback URL in OAuth flows is a browser redirect, not a webhook, because the user’s browser makes that request, not the provider’s servers. Mixing those up leads to receivers that expect a session cookie that will never be there.

Webhook vs API: how to actually choose
The webhook vs API framing is slightly false, since webhooks are delivered over an API. The real question is who initiates, and that decision falls out of two things: who knows when the data changed, and whether you need an answer right now.
Use a webhook when the other system knows something you don’t and you need to react promptly. Payment succeeded. Deploy finished. Form submitted. Ticket status changed. Subscription entered dunning. You cannot know when any of these happen without being told, and polling for them burns rate limit on responses that are empty 99% of the time.
Call the API directly when you need data at a specific moment in your own flow: rendering an invoice page, checking stock before accepting an order, validating a coupon at checkout. Nobody should be waiting on a webhook to know whether a discount code exists.
Where teams get it wrong, in order of frequency:
- Polling for events. A cron job hitting
/orders?updated_since=every minute. It works until volume or rate limits catch up with it, and the latency floor is your poll interval. - Treating the webhook payload as the source of truth. The payload is a snapshot from the moment the event fired. By the time you process it, the object may have changed twice. This is why Stripe now pushes thin event payloads and expects you to fetch the current object back over the API.
- Using webhooks for bulk sync. Onboarding a new client with 80,000 historical records? That is a paginated API read, not a replay of two years of events.
The pattern that holds up: webhook as a notification, API call as the read. The hook tells you “customer 4821 changed”, then you fetch customer 4821 and act on real data. Slightly more requests, dramatically fewer stale-state bugs.
What a delivery actually looks like on the wire
Here is a typical inbound request, stripped down:
POST /hooks/billing HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
User-Agent: Provider-Hookshot/1.0
X-Event-Id: evt_1P9kLmC2eZvKYlo2C7Xy8abc
X-Event-Type: invoice.payment_failed
X-Timestamp: 1770000000
X-Signature-256: sha256=3f9c... # HMAC of "{timestamp}.{raw_body}"
X-Delivery-Attempt: 3
{"id":"evt1P9k...","type":"invoice.paymentfailed","data":{"object":{"id":"in_1..."}}}
Header names vary. GitHub uses X-Hub-Signature-256, Shopify uses X-Shopify-Hmac-Sha256 with a base64 digest, Stripe packs a timestamp and one or more signatures into Stripe-Signature, Slack signs with a v0: prefixed basestring. The Standard Webhooks spec has been gaining adoption since 2023 and normalises this into webhook-id, webhook-timestamp and webhook-signature. If you’re building an API that sends webhooks in 2026, follow that spec rather than inventing your own header scheme.
The event ID and the attempt counter are the two most useful headers
The event ID gives you idempotency for free. The attempt counter tells you, in logs, whether you’re looking at a first delivery or the provider’s fourth try, which saves an hour of confusion when a bug only reproduces on retries.
The five things every receiver must do
This is the checklist we run against inherited codebases. Missing two of these is normal. Missing four is a data corruption incident waiting for a traffic spike.
1. Verify the signature on raw bytes, in constant time. In Express, that means express.raw() on the webhook route specifically, before any global express.json() middleware gets to it.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/hooks/billing',
// Raw buffer only. JSON.parse then re-stringify changes key order and whitespace,
// which changes the bytes, which breaks the HMAC.
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const sig = req.get('X-Signature-256') ?? '';
const ts = Number(req.get('X-Timestamp') ?? 0);
// Reject anything older than 5 minutes: limits the replay window.
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.HOOK_SECRET)
.update(${ts}.)
.update(req.body)
.digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Hand off and get out. jobId gives the queue-level dedupe.
await eventQueue.add('billing', { raw: req.body.toString('utf8') }, {
jobId: req.get('X-Event-Id'),
attempts: 8,
backoff: { type: 'exponential', delay: 2000 },
});
res.sendStatus(202);
});
2. Acknowledge fast, process later. Your handler should do signature check, enqueue, respond. Nothing else. No emails, no PDF generation, no third-party API calls. We wrote a whole piece on why webhook processing belongs in a queue, and the short version is that inline work turns a slow downstream dependency into duplicated events, because the provider times out, retries, and now two workers are handling the same payment.
3. Be idempotent. Duplicates are guaranteed, not theoretical. The cheapest reliable approach is a unique constraint on the provider’s event ID:
CREATE TABLE webhook_events (
event_id VARCHAR(191) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
received_at DATETIME NOT NULL,
processed_at DATETIME NULL,
payload JSON NOT NULL
);
// Insert first. If the DB rejects it, we have already seen this event.
try {
$stmt = $pdo->prepare(
'INSERT INTO webhookevents (eventid, eventtype, receivedat, payload)
VALUES (?, ?, NOW(), ?)'
);
$stmt->execute([$eventId, $eventType, $rawBody]);
} catch (\PDOException $e) {
if ($e->errorInfo[1] === 1062) { // MySQL duplicate key
httpresponsecode(200); // Already handled. Tell them to stop retrying.
exit;
}
throw $e;
}
Note the 200 on duplicate. Returning an error would make the provider keep retrying an event you have already processed.
4. Don’t assume ordering. Retries, parallel delivery workers and network jitter mean subscription.updated can arrive before subscription.created. Guard with a version or timestamp on the object, and skip writes where your stored version is newer. Or refetch the object and stop caring about order entirely, which is usually the better call.
5. Return the right status code. 2xx means “I own this now”. 4xx means “don’t bother retrying, this is malformed or unsigned”. 5xx means “try again”. Returning 200 on an internal failure silently drops the event forever. Stripe keeps retrying with exponential backoff for up to three days in live mode. That retry budget is there for a reason: use it.
When a webhook is the wrong tool
Webhooks fail in specific, predictable ways. Pretending otherwise is how you end up with a billing system quietly missing 0.3% of events.
- Your endpoint was down. Retries have a ceiling. After it, the event is gone from your side forever. Every webhook integration handling money or entitlements needs a reconciliation sweep: a nightly job that lists objects changed in the last 48 hours over the API and compares them against what you recorded. We have caught missed events on client projects with exactly this job, usually traced back to a deploy window.
- You need to backfill. Some providers expose an event list endpoint you can replay. Many don’t. Read the current state instead.
- You need data in the browser. Webhooks are server-to-server. There is no localhost URL a provider can reach without a tunnel, and no client-side receiver. The hook lands on your server and you push to the browser over SSE or WebSocket.
- The provider’s payload is incomplete. Very common. The hook tells you a subscription changed but omits the line items you need. Refetch.
- Strict ordering is a hard requirement. If order genuinely matters and cannot be inferred from the payload, you want a log or stream (Kafka, Kinesis, a provider’s sync API), not webhooks.
Webhooks when you have no backend
Plenty of the sites we ship are static: Astro, Eleventy, or a straight HTML build off Canvas. No server means no webhook receiver, and that is fine for outbound flows. A form submission hitting a hosted endpoint that fans the data out to email, Slack and an arbitrary webhook URL covers most of what small sites actually need, which is why WebForms exists. Your contact form POSTs, the service forwards to whatever Zapier or n8n scenario you have built, and you never provision a server. The wider set of options is covered in our guide to HTML form handling without a backend.
The limit is obvious once you hit it. You cannot receive and verify a signed webhook without somewhere to run code. A single serverless function (Cloudflare Worker, Netlify Function, Lambda) is enough, and costs effectively nothing at low volume. But at that point you are back to owning an endpoint, and every rule above applies again.
Debugging without losing a day
Log the raw body, all headers, the event ID and your response code for every delivery, and keep it for 30 days. When a provider says “we delivered it”, you need to be able to say what you received and what you replied.
Locally, tunnel rather than mock. stripe listen --forward-to localhost:3000/hooks/billing or cloudflared tunnel gives you the real signature and real payload shape. Mocks hide exactly the bugs that matter, especially the raw-body one. For provider-agnostic inspection, webhook.site takes ten seconds to set up and shows you precisely which headers arrive.
One more thing worth building early: a replay button in your admin. Take a stored payload, re-run it through the processing path (bypassing signature check, with the ID dedupe temporarily disabled), and you can fix a handler bug and reprocess 400 failed events without asking the provider for anything. If you’re building that kind of internal tooling, the dashboard layout patterns we use hold up well for event log views.
Frequently Asked Questions
What is a webhook in simple terms?
It’s a phone call instead of you checking your voicemail every minute. A webhook is an HTTP POST that another system sends to a URL you gave it, at the moment something happens on their side. You register the URL once, and they call it on every matching event.
Is a webhook the same as an API?
A webhook is delivered over an API, but the direction is reversed: the provider is the client and you are the server. In a normal API call you decide when to ask. With a webhook, they decide when to tell you, which is why webhooks suit event notification and direct API calls suit on-demand reads.
How do I secure a webhook endpoint?
Verify the HMAC signature over the raw request body using the shared secret, compare digests with a constant-time function, and reject requests whose timestamp is more than about five minutes old. Add IP allowlisting only if the provider publishes stable ranges, and never rely on a secret in the URL path as your sole protection.
Why is my webhook firing twice?
Almost always because your endpoint didn’t return a 2xx quickly enough, so the provider retried while your first handler was still working. Fix it by acknowledging immediately, queueing the work, and deduplicating on the provider’s event ID with a unique database constraint.
Should I still poll if I have webhooks?
Yes, for anything financial or permission-related. Run a low-frequency reconciliation job (hourly or nightly) that lists recently changed objects over the API and compares them to your records. Webhooks give you low latency, polling gives you completeness, and you want both.
Where to start
If you have an existing receiver, audit it against the five rules today. Check whether signature verification runs on raw bytes, time how long your handler takes at p95, and search the codebase for a dedupe check on the event ID. Most teams find a problem within twenty minutes.
If you’re building one fresh, the order that saves the most pain: signature check, insert-for-idempotency, enqueue, 202, worker. Add the reconciliation sweep before launch, not after the first incident. And if your site has no backend at all, keep it that way as long as you can and use a hosted endpoint for outbound form traffic, because every server you don’t run is a server you don’t patch.


