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
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
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
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 perreview.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
Playaudio_url — the same URL you submitted. Give each row a play button that
seeks to that line’s moment:
- At the aligned gate,
lines[].timestampis[mm:ss.xx]. - At the transcript gate there are no timings yet, but
second_opinion.suggestions[].occurrences[].at_secondsgives you a seek target for exactly the lines that are in dispute — which are the ones worth hearing.
3. The quality signals, rendered
All of these are already in the response and all of them are easy to ignore.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:
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 with409 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:
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 pollingGET /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
failedwith stagereviewafter 8 days. - The
job.completewebhook 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.