Retries & Auto-Disable
Kirimdev guarantees at-least-once delivery via an 8-attempt retry
pipeline with exponential backoff and jitter, spread across a
~24-hour total window. After the 8th attempt fails, the delivery is
marked failed and parks in the dead-letter queue. After 20
consecutive failed deliveries on the same subscription, the
subscription is auto-disabled.
Retry schedule
Section titled “Retry schedule”| Attempt | Delay before attempt |
|---|---|
| 1 | immediate |
| 2 | 10 s |
| 3 | 30 s |
| 4 | 2 m |
| 5 | 10 m |
| 6 | 1 h |
| 7 | 6 h |
| 8 | 24 h |
Each delay carries ±20% jitter to avoid thundering herds on shared infrastructure. Per-attempt HTTP timeout: 10 seconds.
After attempt 8 fails, the delivery row status flips to failed and
sits in the dead-letter queue until you either:
- Replay it via the API or dashboard (see Replay below), or
- The daily purge removes it 30 days after creation.
HTTP status semantics
Section titled “HTTP status semantics”| Response | Treated as | Retried? | error_code |
|---|---|---|---|
| 2xx (200, 201, 204, …) | success | — | null |
| 3xx | permanent failure — Kirimdev does not follow redirects | no — dead on the first attempt | http_error |
| 4xx (most) | permanent failure | no — dead on the first attempt | http_error |
4xx 408 (request timeout) | transient failure | yes | http_error |
4xx 429 (rate limited) | transient failure | yes | http_error |
| 5xx | transient failure | yes | http_error |
| DNS / TLS / connection refused / reset | transient failure | yes | connection_error |
| No response within 10 s | transient failure | yes | timeout |
The “most 4xx → dead immediately” rule reflects reality: a 401, 403, or 404 from your server almost always means a config bug (wrong URL, bad gateway auth) that retrying can’t fix. Fix the config, then replay.
Redirects are deliberately not followed. The request signature is computed against the URL you registered; following a redirect would hand a valid signature to whatever host the redirect points at. Point the subscription at the final URL instead.
Reading a failure
Section titled “Reading a failure”Three fields, in this order:
error_code— the failure class. Start here.response_status— the HTTP status, when there was one.response_body_snippet— the first 1 KB your endpoint returned.
response_status and response_body_snippet contain only what your
endpoint sent. When both are null, your endpoint never answered, and
error_code tells you whether that was a timeout or a connection
failure.
Four error_code values mean the request never left Kirimdev, so there
is nothing to debug on your side:
error_code | What to do |
|---|---|
subscription_inactive | The subscription was paused or disabled when the attempt came due. Re-enable, then replay. |
subscription_deleted | The subscription was deleted before delivery. Recreate it if you still want the events. |
ssrf_blocked | The URL resolves to a private or internal address. Use a publicly routable host. |
no_signing_secret | Every signing secret expired. Rotate in a new one, then replay. |
error_message carries a short human-readable detail (200 characters
max) for whichever code applies.
Recommended endpoint behaviour
Section titled “Recommended endpoint behaviour”-
Ack within 1-2 seconds. Persist the raw payload (and the
X-Kirim-Event-Id) inside a quick DB write, then return 200. Hand off heavy processing to your own queue.app.post('/webhooks/kirim', async (req, res) => {await persistRawEvent(req.headers['x-kirim-event-id'], req.body)res.status(200).send('ok')// Async processing kicks off via your own worker.}) -
Return 503 if you’re overloaded rather than 200-then-drop. 503 triggers a retryable failure; you’ll get the same payload again after the backoff.
-
Never return 2xx for a payload you couldn’t store. Acking prematurely breaks the at-least-once contract on your side.
Idempotency on your side
Section titled “Idempotency on your side”Because deliveries are at-least-once, the same event can arrive more than once — typically when a retry fires after your server processed the original but didn’t respond in time.
Dedupe on the X-Kirim-Event-Id header. The same id always
represents the same logical event, regardless of attempt number or
whether the delivery is a manual replay.
const fresh = await redis.set(`kirim:evt:${eventId}`, '1', { EX: 604800, NX: true })if (!fresh) return res.status(200).send('duplicate-ack')See Overview → Dedupe for the full pattern.
Auto-disable
Section titled “Auto-disable”After 20 consecutive failed attempts on the same subscription,
Kirimdev flips the subscription to status: 'disabled':
{ "id": "wbs_…", "object": "webhook_subscription", "status": "disabled", "disabled_reason": "auto_disabled_max_consecutive_failures", "consecutive_failures": 20, "last_disabled_at": "2026-07-24T08:14:33Z", "last_disabled_reason": "auto_disabled_max_consecutive_failures", "...": "..."}The counter tracks attempts, not deliveries. This matters more than it sounds: a single delivery can burn up to 8 attempts on its way through the retry ladder, so an endpoint that reliably times out can trip the 20-attempt threshold after as few as three events. Any successful delivery resets the counter to zero.
disabled_reason describes the current state and is cleared when you
re-enable the subscription. last_disabled_at and
last_disabled_reason are history — they are never cleared, so you can
still tell that a subscription was auto-disabled after recovering it,
and line the outage window up against your own logs.
While disabled, no new events fan out to this subscription. New events that would have been sent are simply not enqueued — they are not buffered for later delivery, because re-enabling would otherwise trigger a thundering herd.
Deliveries whose retries were still scheduled when the subscription was
disabled are terminated with error_code: "subscription_inactive".
They keep their real attempt_count and the last response your
endpoint gave, and they remain replayable once you re-enable.
Re-enabling
Section titled “Re-enabling”Once your endpoint is healthy:
curl -X PATCH \ https://api.kirimdev.com/v1/webhook_subscriptions/wbs_… \ -H "Authorization: Bearer $KIRIM_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "active" }'await kirim.webhookSubscriptions.update('wbs_…', { status: 'active',})httpx.patch( "https://api.kirimdev.com/v1/webhook_subscriptions/wbs_…", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}, json={"status": "active"},).raise_for_status()Re-enabling resets consecutive_failures to 0. Future deliveries
resume immediately. Replaying old failed deliveries is opt-in —
they don’t fire automatically, so a stale endpoint doesn’t drown
itself the second it comes back online.
Pausing manually
Section titled “Pausing manually”To temporarily stop deliveries without losing the subscription (e.g. during a planned maintenance window):
await kirim.webhookSubscriptions.update('wbs_…', { status: 'paused' })Paused subscriptions don’t accumulate failed deliveries — Kirimdev
drops events that would have fanned out to them. Resume with
{ status: 'active' }.
Replaying failed deliveries
Section titled “Replaying failed deliveries”The dead-letter queue keeps failed deliveries for 30 days. Inspect, filter, and replay them once your endpoint is healthy again.
List failed deliveries
Section titled “List failed deliveries”curl -G https://api.kirimdev.com/v1/webhook_deliveries \ --data-urlencode "status=failed" \ --data-urlencode "subscription_id=wbs_…" \ --data-urlencode "limit=50" \ -H "Authorization: Bearer $KIRIM_KEY"// Paginate failed deliveries for a subscription.for await (const delivery of kirim.webhookDeliveries.list({ status: 'failed', subscription_id: 'wbs_…',})) { console.log(delivery.id, delivery.attempt_count, delivery.response_status, delivery.error_code)}resp = httpx.get( "https://api.kirimdev.com/v1/webhook_deliveries", params={"status": "failed", "subscription_id": "wbs_…", "limit": 50}, headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"},)resp.raise_for_status()for delivery in resp.json()["data"]: print(delivery["id"], delivery["attempt_count"])Replay a single delivery
Section titled “Replay a single delivery”curl -X POST \ https://api.kirimdev.com/v1/webhook_deliveries/wbd_…/replay \ -H "Authorization: Bearer $KIRIM_KEY"const replay = await kirim.webhookDeliveries.replay('wbd_…')console.log(replay.id, replay.replayed_from)httpx.post( f"https://api.kirimdev.com/v1/webhook_deliveries/{delivery_id}/replay", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"},).raise_for_status()Bulk replay
Section titled “Bulk replay”curl -X POST \ https://api.kirimdev.com/v1/webhook_deliveries/bulk_replay \ -H "Authorization: Bearer $KIRIM_KEY" \ -H "Content-Type: application/json" \ -d '{ "subscription_id": "wbs_…", "status": "failed", "created_after": "2026-05-20T00:00:00Z", "created_before": "2026-05-23T00:00:00Z" }'const result = await kirim.webhookDeliveries.bulkReplay({ subscription_id: 'wbs_…', status: 'failed', created_after: '2026-05-20T00:00:00Z', created_before: '2026-05-23T00:00:00Z',})
console.log(`enqueued ${result.enqueued} (capped: ${result.capped})`)resp = httpx.post( "https://api.kirimdev.com/v1/webhook_deliveries/bulk_replay", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}, json={ "subscription_id": "wbs_…", "status": "failed", "created_after": "2026-05-20T00:00:00Z", "created_before": "2026-05-23T00:00:00Z", },)resp.raise_for_status()print(resp.json()["data"])Cap: 1000 deliveries per bulk_replay call. If your filter
matches more, the response capped field is true — paginate via
created_after/created_before and call again.
Suggested recovery playbook
Section titled “Suggested recovery playbook”-
Spot the auto-disable via the email notification, the dashboard banner, or by polling
GET /v1/webhook_subscriptions/{id}forstatus: 'disabled'. -
Fix the underlying issue. TLS cert renewal, infra rollback, bug fix — whatever the failed deliveries’ response bodies indicate.
-
Test against a single delivery first. Pick one failed
wbd_…andreplayit. Check your endpoint returned 2xx. -
Re-enable the subscription with
{ status: 'active' }. -
Bulk-replay the backlog filtered to the outage window.
-
Set up monitoring — alert on
subscription.status != 'active'and on risingconsecutive_failuresso you catch the next outage before it auto-disables.
Observability
Section titled “Observability”The dashboard’s Developers → Webhook Deliveries page shows every delivery (last 30 days), filterable by subscription, status, event type, and date range. Each row exposes:
- Attempt count + last/next attempt timestamps
- Failure reason, in plain language, derived from
error_code - Response status + first 1 KB of the response body, shown only when your endpoint actually answered
- Full payload (pretty-printed JSON, copy-to-clipboard)
- Replay button
The same data is queryable via GET /v1/webhook_deliveries — see the
API reference.