Handle webhook retries idempotently
Webhook senders retry on 5xx, timeouts, and network errors. If your handler processes the same event twice, you can double-charge, double-ticket, double-send. The fix: dedupe by a stable event ID before doing anything destructive.
message.com itself dedupes inbound email on messageId within 24 hours. The same pattern applies in reverse: your handlers should dedupe by whatever ID the source provides.
1. Identify the dedupe key
What you key on depends on the source:
- Email inbound. RFC 5322
Message-IDheader. Globally unique by spec. - Stripe. Event
id(e.g.,evt_xxx). - Shopify.
X-Shopify-Webhook-Idheader. - HubSpot. Per-event ID inside the array body.
- message.com outgoing.
X-Message-Webhook-Idheader on each delivery. - Anything else. Use the
Idempotency-Keyheader if the sender exposes one. Otherwise a hash of (timestamp + body).
2. Dedupe in Redis
Fast, simple, expires automatically.
// Express handler with Redis-backed dedupe
import { createClient } from "redis";
import express from "express";
const redis = createClient();
await redis.connect();
const app = express();
app.use(express.json());
app.post("/webhooks/email-inbound", async (req, res) => {
// verifyHmac throws on bad signature
verifyHmac(req);
const id = req.body.messageId;
const seen = await redis.set(`webhook:${id}`, "1", { NX: true, EX: 86400 });
if (seen === null) {
// Already processed within the past 24h. Acknowledge silently.
return res.status(200).end();
}
await processEmail(req.body);
res.status(200).end();
});The NX (set if not exists) plus EX (expire in seconds) combo is atomic. No race.
2b. Dedupe in Postgres
If you do not have Redis, INSERT ... ON CONFLICT DO NOTHING works.
-- Postgres dedupe with an idempotency table
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
-- INSERT ... ON CONFLICT pattern in your handler
INSERT INTO webhook_events (id, source) VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING
RETURNING id;Run a daily cron that deletes rows older than 48 hours.
3. Always return 2xx for duplicates
If you return 4xx or 5xx on a known event, the sender thinks you failed and retries again. Return 200 OK silently. The sender stops, your data stays clean.
4. Acknowledge before doing slow work
Many senders enforce a tight ack window (Shopify 5 seconds, Stripe 30 seconds, message.com 10 seconds). If your processing is slow:
- Verify signature and dedupe immediately.
- Enqueue the work (BullMQ, SQS, RabbitMQ).
- Return
200 OK. - Worker processes the queued event with its own retry semantics.
5. Recover lost events
If your endpoint was down and the sender exhausted retries, two recovery options:
- Replay from the sender's dashboard. Stripe, Shopify, and HubSpot all expose a delivery log with manual replay.
- Reconcile from the API. Pull the source-of-truth resource (e.g.,
GET /api/v1/conversations?from=...) since last successful sync.
Common pitfalls
- Storing dedupe inside the same transaction. If the dedupe insert fires but the work fails, your handler will skip the retry. Either commit dedupe last, or use a separate transaction for dedupe, or process inside a try/catch that rolls back both.
- Stable IDs across replays. If a sender uses a fresh ID per replay (rare), your dedupe never matches. Test replay before trusting the model.
- Clock skew. If you use timestamp-based replay protection (e.g., reject events older than 5 minutes), allow some clock skew.
±5minis the standard window. - At-least-once is the contract. Every retry-aware webhook is at-least-once, never exactly-once. Plan for duplicates.