A webhook is an HTTP request one system sends to another when something happens: a payment succeeds (Stripe), code is pushed (GitHub), a form is submitted, an order ships. Instead of polling an API every minute, your server is notified instantly.
How it works
- You register a URL with the provider, e.g.
https://example.com/webhooks/stripe, and choose events. - When an event occurs, the provider sends a
POSTwith a JSON payload to that URL. - Your endpoint verifies it, acknowledges with a
2xxstatus, and processes the event. - If your endpoint fails or times out, the provider retries — often for hours or days with backoff.
Rule 1: verify the signature
Anyone can POST to a public URL. Providers sign webhooks so you can confirm they’re genuine — usually an HMAC of the raw request body with a shared secret, sent in a header (Stripe-Signature, X-Hub-Signature-256, etc.).
Example (Node.js, GitHub-style HMAC SHA-256):
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Hub-Signature-256') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body) // the RAW body, not re-serialised JSON
.digest('hex');
const ok = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
queue.add('github-event', event); // process later
res.sendStatus(204);
});
Key details:
- Compute the HMAC over the raw bytes — parsing and re-stringifying JSON changes whitespace and breaks the signature.
- Use a constant-time comparison (
timingSafeEqual). - Check the timestamp if the provider includes one, and reject old requests to prevent replay attacks.
- Use the provider’s SDK helper when available (e.g. Stripe’s
constructEvent).
Rule 2: respond fast, process asynchronously
Providers expect a response within a few seconds. Don’t send emails, generate PDFs or call slow APIs inside the request. Instead:
- Verify the signature.
- Store the event or push it to a queue (Redis/BullMQ, Celery, Laravel queues, a database table).
- Return
200/204immediately. - A background worker processes the event.
This also keeps your endpoint healthy during traffic spikes. See background jobs and queues.
Rule 3: be idempotent
Retries mean you will receive some events more than once — and sometimes out of order. Make processing safe to repeat:
- Store each event’s unique ID and skip ones you’ve already handled.
- Use upserts and state checks (“only mark paid if currently pending”).
- Don’t rely on delivery order; fetch the current state from the provider’s API when order matters.
Other good practices
- Return non-2xx for real failures so the provider retries; return
2xxfor events you deliberately ignore. - Log event IDs, types and outcomes; providers’ dashboards show delivery attempts too.
- Use HTTPS with a valid certificate — many providers require it.
- Restrict by IP if the provider publishes source ranges (GitHub, Stripe do), as an extra layer — not a replacement for signatures.
- Rotate secrets periodically; support two secrets during rotation.
- Rate-limit the endpoint to protect against floods.
Testing locally
Your laptop isn’t reachable from the internet. Options:
- Provider CLIs that forward events (
stripe listen --forward-to localhost:3000/webhooks/stripe). - Tunnels: Cloudflare Tunnel, or your own VPS with an SSH reverse tunnel (
ssh -R) or frp. See ngrok alternatives. - Webhook inspection services to see raw payloads.
Hosting a webhook receiver
Webhook endpoints must be always online — downtime means retries pile up or events are dropped after the retry window. A small VPS with a static IP and HTTPS is ideal; tools like n8n can receive webhooks and run automations without writing code.
Related: REST vs GraphQL and API security best practices.