Skip to main content
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. 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. 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

Three calls. Everything else is your UI.

1 · Submit

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

While a job is held, the response carries everything your screen needs. This is the whole shape, once:
The review block is present only while the job is held and disappears on approval — from then on results is the authoritative copy.
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.

3 · Approve

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

A complete integration

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

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

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.

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:
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. "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.
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:
That filter takes your external_id or the id we return. An identifier we have never seen is an empty page, not an error.
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.

Webhooks

Optional. Everything below is on the job, so polling GET /jobs 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:
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:
1

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

Approving unchanged works

POST /approve with an empty body {}. Expect 200 with lines_edited: 0, then the job to finish on its own.
3

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

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

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

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.

Errors

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