Skip to main content

Delivery and retries

The rules​

ParameterValue
SuccessAny 2xx response
Timeout20 seconds
Attempts6 — one immediate and five retries
Intervals60s · 5min · 30min · 2h · 6h
Automatic disablingAfter 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"
}
]
}
FieldWhat it reveals
responseStatusWhat your server returned — null means it never answered
responseBodyThe response body, truncated at 2000 characters
errorTransport failure: DNS, TLS, timeout
durationMsClose to 20000 means a timeout
attemptWhich 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.