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

# Running Review In Your Own Product

> Build the lyric review step inside your product, instead of sending the artist to lyrcs.ai

A job submitted with `review: true` stops and waits for a human to say the lyrics
are right. There are two ways to reach that human, and they are two doors into
the same held job — not two modes.

|                              | Door A — review on lyrcs.ai    | Door B — review in your product                |
| ---------------------------- | ------------------------------ | ---------------------------------------------- |
| Who builds the screen        | We do                          | You do                                         |
| How the artist gets there    | You forward `review_url`       | They are already signed in to your app         |
| How approval happens         | They click approve on our page | Your server calls `POST /v1/jobs/{id}/approve` |
| Artist needs a lyrcs.ai link | Yes                            | No                                             |

Nothing downstream knows or cares which door was used. Alignment, the outputs and
the `job.complete` webhook are identical either way, the first approval wins
whichever door it came through, and you choose per job.

Door A needs no work beyond forwarding a URL and is covered in
[Review Flow](/guides/review-flow). This guide is Door B.

## When Door B is worth building

Choose it when the artist is already inside your product and a hand-off to a
second website would be the odd step in an otherwise finished flow — or when your
compliance requires that every approval originate from your own system.

Choose Door A when you would rather not build and maintain a lyrics editor. Our
page has an audio player, per-line playback, line editing and the quality signals
already rendered. Matching it is a real piece of front-end work, and a worse
reviewer catches fewer errors — which is the thing the review step exists for.

## The shape of it

```
submit (review: true)          POST /v1/transcribe
      │
      ▼
transcription runs (~60s)
      │
      ▼
job held at the gate           GET  /v1/jobs/{id}  → review.lines[]
      │                        ← your reviewer screen
      ▼
artist accepts                 POST /v1/jobs/{id}/approve
      │
      ▼
alignment runs (~30s)
      │
      ▼
LRC · SRT · word timings       GET  /v1/jobs/{id}  → results.downloads
```

Three calls. Everything else is your UI.

## 1 · Submit

```json theme={null}
POST /api/v1/transcribe
{
  "audio_url": "https://cdn.example.com/song.mp3",
  "language": "Punjabi",
  "review": true,
  "review_stage": "transcript",
  "review_delivery": "api",
  "external_id": "TRACK_12345",
  "end_user": { "external_id": "cust_8842", "email": "artist@example.com" }
}
```

`review_stage: "transcript"` holds the job **before** any timing work, so
corrections reach the aligner. That is almost always what you want here — see
[choosing a stage](/guides/review-flow#choosing-a-stage).

`review_delivery: "api"` is optional and shuts Door A for this job. Leave it out
if you want the option of falling back to forwarding `review_url`.

## 2 · Read the held job

```
GET /api/v1/jobs/{id}
```

While a job is held, the response carries everything your screen needs. This is
the whole shape, once:

```json theme={null}
{
  "job_id": "a1b2c3d4-…",
  "external_id": "TRACK_12345",
  "status": "processing",
  "stage": "awaiting_review",
  "language": "Punjabi",
  "end_user": { "id": "2fc0df07-…", "external_id": "cust_8842", "email": "artist@example.com", "name": null },

  "review_required": true,
  "review_approved_at": null,
  "review_delivery": "api",
  "review_url": null,

  "review": {
    "stage": "transcript",
    "delivery": "api",
    "lines": [
      { "index": 0, "text": "…", "transliteration": "…", "timestamp": null, "flagged": false },
      { "index": 1, "text": "…", "transliteration": "…", "timestamp": null, "flagged": false }
    ]
  },

  "second_opinion": {
    "total_lines": 24,
    "agreed_lines": 21,
    "match_ratio": 0.875,
    "hidden_count": 0,
    "suggestions": [
      { "kind": "line", "current": "…", "heard": "…",
        "occurrences": [ { "line_index": 7, "at_seconds": 41.2 } ] },
      { "kind": "section", "current": null, "heard": "…",
        "occurrences": [ { "line_index": 18, "at_seconds": 96.4 } ] }
    ]
  },

  "alignment": null,
  "audio_url": "https://cdn.example.com/song.mp3"
}
```

The `review` block is present **only while the job is held** and disappears on
approval — from then on `results` is the authoritative copy.

<Note>
  `review.lines` is an array rather than the newline-joined `results.transcript`
  string on purpose. Line identity is what makes per-line playback and every
  per-line signal below usable. `results.transcript` is unchanged and still there.
</Note>

## 3 · Approve

```json theme={null}
POST /api/v1/jobs/{id}/approve
{
  "lines": ["first line", "second line corrected", "third line"],
  "transliterated_lines": ["…", "…", "…"]
}
```

Both fields are optional; omit them to approve unchanged. Send **text only** — no
timestamps. You have not run the aligner, so you have no measured timings to
send, and anything an edit invalidates is re-derived from the audio.

At the transcript gate this releases alignment and `job.complete` arrives later.
At the aligned gate it finalises the job, re-timing the corrected text.

<Note>
  Approval is **idempotent**. The first one wins; a retry, or an approval through
  the other door, returns `already_approved: true` and changes nothing. Retrying
  after a timeout is safe.
</Note>

## A complete integration

Poll, render, approve. This is the whole server side.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const API = "https://lyrcs.ai/api/v1";
  const headers = {
    Authorization: `Bearer ${process.env.LYRCS_API_KEY}`,
    "Content-Type": "application/json",
  };

  async function submit(audioUrl: string, language: string, customerId: string) {
    const res = await fetch(`${API}/transcribe`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        audio_url: audioUrl,
        language,
        review: true,
        review_stage: "transcript",
        review_delivery: "api",
        end_user: { external_id: customerId },
      }),
    });
    if (!res.ok) throw new Error(`submit failed: ${res.status}`);
    return (await res.json()).job_id as string;
  }

  // Find everything waiting on a human, in one call, without polling job by job.
  async function jobsAwaitingReview(since: string) {
    const res = await fetch(
      `${API}/jobs?updated_since=${encodeURIComponent(since)}&limit=100`,
      { headers }
    );
    const { jobs } = await res.json();
    return jobs.filter(
      (j: any) => j.review_required && j.review_approved_at === null
    );
  }

  async function getHeldJob(jobId: string) {
    const res = await fetch(`${API}/jobs/${jobId}`, { headers });
    const job = await res.json();
    if (!job.review) return null; // not held — already approved, or not there yet
    return job;
  }

  async function approve(
    jobId: string,
    lines?: string[],
    transliteratedLines?: string[]
  ) {
    const res = await fetch(`${API}/jobs/${jobId}/approve`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        ...(lines ? { lines } : {}),
        ...(transliteratedLines ? { transliterated_lines: transliteratedLines } : {}),
      }),
    });
    const body = await res.json();

    // 502 APR_004 means the edits saved but the release failed. The approval was
    // rolled back, so retrying is a clean repeat rather than a no-op.
    if (res.status === 502 && body.code === "APR_004") {
      return approve(jobId, lines, transliteratedLines);
    }
    if (!res.ok) throw new Error(`${body.code}: ${body.message}`);
    return body;
  }
  ```

  ```python Python theme={null}
  import os, requests

  API = "https://lyrcs.ai/api/v1"
  HEADERS = {
      "Authorization": f"Bearer {os.environ['LYRCS_API_KEY']}",
      "Content-Type": "application/json",
  }

  def submit(audio_url, language, customer_id):
      r = requests.post(f"{API}/transcribe", headers=HEADERS, json={
          "audio_url": audio_url,
          "language": language,
          "review": True,
          "review_stage": "transcript",
          "review_delivery": "api",
          "end_user": {"external_id": customer_id},
      })
      r.raise_for_status()
      return r.json()["job_id"]

  def jobs_awaiting_review(since):
      r = requests.get(f"{API}/jobs", headers=HEADERS,
                       params={"updated_since": since, "limit": 100})
      r.raise_for_status()
      return [j for j in r.json()["jobs"]
              if j["review_required"] and j["review_approved_at"] is None]

  def get_held_job(job_id):
      job = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS).json()
      return job if job.get("review") else None

  def approve(job_id, lines=None, transliterated_lines=None):
      payload = {}
      if lines is not None:
          payload["lines"] = lines
      if transliterated_lines is not None:
          payload["transliterated_lines"] = transliterated_lines

      r = requests.post(f"{API}/jobs/{job_id}/approve", headers=HEADERS, json=payload)
      body = r.json()

      # 502 APR_004: edits saved, release failed, approval rolled back. Retry.
      if r.status_code == 502 and body.get("code") == "APR_004":
          return approve(job_id, lines, transliterated_lines)
      r.raise_for_status()
      return body
  ```
</CodeGroup>

## Building a reviewer worth having

If you build the screen, the quality of the review becomes the quality of your
UI. A textarea containing the joined lyrics technically works and catches almost
nothing.

These are the things that make the difference, in the order they are worth
building.

### 1. Per-line rows, not a text box

One editable row per `review.lines[]` entry, keyed by `index`. Everything else
here attaches to a line, and none of it can be shown against a blob of text.

Show `transliteration` beside `text` where it exists. A reviewer who cannot read
the source script can still catch errors in the romanisation.

### 2. Audio, seekable per line

Play `audio_url` — the same URL you submitted. Give each row a play button that
seeks to that line's moment:

* At the **aligned** gate, `lines[].timestamp` is `[mm:ss.xx]`.
* At the **transcript** gate there are no timings yet, but
  `second_opinion.suggestions[].occurrences[].at_seconds` gives you a seek target
  for exactly the lines that are in dispute — which are the ones worth hearing.

A reviewer who can hear the line corrects it. One who cannot, guesses.

### 3. The quality signals, rendered

All of these are already in the response and all of them are easy to ignore.

| Signal                                            | What a good reviewer does with it                                                   |
| ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `review.lines[].flagged`                          | Highlight the row. Its audio does not match its words.                              |
| `alignment.line_flags[]`                          | The same lines with a `ratio` — how much worse than their peers. Aligned gate only. |
| `second_opinion.suggestions[]`, `kind: "line"`    | Show `heard` as a one-click alternative to `current` on that row.                   |
| `second_opinion.suggestions[]`, `kind: "section"` | Prompt to **add** a line near `occurrences[].line_index`.                           |
| `occurrences[].at_seconds`                        | Seek the player straight to the disputed moment.                                    |
| `second_opinion.hidden_count`                     | Say "and N more" — the list is not the whole set.                                   |
| `second_opinion.match_ratio`                      | A one-glance "how much of this is in dispute" for the top of the screen.            |

<Warning>
  `kind: "section"` means **lyrics may be missing altogether** — an independent
  listener heard a passage where the transcript has nothing, so `current` is
  `null` and there is no line to correct. Handled as a line replacement it does
  nothing at all; handled as "add a line here" it catches a whole missed verse.

  It is the single highest-value case to get right in a custom reviewer, and the
  one most likely to be dropped by code that assumes every suggestion replaces
  something.
</Warning>

### 4. Add and delete lines, not just retyping

Transcription gets line *boundaries* wrong as well as words: a line missed
entirely, two sung lines merged into one, one split across two. If your reviewer
can only retype existing rows, none of those can be fixed.

Send the array you want. Its length may differ from what you received.

### 5. Reading `null` correctly

On `second_opinion` and `alignment`, `null` means **the check did not run** — not
that it found nothing.

An empty `suggestions` array is the good news: it ran and agreed with every line.
Collapsing the two into one falsy value throws that away and lets silence read as
a clean transcript. See [Quality Signals](/guides/quality-signals).

## The two scripts move together

`transliterated_lines` is **position-parallel** to `lines`: index 3 of one is the
romanisation of index 3 of the other. Alignment inherits each transliteration's
timing from its original by index.

So when you add or remove a line, send **both** arrays. Sending `lines` alone with
a different length is refused with `409 APR_005`:

```json theme={null}
{
  "error": "scripts_out_of_sync",
  "message": "lines has 4 entries but transliterated_lines would have 3. The two scripts are position-parallel — send both arrays when you add or remove a line.",
  "code": "APR_005"
}
```

That refusal exists because the alternative is silent. An uneven pair does not
fail alignment — it produces one blank transliterated line, and a *deletion*
shifts every remaining pair onto the wrong original for the rest of the song.

Editing wording without changing the count is unaffected, and a job with no
transliteration at all has nothing to keep in step.

### Line count at the aligned gate

At the **transcript** gate, send whatever array you want. Alignment has not run,
so there is nothing to invalidate.

At the **aligned** gate, a change in line count needs the timings re-derived from
the audio. Where that is not available for your organisation the request is
refused with `409 REV_001` rather than given a timestamp nobody measured.

## Common mistakes

### Treating `status: "complete"` as approved

At the **aligned** gate a held job reports `status: "complete"` and has
working download URLs before anyone has signed off — only the `job.complete`
webhook waits for approval.

`review_approved_at` is the only field that means a human approved. If you
need results to be genuinely unavailable before approval, use
`review_stage: "transcript"`: alignment has not run, so there is nothing
timed to fetch early.

### Rendering `results.transcript` instead of `review.lines`

`results` is only filled once a job is `complete`, and a transcript-gate job
is `processing` — so on the jobs you most want to review, it is empty. Read
`review.lines` while the job is held.

### Ignoring `kind: "section"` suggestions

Code written as `replace(current, heard)` silently does nothing for these,
because `current` is `null`. They are the ones that catch a missing verse.

### Sending timestamps back

There is no timestamp field on the approve request. If your client is
carrying timings around to send them back, it is carrying them for nothing —
the server takes them from the job and re-derives anything an edit changes.

### Polling each job individually

`GET /jobs?updated_since=` finds every job that changed since your last sweep,
in one call, with a resumable cursor. Polling job by job is what the read rate
limit is there to stop.

### Caching a null `review_url`

On a `review_delivery: "both"` job, `review_url` is `null` until the job
reaches its gate. Poll until it is non-null rather than caching the null. On
an `"api"` job it is null permanently and by design.

### Treating `502 APR_004` as a failure

It means the edits saved but the job could not be released — and the approval
was rolled back, so the retry is a clean repeat, not a no-op. Retry it.
Everything else in the 4xx range is a request to fix, not to retry.

## Choosing which doors a job opens

`review_delivery` is optional on submit and requires `review: true`.

| Value    | Effect                                                                                                      |
| -------- | ----------------------------------------------------------------------------------------------------------- |
| `"both"` | **Default.** A review link is created and the API approve endpoint works.                                   |
| `"api"`  | No review link is created. `review_url` is `null` and our review page cannot be opened for this job at all. |
| `"link"` | A review link is created; API approval is refused with `409 APR_003`.                                       |

`"api"` is a structural guarantee, not a convention: the link's security *is* its
token, and for these jobs no token is ever minted. There is nothing to leak and
nothing to remember not to forward.

Leave it unset unless you need a door shut. `"both"` lets you fall back to
forwarding `review_url` for a song your own reviewer cannot handle.

## Telling us whose song it is

In Door B the artist never touches lyrcs.ai, so a job would otherwise arrive with
no trace of who it belongs to. `end_user` fixes that, and it is optional.

```json theme={null}
"end_user": {
  "external_id": "cust_8842",
  "email": "artist@example.com",
  "name": "Asha R."
}
```

`external_id` is **your** identifier and is the key — send the same one and you
get the same record, however many jobs it appears on. Email and name are optional
attributes; a later submit fills one that was missing, but never overwrites a
value already recorded.

We never merge records on email. Addresses change, get shared between an artist
and their manager, and one person can arrive under two — merging on email would
splice two catalogues together.

Every job echoes the record — on `GET /jobs/{id}`, on list rows, and in the
`job.complete` webhook — and you can list a customer's whole catalogue:

```
GET /api/v1/jobs?end_user_id=cust_8842
```

That filter takes your `external_id` or the `id` we return. An identifier we have
never seen is an empty page, not an error.

<Note>
  These are not lyrcs.ai accounts and cannot sign in anywhere. The record exists
  so that "which of your customers is job `8f3a`?" has an answer on both sides
  when something goes wrong — you are the only one who can reach the person
  affected.
</Note>

## Webhooks

Optional. Everything below is on the job, so polling
[`GET /jobs`](/api-reference/jobs-list) with `updated_since` finds every job
waiting on a human in one sweep.

If you set `webhook_url`, `job.awaiting_review` tells you a job reached its gate:

```json theme={null}
{
  "event": "job.awaiting_review",
  "job_id": "a1b2c3d4-…",
  "language": "Punjabi",
  "review_stage": "transcript",
  "review_delivery": "api",
  "review_url": null,
  "expires_at": null,
  "approve_url": "https://lyrcs.ai/api/v1/jobs/a1b2c3d4-…/approve",
  "studio_url": "https://lyrcs.ai/studio/a1b2c3d4-…",
  "artist_url": "https://lyrcs.ai/artist?token=ps_…"
}
```

`review_url` and `expires_at` are `null` exactly when `review_delivery` is
`"api"`, so you do not have to remember what you configured to know which route
to take.

`artist_url` is the one link to hand your artist. When a submission carried an
`end_user`, this is a durable, catalogue-wide session
for that person — their own page listing every song you have submitted for them,
where they review lyrics, download the files, and order a video. Unlike
`review_url` (one job, seven days), it covers the whole catalogue and slides its
expiry as they use it, so a link mailed weeks ago still works. It is `null` when
the job had no `end_user`. Present even on `review_delivery: "api"`, because it
is the artist's door, not a review-page door — withholding the review link does
not withhold the artist's own page.

## Testing your integration

Worth proving before you go live, in roughly this order:

<Steps>
  <Step title="A held job renders">
    Submit with `review: true, review_stage: "transcript"`. Poll until `review`
    appears, and check your screen shows one row per line with transliterations
    beside them.
  </Step>

  <Step title="Approving unchanged works">
    `POST /approve` with an empty body `{}`. Expect `200` with
    `lines_edited: 0`, then the job to finish on its own.
  </Step>

  <Step title="An edit reaches the output">
    Change one line, approve, wait for `job.complete`, and confirm your change is
    in the LRC download. This is the whole point of the transcript gate.
  </Step>

  <Step title="Adding a line is refused when the scripts disagree">
    Send `lines` with one extra entry and no `transliterated_lines`. Expect
    `409 APR_005`. Then send both and expect `200`.
  </Step>

  <Step title="Retrying is safe">
    Approve the same job twice. Expect `already_approved: true` on the second,
    and confirm the second call's `lines` were not applied.
  </Step>

  <Step title="A section suggestion renders as 'add a line'">
    Hardest to arrange deliberately — but check your code path for
    `current: null` rather than waiting to meet it in production.
  </Step>
</Steps>

## Errors

| Status | Code      | Meaning                                                                                                                           |
| ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `APR_002` | `lines` or `transliterated_lines` was not an array of strings, or was empty. Omit the field to approve unchanged.                 |
| 404    | `NOT_001` | No such job, or it belongs to another organisation.                                                                               |
| 409    | `APR_001` | The job is not waiting for review. Submit with `review: true`.                                                                    |
| 409    | `APR_003` | The job was submitted with `review_delivery: "link"`.                                                                             |
| 409    | `APR_005` | `lines` and `transliterated_lines` would have different lengths. Send both.                                                       |
| 409    | `REV_001` | Line count changed at the aligned gate and the timings cannot be re-derived.                                                      |
| 502    | `APR_004` | The edits were saved but the job could not be released. **Retry** — the approval was rolled back, so the retry is a clean repeat. |

## Limits

* A transcript-gate job that is never approved is marked `failed` with stage
  `review` after **8 days**.
* The `job.complete` webhook fired on approval is **single-attempt**. If you miss
  it, poll the job or use [webhook recovery](/api-reference/webhook-recovery).
* Approving is counted against your **read** limit, not your submission limit.
  The job was charged when you created it; approving it is the second half of
  that same submission.
