> ## 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.

# Webhooks

> Receive event notifications instead of polling for job status

Webhooks deliver event payloads to your server as jobs complete. Pass a `webhook_url` when submitting a job or batch — lyrcs.ai will POST the event payload to that URL.

## Events

| Event                 | Fired when                                                              |
| --------------------- | ----------------------------------------------------------------------- |
| `job.complete`        | Job finishes successfully (or after artist approval if `review=true`)   |
| `job.awaiting_review` | Alignment done and `review=true` — results held pending artist approval |
| `job.degraded`        | First upstream (Gemini 503) retry — job is still processing             |
| `job.failed`          | Job fails permanently                                                   |
| `batch.complete`      | All jobs in a batch finish (whether complete or failed)                 |

## Retry schedule

lyrcs.ai retries failed webhook deliveries (non-2xx or timeout) on this schedule:

| Attempt | Delay       |
| ------- | ----------- |
| 1       | Immediate   |
| 2       | +1 minute   |
| 3       | +5 minutes  |
| 4       | +30 minutes |

Your endpoint must return a `2xx` response within **10 seconds** or the delivery is counted as failed.

<Note>
  The `job.complete` webhook fired after artist approval in the review flow uses **single-attempt delivery** — it does not retry. Ensure your review webhook endpoint is reliable.
</Note>

## Signature verification

Every webhook delivery includes an `X-Lyrcs-Signature` header containing an HMAC-SHA256 signature of the request body, keyed with your `webhook_secret`.

Verify the signature before processing the payload to confirm the request came from lyrcs.ai.

```javascript Node.js — signature verification theme={null}
import crypto from "crypto";

export function verifyLyrcsSignature(rawBody, signature, webhookSecret) {
  const expected = crypto
    .createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature, "hex"),
    Buffer.from(expected, "hex")
  );
}

// In your webhook handler (e.g. Express):
app.post("/webhooks/lyrcs", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-lyrcs-signature"];
  if (!verifyLyrcsSignature(req.body, signature, process.env.LYRCS_WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());
  // handle event...
  res.sendStatus(200);
});
```

***

## Event payloads

### job.complete

Fires when a job completes successfully. `downloads` is omitted when `align=false` was set. `cultural_notes` may be `null`.

```json theme={null}
{
  "event": "job.complete",
  "job_id": "a1b2c3d4-...",
  "created_at": "2024-01-01T00:00:00.000Z",
  "language": "Tamil",
  "duration_seconds": 214,
  "studio_url": "https://lyrcs.ai/studio/a1b2c3d4-...",
  "results": {
    "transcript": "நான் உன்னை நேசிக்கிறேன்...",
    "transliteration": "Naan unnai nesikirein...",
    "translation": "I love you...",
    "cultural_notes": null,
    "downloads": {
      "lrc_original": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/lrc/original",
      "lrc_transliterated": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/lrc/transliterated",
      "srt_original": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/srt/original",
      "srt_transliterated": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/srt/transliterated",
      "words_original": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/words/original",
      "words_transliterated": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/words/transliterated"
    }
  }
}
```

Download URLs in the payload still require your API key in the `Authorization` header to fetch.

<Note>
  `words_original` and `words_transliterated` are present when `word_align_requested: true` (the default). Jobs submitted with `word_align=false` include only `lrc_*` and `srt_*` in `downloads`.
</Note>

***

### job.awaiting\_review

Fires instead of `job.complete` when `review=true` and alignment succeeded. No `results` block — lyrics are held until the artist approves.

```json theme={null}
{
  "event": "job.awaiting_review",
  "job_id": "a1b2c3d4-...",
  "language": "Punjabi",
  "review_url": "https://lyrcs.ai/review/a1b2c3d4-...?token=abc123",
  "expires_at": "2024-01-02T00:00:00.000Z",
  "studio_url": "https://lyrcs.ai/studio/a1b2c3d4-..."
}
```

Forward `review_url` to the artist. When they approve, `job.complete` fires with the full payload.

***

### job.degraded

Fires on the **first** Gemini 503 retry only. The job is still processing — this is informational. Do not assume the job has failed.

```json theme={null}
{
  "event": "job.degraded",
  "job_id": "a1b2c3d4-...",
  "language": "Hindi",
  "reason": "gemini_503",
  "attempt": 1,
  "retrying_in_ms": 7243,
  "message": "Transcription delayed due to upstream capacity. Job is retrying automatically."
}
```

| Field            | Value                                                |
| ---------------- | ---------------------------------------------------- |
| `reason`         | Always `"gemini_503"`                                |
| `attempt`        | Always `1` — only fires on the first retry           |
| `retrying_in_ms` | Milliseconds until the retry fires (includes jitter) |

***

### job.failed

Fires when a job fails permanently after all retries are exhausted.

```json theme={null}
{
  "event": "job.failed",
  "job_id": "a1b2c3d4-...",
  "created_at": "2024-01-01T00:00:00.000Z",
  "language": "Tamil",
  "error": "processing_failed"
}
```

***

### batch.complete

Fires when all jobs in a batch have finished — whether complete or failed. `status` is `"complete"` if all jobs succeeded, `"partial"` if any failed or timed out.

`downloads` is included per job only when `status === "complete"` AND `align_requested === true` for that job.

```json theme={null}
{
  "event": "batch.complete",
  "batch_id": "b1a2t3c4-...",
  "status": "partial",
  "job_count": 3,
  "completed": 2,
  "failed": 1,
  "jobs": [
    {
      "job_id": "a1b2c3d4-...",
      "language": "Hindi",
      "status": "complete",
      "review_required": false,
      "review_url": null,
      "review_approved_at": null,
      "studio_url": "https://lyrcs.ai/studio/a1b2c3d4-...",
      "downloads": {
        "lrc_original": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/lrc/original",
        "lrc_transliterated": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/lrc/transliterated",
        "srt_original": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/srt/original",
        "srt_transliterated": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-.../download/srt/transliterated"
      }
    },
    {
      "job_id": "b2c3d4e5-...",
      "language": "Tamil",
      "status": "failed",
      "review_required": false,
      "review_url": null,
      "review_approved_at": null,
      "studio_url": "https://lyrcs.ai/studio/b2c3d4e5-..."
    }
  ]
}
```
