Delivery and retries
The rules
| Parameter | Value |
|---|---|
| Success | Any 2xx response |
| Timeout | 20 seconds |
| Attempts | 6 — one immediate and five retries |
| Intervals | 60s · 5min · 30min · 2h · 6h |
| Automatic disabling | After 50 consecutive failures |
Anything that isn't 2xx counts as a failure: 3xx, 4xx, 5xx, timeout, DNS error,
invalid TLS. Redirects are not followed — a 301 is a failure.
Once the six attempts are exhausted, the delivery stays failed and is not retried
automatically. Total time between first and last is around 9 hours.
Why you should answer fast
The timeout covers the whole response, headers and body. A handler that processes before responding has 20 seconds for everything — and a traffic spike will blow through that.
// Wrong
app.post('/webhooks', async (req, res) => {
await saveToDatabase(req.body);
await callCrm(req.body);
await notifyTeam(req.body);
res.sendStatus(200); // may never get there in time
});
// Right
app.post('/webhooks', (req, res) => {
res.sendStatus(200);
queue.enqueue(req.body);
});
Idempotency
Since a delivery can repeat, the same event will arrive more than once. That's not an edge case: it's normal behaviour whenever your server is slow or flaky.
Two deduplication keys:
X-Joinotify-Delivery— stable across the retries of that delivery- The message
wamid— stable even if you redeliver manually
const isNew = await redis.set(`webhook:${deliveryId}`, '1', { NX: true, EX: 86400 });
if (!isNew) {
return; // repeat, ignore it
}
Automatic disabling
Each failure increments failureCount; any success resets it. At 50 consecutive
failures the endpoint is disabled: active: false, disabledAt stamped, and later
deliveries are marked failed with "Endpoint is inactive.".
This protects both sides — we stop hammering a server that clearly isn't there, and you don't get an avalanche when it comes back.
# Reactivate: the counter resets too
curl -X PATCH https://api.joinotify.com/webhook-endpoints/whe_01J8ZK... \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "active": true }'
Watch failureCount before it reaches 50. An endpoint at 30 failures is already in
trouble.
Investigating
curl 'https://api.joinotify.com/webhook-endpoints/deliveries?status=failed&limit=20' \
-H 'Authorization: Bearer sk_live_xxx'
{
"data": [
{
"id": "whd_01J8ZM...",
"endpointId": "whe_01J8ZK...",
"field": "messages",
"status": "failed",
"attempt": 6,
"responseStatus": 500,
"responseBody": "Internal Server Error",
"error": null,
"durationMs": 18412,
"nextRetryAt": null,
"createdAt": "2026-07-27T14:02:11.482Z"
}
]
}
| Field | What it reveals |
|---|---|
responseStatus | What your server returned — null means it never answered |
responseBody | The response body, truncated at 2000 characters |
error | Transport failure: DNS, TLS, timeout |
durationMs | Close to 20000 means a timeout |
attempt | Which of the six attempts it stopped on |
responseStatus: null with error filled in is a network or certificate problem, not
a code one. durationMs near 20000 is a slow handler.
Redelivering
curl -X POST https://api.joinotify.com/webhook-endpoints/deliveries/whd_01J8ZM.../redeliver \
-H 'Authorization: Bearer sk_live_xxx'
Creates a new delivery instead of recycling the old one, preserving the original failure in the history. The six-attempt cycle starts over.
To reprocess in bulk after an incident, list the failures for the period and redeliver one by one:
curl 'https://api.joinotify.com/webhook-endpoints/deliveries?status=failed&limit=200' \
-H 'Authorization: Bearer sk_live_xxx' \
| jq -r '.data[].id' \
| while read id; do
curl -sS -X POST "https://api.joinotify.com/webhook-endpoints/deliveries/$id/redeliver" \
-H 'Authorization: Bearer sk_live_xxx' > /dev/null
done
Safety net
Even with retries, an event can be lost — an endpoint down for more than nine hours, for
example. For critical data, run a periodic reconciliation against GET /events, which
keeps the raw history independently of deliveries.