Failed Deliveries (Dead-Letter UI)
When an outbound webhook delivery fails — and exhausts its retries — it lands in the Failed deliveries tab on /developer. This tab is your visibility into "what didn't ship + why."
What counts as a failure
A delivery is a failure if, after the full retry schedule (1m → 5m → 25m, 3 attempts), the destination is still non-2xx. Specifically:
| HTTP status | Treatment |
|---|---|
| 2xx | Success — never a failure |
| 3xx | Treated as success on 2xx follow (we don't auto-follow redirects on POST — endpoint should not 3xx) |
| 4xx | Terminal — no retries, immediate failure (bad request shape, auth missing, etc.) |
| 5xx | Retried per schedule, then failure |
| Network errors (timeout, DNS, TLS) | Retried per schedule, then failure |
So a single 5xx blip is auto-recovered. A persistently broken endpoint piles up in dead-letter.
What you see
The Failed deliveries tab shows a table:
| Column | Use |
|---|---|
| Event | lead.created, note.created, etc. |
| Subscription | The destination URL config that triggered the delivery |
| Attempted at | When we last tried |
| HTTP status | The final error code (or "network error") |
| Error | The body / error message returned |
| Attempt | Which retry was the final (typically 3) |
| Actions | Replay / Dismiss |
Click a row to expand the full request body, response headers, and any error trace.
Replay
The Replay button (paper-plane icon) re-queues a fresh delivery. We:
- Mark the old delivery as
dismissed_at = NOW()(so it disappears from the failed list). - Insert a brand-new delivery record + dispatch it.
- The new delivery goes through the normal retry schedule.
Idempotency note: the destination receives the same payload as before (same id, same event, same data). On your side, dedupe by id is critical — replaying after a successful manual fix could otherwise double-process.
Dismiss
The Dismiss button (trash icon) sets dismissed_at = NOW() without retrying. The row disappears from the active failed list (it's a soft delete — still in webhook_deliveries for audit).
Use Dismiss when:
- The destination is genuinely gone (you're switching to a new endpoint)
- You manually re-processed the event some other way
- You don't care about this specific failure
Bulk actions
For mass cleanups (e.g., your endpoint was down for 24h and there are 500 failed deliveries), select all rows + bulk Replay or bulk Dismiss. Replay all goes serially through the queue, so a flood of bulk replays won't DoS your endpoint.
Filters
The Failed tab supports filters:
- Subscription — narrow to one destination URL
- Event — narrow to a specific event type
- Status — narrow to 5xx vs 4xx vs network errors
- Date range — failures within X
Useful for "what's been failing this week" investigations.
Why a delivery shows here vs in the regular delivery log
The regular delivery log (under each subscription) shows every delivery, including successes. The Failed tab is the subset that failed terminally. Both back the same webhook_deliveries table — the Failed tab filters WHERE status NOT IN (2xx range) AND dismissed_at IS NULL.
Permission
The Failed deliveries tab is gated by api.manage (admins auto). The Replay and Dismiss actions are gated the same way.
Common causes of failures
| Pattern | Likely cause |
|---|---|
| Lots of 401s from one endpoint | Destination auth changed (rotated API key, etc.). Update endpoint to accept anonymous + validate by HMAC. |
| 422 with detailed body | Your endpoint rejects valid Kinderpedia payloads. Inspect; fix endpoint logic. |
| 500 spikes timed to deploys | Your endpoint was redeploying. Replays after deploy succeed. |
| Network errors only | Firewall / IP whitelist blocking Kinderpedia's egress IPs. |
| 200s in logs but Kinderpedia shows failures | Your endpoint returns 200 late (timeout >30s). Speed up the handler. |
What's stored
Each delivery row carries:
- The full request body (what we sent)
- The destination URL + secret hint (no full secret, just the last 4 chars)
- The full response headers + body (so you can see exactly what the destination said)
- All retry attempt timestamps
Storage retention is 30 days for successful deliveries, 90 days for failed deliveries (longer so you have time to investigate).
Audit
Every Replay + Dismiss action fires an audit_logs entry:
webhook_delivery_replayed— delivery id, subscription, who clickedwebhook_delivery_dismissed— same shape