message.comDevelopers

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-ID header. Globally unique by spec.
  • Stripe. Event id (e.g., evt_xxx).
  • Shopify. X-Shopify-Webhook-Id header.
  • HubSpot. Per-event ID inside the array body.
  • message.com outgoing. X-Message-Webhook-Id header on each delivery.
  • Anything else. Use the Idempotency-Key header if the sender exposes one. Otherwise a hash of (timestamp + body).

2. Dedupe in Redis

Fast, simple, expires automatically.

server.ts
// 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.

sql
-- 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:

  1. Verify signature and dedupe immediately.
  2. Enqueue the work (BullMQ, SQS, RabbitMQ).
  3. Return 200 OK.
  4. 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. ±5min is the standard window.
  • At-least-once is the contract. Every retry-aware webhook is at-least-once, never exactly-once. Plan for duplicates.

Next steps