Webhooks are how other systems tell yours that something happened: an order was paid, a product changed, a subscription renewed. They look simple, a POST request with some JSON, but a naive handler causes real problems: forged requests that create orders, duplicate emails when the provider retries, and lost events when your server is slow to answer.
This guide builds a webhook receiver in Express that handles all three, with examples from Stripe, Shopify and WooCommerce.
How providers secure webhooksLink to this section
Most providers sign each request with a shared secret using HMAC-SHA256 and send the signature in a header. You recompute the HMAC over the request body with the same secret and compare.
| Provider | Header | Encoding | Signed content |
|---|---|---|---|
| Shopify | X-Shopify-Hmac-Sha256 |
Base64 | raw body |
| WooCommerce | X-WC-Webhook-Signature |
Base64 | raw body |
| Stripe | Stripe-Signature |
hex, with timestamp t= |
timestamp.rawBody |
| GitHub | X-Hub-Signature-256 |
hex, prefixed sha256= |
raw body |
The key detail: the signature is computed over the exact bytes the provider sent. If your framework parses the JSON first and you re-serialise it, whitespace and key order change and the signature never matches.
Keep the raw bodyLink to this section
In Express, mount a raw body parser on the webhook route before any global JSON parser touches it:
import express from 'express';
const app = express();
// Webhooks: raw bytes, so the signature can be checked
app.post('/webhooks/:provider', express.raw({ type: 'application/json', limit: '1mb' }), handleWebhook);
// Everything else: parsed JSON
app.use(express.json({ limit: '100kb' }));Verify the signatureLink to this section
import crypto from 'node:crypto';
function safeEqual(a, b) {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyShopify(req, secret) {
const sent = req.get('x-shopify-hmac-sha256') ?? '';
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('base64');
return safeEqual(sent, expected);
}
function verifyStripe(req, secret, toleranceSec = 300) {
const header = req.get('stripe-signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay protection
const expected = crypto.createHmac('sha256', secret).update(`${t}.${req.body}`).digest('hex');
return safeEqual(parts.v1 ?? '', expected);
}Three rules:
- Constant-time comparison.
timingSafeEqualprevents attackers from guessing the signature byte by byte from response times. A plain===leaks timing information. - Check timestamps where the provider sends them. Stripe signs a timestamp; rejecting events older than a few minutes stops an attacker from replaying a captured request.
- Use the provider's SDK when one exists. Stripe's
stripe.webhooks.constructEvent(rawBody, signature, secret)does all of this and handles signature rotation. The manual version above shows what it does.
If a signature never matches, compare encodings: Shopify and WooCommerce send Base64, Stripe and GitHub send hex. The Base64 encoder helps confirm what you're looking at.
Respond fast, work laterLink to this section
Providers wait only a few seconds for a response before marking the delivery as failed and retrying. If your handler sends emails, calls an ERP and updates stock before answering, it will time out under load and trigger retries, which makes the load worse.
The pattern: verify, record, enqueue, answer.
async function handleWebhook(req, res) {
const provider = req.params.provider;
const secret = process.env[`WEBHOOK_SECRET_${provider.toUpperCase()}`];
const ok =
provider === 'stripe' ? verifyStripe(req, secret) :
provider === 'shopify' ? verifyShopify(req, secret) :
false;
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
const eventId = eventIdFor(provider, req, event);
const isNew = await recordEvent(provider, eventId, event); // returns false for duplicates
if (isNew) await queue.add('webhook', { provider, eventId }, { jobId: `${provider}:${eventId}` });
res.status(200).json({ received: true });
}The background worker loads the stored event and does the slow work, with its own retries. The background jobs guide shows the queue side with BullMQ.
Idempotency: the same event twice is normalLink to this section
At-least-once delivery means duplicates are expected, not exceptional. A retry after a timeout, a provider-side glitch or a manual "resend" from a dashboard will deliver an event you've already handled.
Make handling idempotent by recording each event's unique ID with a database constraint:
CREATE TABLE webhook_events (
provider VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
PRIMARY KEY (provider, event_id)
);async function recordEvent(provider, eventId, event) {
try {
await pool.execute(
'INSERT INTO webhook_events (provider, event_id, type, payload) VALUES (?, ?, ?, ?)',
[provider, eventId, event.type ?? event.topic ?? 'unknown', JSON.stringify(event)],
);
return true;
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return false; // already received
throw err;
}
}Where the event ID comes from depends on the provider: Stripe events have an id; Shopify sends X-Shopify-Webhook-Id; WooCommerce sends X-WC-Webhook-Delivery-ID. The primary key turns a race between two simultaneous deliveries into one insert and one duplicate error, which is exactly what you want.
Make the business logic idempotent too: "set order status to paid" is safe to repeat; "add 10 loyalty points" is not unless you record that it was applied for this event.
OrderingLink to this section
Webhooks can arrive out of order. An order.updated may arrive before order.created, or two updates may swap. Don't assume order:
- Store the provider's timestamp (
updated_at, or Stripe'screated) and ignore an update older than what you already have. - For important state, fetch the current resource from the provider's API inside the job instead of trusting the payload's snapshot.
ObservabilityLink to this section
Log one structured line per delivery with provider, event ID, type, verification result and processing outcome. When a merchant says "the order didn't sync", you can search by order number, find the event and replay it from the stored payload. Timestamps in payloads are often Unix seconds; the timestamp converter turns them into readable dates while you investigate, and the JSON formatter makes stored payloads readable.
Also:
- Alert on a spike of signature failures (a rotated secret or an attack) and on a growing backlog of unprocessed events.
- Expose an internal "replay event" command that re-queues a stored event by ID.
Local developmentLink to this section
Providers need a public HTTPS URL. Use the provider's CLI where available (stripe listen --forward-to localhost:3000/webhooks/stripe) or a tunnelling tool, and use test-mode secrets. Never point production webhooks at a developer's machine.
WooCommerce specificsLink to this section
WooCommerce webhooks (WooCommerce → Settings → Advanced → Webhooks) send a ping when created, which isn't a real event; accept it with a 200. Deliveries that fail repeatedly get the webhook disabled automatically, so watch the status in the admin. On the WordPress side, the WooCommerce customisation guide covers where order data comes from.
A replay tool for failed eventsLink to this section
Even with retries, some events will fail for reasons that need a human: a product that doesn't exist in the ERP yet, a validation rule that changed, an outage longer than the provider's retry window. Because every event is stored, you can build a small replay tool instead of asking the provider to resend.
// scripts/replay-event.js: node scripts/replay-event.js shopify 1234567890
const [provider, eventId] = process.argv.slice(2);
const [rows] = await pool.execute(
'SELECT event_id FROM webhook_events WHERE provider = ? AND event_id = ?',
[provider, eventId],
);
if (!rows.length) throw new Error('Event not found');
await pool.execute('UPDATE webhook_events SET processed_at = NULL WHERE provider = ? AND event_id = ?', [provider, eventId]);
await queue.add('webhook', { provider, eventId }, { jobId: `${provider}:${eventId}:replay:${Date.now()}` });
console.log('Re-queued', provider, eventId);Note the different jobId: the original job ID already exists in the queue's history, so a replay needs a unique one. The handler itself is still idempotent, so replaying an event that actually succeeded does no harm.
For bigger incidents, replay by time range: select every event received during the outage that has no processed_at, and queue them in order. Check the provider's own dashboard too; Shopify, Stripe and WooCommerce all show failed deliveries and let you resend individual ones.
Rotating secrets without downtimeLink to this section
Secrets need rotating when a team member leaves or a secret leaks. To avoid rejecting events during the switch, accept both the old and new secrets for a short window: try the new one first, fall back to the old one, and log which matched. Once logs show only the new secret in use, remove the old one.
Common problemsLink to this section
- Signatures never match. The body was parsed before verification, the wrong secret is used (test vs live), or the encoding is wrong.
- Duplicate emails or orders. No idempotency check. Record event IDs with a unique constraint.
- Provider shows timeouts. The handler does work before responding. Enqueue and return.
- Events lost during deploys. Close the server gracefully so in-flight requests finish, and rely on the provider's retries for anything that was refused.
- Some events missing entirely. Check that you subscribed to every topic you handle, and that the webhook wasn't auto-disabled after failures.
Frequently asked questionsLink to this section
Should the webhook endpoint require authentication headers too?Link to this section
The signature is the authentication. You can also restrict by the provider's published IP ranges, but those change; treat them as an extra layer, not a replacement.
What status code should I return for events I don't handle?Link to this section
- Returning an error makes the provider retry an event you'll never process.
Can I process the event synchronously if it's fast?Link to this section
For very light work, yes, but the queue pattern costs little and protects you when the work grows or a dependency slows down.
How long should I keep stored events?Link to this section
Long enough to investigate problems and replay: 30 to 90 days is typical. Remove or redact personal data according to your privacy policy.
Building an integration between a store and other systems? See the production API guide or tell me about it.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.