> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lyrcs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook recovery

> See what we tried to send, and send it again

Webhook delivery is retried four times over about 36 minutes, and the `job.complete` that follows a review approval is sent once. After that the event is gone. These endpoints exist so that being unreachable for an hour is recoverable rather than permanent.

Three tools, in the order you reach for them: **dead letters** are the short, actionable list of events that ran out of retries and were never delivered — the "what did I miss?" answer; **deliveries** is the full attempt-by-attempt log for diagnosing *why*; and **replay** sends an event again.

## GET /webhooks/dead-letters

Every event that exhausted all four attempts and was never delivered, newest first. This is the list to reconcile against after an outage — one entry per lost event, not one per attempt. Outstanding entries only by default; a later successful delivery (including a replay) clears an entry automatically, so an empty list means nothing is outstanding.

```bash theme={null}
curl -H "Authorization: Bearer $LYRCS_API_KEY" \
  "https://lyrcs.ai/api/v1/webhooks/dead-letters"
```

| Parameter  | Type   | Description                                                      |
| ---------- | ------ | ---------------------------------------------------------------- |
| `resolved` | `true` | Include cleared entries too. Default shows only outstanding ones |
| `job_id`   | UUID   | Dead letters for one job                                         |
| `limit`    | 1–200  | Page size, default 50                                            |
| `cursor`   | string | From `next_cursor`                                               |

```json theme={null}
{
  "dead_letters": [
    {
      "id": "…",
      "job_id": "a1b2c3d4-…",
      "event": "job.complete",
      "webhook_url": "https://api.example.com/webhooks/lyrcs",
      "attempts": 4,
      "last_status_code": 503,
      "last_error": null,
      "created_at": "2026-08-20T15:45:36.482Z",
      "resolved_at": null,
      "resolved_via": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## POST /webhooks/dead-letters/\[id]/replay

Resend one dead-lettered event.

```bash theme={null}
curl -X POST https://lyrcs.ai/api/v1/webhooks/dead-letters/{id}/replay \
  -H "Authorization: Bearer $LYRCS_API_KEY"
```

```json theme={null}
{
  "dead_letter_id": "…",
  "job_id": "a1b2c3d4-…",
  "event": "job.complete",
  "delivered": true,
  "status_code": 200,
  "error": null,
  "resolved": true
}
```

Unlike the per-job replay below, this resends the **exact bytes we stored** when the event was dead-lettered — precisely the message you missed, not one rebuilt from the job's current state. A successful resend clears the entry (`resolved_via: "replay"`); a resend that fails again leaves it outstanding, and replaying an already-cleared entry returns `409`.

## GET /webhooks/deliveries

Every delivery attempt we have made for your organisation, newest first.

```bash theme={null}
curl -H "Authorization: Bearer $LYRCS_API_KEY" \
  "https://lyrcs.ai/api/v1/webhooks/deliveries?success=false&since=2026-09-01T00:00:00Z"
```

| Parameter | Type              | Description                                                         |
| --------- | ----------------- | ------------------------------------------------------------------- |
| `job_id`  | UUID              | Attempts for one job                                                |
| `event`   | string            | `job.complete`, `job.awaiting_review`, `job.failed`, `job.degraded` |
| `success` | `true` \| `false` | Filter by outcome — `false` is the one you want after an outage     |
| `since`   | ISO 8601          | Attempts after this time                                            |
| `limit`   | 1–200             | Page size, default 50                                               |
| `cursor`  | string            | From `next_cursor`                                                  |

```json theme={null}
{
  "deliveries": [
    {
      "id": "…",
      "job_id": "a1b2c3d4-…",
      "event": "job.awaiting_review",
      "webhook_url": "https://api.example.com/webhooks/lyrcs",
      "attempt": 4,
      "status_code": 404,
      "success": false,
      "error": null,
      "delivered_at": "2026-08-20T15:45:36.482Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

<Note>
  **`status_code` and `error` tell you different things.** A `status_code` means
  your endpoint answered and rejected it — the problem is in your handler. An
  `error` with no status code means we never got a reply at all — a timeout, DNS,
  or TLS failure — and the problem is in front of your handler.
</Note>

Ordered newest-first, unlike [`GET /jobs`](/api-reference/jobs-list). That endpoint is a sync feed where ascending order keeps a watermark safe; this is a log you read when something has just broken, so the recent end is the useful one.

## POST /jobs/\[id]/replay-webhook

Send a webhook again for one job.

```bash theme={null}
curl -X POST https://lyrcs.ai/api/v1/jobs/{job_id}/replay-webhook \
  -H "Authorization: Bearer $LYRCS_API_KEY"
```

Optionally name the event; otherwise we pick the one the job's current state warrants — `job.awaiting_review` if it is waiting for a reviewer, `job.complete` if it has finished.

```json theme={null}
{ "event": "job.complete" }
```

Response:

```json theme={null}
{
  "job_id": "a1b2c3d4-…",
  "event": "job.complete",
  "delivered": true,
  "status_code": 200,
  "webhook_url": "https://api.example.com/webhooks/lyrcs"
}
```

You always get `200` if the replay ran; **`delivered` reports what your endpoint did with it.** Those are different failures and conflating them would be unhelpful on the one endpoint you reach for when things are already broken.

### It rebuilds the payload

The delivery log records what happened to each attempt, not the bytes we sent, so a replay reconstructs the payload from the job **as it stands now**. Normally that is what you want — current truth rather than a stale copy — but a replayed `job.complete` can differ from the one you missed if the job changed in between, for example because a reviewer corrected a line and the timings were re-derived.

### The destination cannot be changed

Replay always sends to the `webhook_url` the job was submitted with. There is no parameter to redirect it — accepting one would turn an authenticated recovery tool into a way to point our servers at an arbitrary host.

To change where a job's webhooks go, submit the next job with the new URL.

### Cost

None. No transcription, no alignment, no vendor call — one database read and one outbound request. Replay draws on the read rate limit, not the submit limit.

## Errors

| HTTP | `code`     | Meaning                                                                           |
| ---- | ---------- | --------------------------------------------------------------------------------- |
| 404  | `NOT_001`  | Job not found, or belongs to another organisation                                 |
| 409  | `HOOK_001` | The job was submitted without a `webhook_url` — poll instead                      |
| 409  | `HOOK_002` | The job is not complete, so there is no `job.complete` to replay                  |
| 409  | `HOOK_003` | No review link on this job to replay                                              |
| 409  | `HOOK_004` | The review link has expired — contact [support@lyrcs.ai](mailto:support@lyrcs.ai) |
| 404  | `HOOK_005` | No dead letter with that id, or it belongs to another organisation                |
| 409  | `HOOK_006` | That dead letter has already been cleared                                         |
