Retries and idempotency Live
Webhook delivery is at-least-once. We retry on any non-2xx response or timeout with exponential backoff for up to 24 hours. Your handler must therefore be idempotent: receiving the same event twice produces the same end state, not a duplicate side effect.
Retry schedule
When your endpoint fails or times out, we requeue with exponential backoff. The schedule is fixed; if you need a different cadence, ack 200 immediately and run your own retry logic on your side.
Attempt 1 → immediate
Attempt 2 → +1 minute
Attempt 3 → +5 minutes
Attempt 4 → +30 minutes
Attempt 5 → +2 hours
Attempt 6 → +6 hours
Attempt 7 → +24 hours
Give up → after 24h total (delivery marked dead)If all seven attempts fail across a 24-hour window, the delivery is marked dead and surfaces a warning in the dashboard at Settings → Integrations → Webhooks. The event itself is preserved indefinitely (you can manually replay it from the dashboard) but no further automatic retries fire.
Twenty-four hours is the cap. We do not retry past it because: (a) by then the event is usually stale, (b) backing-off forever clogs the queue, and (c) most real outages are resolved in minutes, not days.
Timeouts
We wait 10 seconds for a response. Anything beyond is treated as a failure. Your handler should ack 200 within that window and push slow work onto a queue. If processing routinely takes longer, the retry budget gets consumed on requests we never actually delivered.
Idempotency keys
Every webhook envelope carries an id field. Duplicate deliveries (during retry storms, or simply because at-least-once means “sometimes twice”) reuse the same id. Treat it as the dedupe key on your side. Persist seen IDs for at least 24 hours.
// Express handler. Use the webhook 'id' as the dedupe key.
import { redis } from "./redis";
app.post("/webhooks/message", async (req, res) => {
const { id, type, data } = req.body;
// SETNX returns true if the key was newly set, false if it already existed.
const fresh = await redis.set(`whk:${id}`, "1", "EX", 86400, "NX");
if (!fresh) {
// Duplicate delivery. Already processed. Ack and move on.
return res.status(200).json({ status: "duplicate" });
}
// Process the event idempotently.
await processEvent(type, data);
res.status(200).json({ status: "ok" });
});Redis SETNX with a TTL is the textbook pattern. Postgres works too: insert into a webhook_events_seen table with the id as the primary key, catch the unique-violation error, ack 200 on collision.
Our dedupe window
On our side, we dedupe within a 24-hour window across both incoming and outgoing deliveries:
- Incoming. Inbound email keys on
messageId. Email delivery events key on the provider’s signed delivery ID. Inbound call events key oncallId + event. - Outgoing. We dedupe by the source event ID so two triggers (for example, a status change racing with a transfer) do not fire two outbound webhooks for the same effective state.
REST idempotency keys
The same idempotency model applies to destructive REST calls. Pass an Idempotency-Key header on any POST that could be retried. We dedupe by that key for 24 hours, so retrying with the same key never creates two resources.
curl -X POST 'https://app.message.com/api/v1/conversations/abc/messages' \
-H 'Authorization: Bearer YOUR_JWT' \
-H 'Idempotency-Key: msg-2026-05-13-checkout-failed-12345' \
-H 'Content-Type: application/json' \
-d '{ "body": "Auto-detected failed payment, reaching out..." }'Use a stable, intent-derived key (for example, msg-{user_id}-{event_id}) rather than a random UUID per call. A random UUID re-rolls on retry and defeats the dedupe.
Ordering
Webhooks are not strictly ordered across event types. Two events for the same conversation may arrive out of order during retries. Build handlers that converge regardless of order: read the latest state from the REST API rather than treating webhook payloads as authoritative.
For ordering-sensitive workflows, key your local state by the resource version (version field on conversations, message timestamps on messages) and reject older updates.
Failure modes to handle
| Failure | What we do | What you do |
|---|---|---|
| Your endpoint returns 5xx | Retry on the schedule. | Fix the underlying error. Manually replay the dead deliveries from the dashboard. |
| Your endpoint returns 4xx (other than 410) | Retry on the schedule. | 4xx usually indicates a bad payload. Open a ticket so we can fix it. |
| Your endpoint returns 410 Gone | Stop retrying immediately. Surface a warning. | Use this when an integration is permanently disabled. |
| Timeout (no response in 10s) | Treat as failure. Retry on the schedule. | Ack 200 fast, do work async. |
| SSL failure | Retry on the schedule. | Renew certificate. Failed deliveries between expiry and renewal can be manually replayed. |
Common pitfalls
- Not deduping. One retry storm turns into 10 emails to the customer, 10 charges on Stripe, 10 rows in your CRM. Always dedupe.
- Deduping by content hash. The content can shift slightly between retries (timestamps, internal IDs). Dedupe by the envelope
id, not the body. - Long synchronous handlers. Anything over 10 seconds times out. Ack fast, queue the work.
- Sweating about ordering. Compare versions or timestamps to reject stale updates. Do not try to enforce arrival order on the wire.
- Random idempotency keys. Generate the key from the intent, not from
uuid(). Otherwise retries each get a new key and defeat the dedupe.