Quick start
- Make a key on your account’s API page.
- Send a link. The job comes back at once, with status 202.
- Wait for the
job.completedwebhook, or poll the job, then download the lyrics. Most songs take a few minutes.
curl https://lyricbook.ai/api/v1/jobs \
-H "Authorization: Bearer $LYRICBOOK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"items": [
"https://open.spotify.com/track/TRACK_ID",
{ "url": "https://youtu.be/VIDEO_ID", "source_language": "ja" }
],
"translate_to": "en",
"metadata": { "order": "1042" }
}'Authentication
Send your key as a bearer token. Keys start with lyr_live_, are shown once (we store only a hash) and stop working the moment you revoke them. Up to 10 per account.
Authorization: Bearer lyr_live_...A key spends your credits, so call the API from your server, never from a browser or an app you ship. The API ignores the site’s cookies, and keys don’t work on the site.
Jobs
POST/api/v1/jobs
One job per song: a Spotify album becomes a job for each song. Up to 50 songs a request, each up to 30 minutes. The new songs are charged together, or none is. Unknown fields are refused.
itemsarray, required- Spotify song or album links and YouTube video links, as strings. Or objects:
{ "url" }for a link,{ "upload_id", "duration_ms", "title" }for a file (Uploads). Either object can have its ownsource_language; an album’s applies to every song on it. translate_tostring or null- A language code from the list below. Default: no translation.
source_languagestring- The language sung, or
auto(default) to detect it. For every item without its own. chordsboolean- The song’s chords, detected from the recording and placed on the timed words. Default
false. 1 credit more a song, given back when no chords can be made. The sheet’s JSON then haschords(each with itsstart,endand a Hartelabelsuch asA:min7),keyandbeats; ChordPro and MIDI downloads are on the results page. published_lyricsboolean- Free: look each Spotify song up in published-lyrics databases instead of transcribing it. Default
false. Not withchordsortranslate_to; see below. metadataobject- Up to 1024 bytes of your JSON, returned with the job and its webhooks.
Idempotency-Keyheader- Up to 255 printable characters, such as a UUID or your order id. A retry with the same key returns the jobs it made, uncharged, so a timeout is safe to retry. Per account; a song added in the retry becomes a new job.
Language codes
arArabicbnBengaliyueCantonesezhChinese (Mandarin)zh-HantChinese (Traditional) (translate_to only)csCzechdaDanishnlDutchenEnglishfiFinnishfrFrenchdeGermanelGreekheHebrewhiHindihuHungarianidIndonesianitItalianjaJapanesekoKoreanmsMalaynoNorwegianfaPersianplPolishptPortugueseroRomanianruRussianesSpanishswSwahilisvSwedishtlTagalogtaTamilthThaitrTurkishukUkrainianurUrduviVietnamese
Every song is transcribed from its recording and timed by word and by line, at 1 credit a minute; the timing is included. With translate_to each song is 1 credit more, and with chords 1 more.
With published_lyrics: true nothing is transcribed or charged. Each Spotify song is looked up on LRCLIB, then lyrics.ovh, and comes back completed when found, with LRCLIB’s line timings when it has them. Songs not found, YouTube videos and uploads are left out (skipped); when nothing is found the answer is 404 no_published_lyrics, and when the databases didn’t answer, 503 lyrics_lookup_unavailable (try again shortly).
{
"data": [
{
"id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"object": "job",
"status": "queued",
"title": "Song title",
"artist": "Artist",
"credits_charged": 4,
...the rest of the job object
},
{
"id": "0d3b5a1c-7e9f-4b2d-8c6a-1f3e5d7b9a2c",
"object": "job",
"status": "queued",
"title": "Video title",
"artist": "Channel",
"credits_charged": 3,
...the rest of the job object
}
],
"skipped": 0,
"message": null
}skipped counts songs left out (over 30 minutes, or a file another job already used); message says why.
The job object
statusqueued,processing,completedorfailed.stage- While processing:
downloading,separating,transcribingortranslating. lyrics_sourcetranscribed, or where published lyrics came from:lrcliborlyrics.ovh. Credit LRCLIB or lyrics.ovh wherever you show those lyrics.credits_charged- Final once the job completes: a recording shorter than its listed length gets the difference back.
error, refunded- A failed job has
error.messageand is refunded automatically (refunded).
{
"id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"object": "job",
"status": "completed",
"stage": null,
"input": {
"type": "youtube",
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
},
"title": "Song title",
"artist": "Artist",
"duration_ms": 215000,
"artwork_url": "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
"lyrics_source": "transcribed",
"options": {
"chords": false,
"source_language": "auto",
"translate_to": "en"
},
"credits_charged": 5,
"free_song": false,
"refunded": false,
"error": null,
"metadata": {
"order": "1042"
},
"created_via": "api",
"created_at": "2026-10-20T12:00:00.000Z",
"finished_at": "2026-10-20T12:02:41.512Z",
"results_expire_at": "2026-10-21T12:00:00.000Z",
"results_deleted": false,
"links": {
"self": "https://lyricbook.ai/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"lyrics": "https://lyricbook.ai/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f/lyrics",
"web": "https://lyricbook.ai/results/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}
}Get and list jobs
GET/api/v1/jobs/{id}
curl https://lyricbook.ai/api/v1/jobs/JOB_ID \
-H "Authorization: Bearer $LYRICBOOK_API_KEY"GET/api/v1/jobs
Your jobs, newest first, from the API and the site. limit is 1 to 100 (default 20), status filters, and cursor takes the previous page’s next_cursor.
curl "https://lyricbook.ai/api/v1/jobs?limit=20&status=completed" \
-H "Authorization: Bearer $LYRICBOOK_API_KEY"{
"data": [
{
"id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"object": "job",
"status": "completed",
"title": "Song title",
...the rest of the job object
}
],
"has_more": true,
"next_cursor": "WyIyMDI2LTEwLTIwVDEyOjAwOjAwLjAwMDAwMFoiLCI2ZjFjIl0"
}Lyrics
GET/api/v1/jobs/{id}/lyrics
formatstringjson(default),lrc,srt,txtormd. LRC and SRT need line timings.viewstringoriginal(default),translation, orboth(each translated line under its original). JSON always holds both.timingstringline(default) orword: LRC with every word of the original lines marked (Enhanced LRC), on a transcribed sheet. JSON always carries the words, each line’swordsas{ "text", "start", "end" }; joined, their texts are the line.
curl "https://lyricbook.ai/api/v1/jobs/JOB_ID/lyrics?format=lrc&view=both" \
-H "Authorization: Bearer $LYRICBOOK_API_KEY" -o song.lrc{
"title": "Song title",
"artist": "Artist",
"provenance": "Automatic transcription and machine translation by lyricbook",
"segments": [
{
"text": "First line as sung",
"start": 12.4,
"end": 15.1
},
{
"text": "Second line as sung",
"start": 15.6,
"end": 18.9
}
],
"translated_segments": [
{
"text": "First line, translated"
},
{
"text": "Second line, translated"
}
],
"language": "ja",
"target_lang": "en",
"source": "pipeline",
"timed": true
}Times are seconds from the start of the track. 409 while the job is queued or processing, or when it failed; 410 once the lyrics are deleted, 24 hours after the job was made.
A sheet made with translate_to but without a translation says why in translation_skipped: same_language when the song is already sung in that language, failed when the translation couldn’t be made. Either way translation_refunded says whether the translation credit came back. On a job made with chords, chords_status is failed when none could be made, and chords_refunded says whether the chord credit came back; when it couldn’t be given back automatically, chords_refund_owed is true: email support@lyricbook.ai with the job id.
Uploads
POST/api/v1/uploads
Ask for an upload URL, PUT the file there within 2 hours with exactly the headers given, then make a job with its upload_id. MP3, M4A, WAV, FLAC, OGG, AIFF, or WMA, up to 50 MB. A file no job uses is deleted after about 3 hours; a transcribed one as soon as it’s done.
Send duration_ms if you know the length; otherwise we read it from the file, or answer 422 if we can’t. The song is priced on that length and settled on the decoded audio: a minute or more shorter gets the difference back, and longer uses the extra minutes (or fails, refunded, when your balance can’t cover them).
# 1. Ask where to send the file
curl https://lyricbook.ai/api/v1/uploads \
-H "Authorization: Bearer $LYRICBOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "demo.mp3", "size_bytes": 4718592 }'
# 2. PUT the file to upload.url, with every header in upload.headers
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: audio/mpeg" -H "x-upsert: false" \
-H "apikey: $UPLOAD_APIKEY" -H "Authorization: Bearer $UPLOAD_APIKEY" \
--data-binary @demo.mp3
# 3. Make a job from it
curl https://lyricbook.ai/api/v1/jobs \
-H "Authorization: Bearer $LYRICBOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "items": [{ "upload_id": "upl_3f2b9c0d41e84a7cb1f05d6e2a9c8b71", "duration_ms": 215000 }] }'{
"object": "upload",
"upload_id": "upl_3f2b9c0d41e84a7cb1f05d6e2a9c8b71",
"filename": "demo.mp3",
"size_bytes": 4718592,
"expires_at": "2026-10-20T14:00:00.000Z",
"upload": {
"method": "PUT",
"url": "https://<storage host>/storage/v1/object/upload/sign/uploads/...?token=...",
"headers": {
"Content-Type": "audio/mpeg",
"x-upsert": "false",
"apikey": "<public storage key>",
"Authorization": "Bearer <public storage key>"
}
}
}Quote and account
GET/api/v1/quote?url=...&translate_to=...&chords=true
What a link holds and what it costs, before anything is charged. With translate_to, each song is 1 credit more, and with chords=true 1 more. An unused free song covers one song up to 10 minutes.
curl "https://lyricbook.ai/api/v1/quote?url=https%3A%2F%2Fopen.spotify.com%2Falbum%2FALBUM_ID&translate_to=en" \
-H "Authorization: Bearer $LYRICBOOK_API_KEY"{
"source": "spotify",
"kind": "album",
"title": "Album title",
"subtitle": "Artist",
"artwork_url": "https://i.scdn.co/image/...",
"url": "https://open.spotify.com/album/ALBUM_ID",
"translate_to": "en",
"track_count": 2,
"duration_ms": 412000,
"credits": 10,
"message": "This prices every song transcribed and timed. Published lyrics, where a song has them, are free with published_lyrics.",
"tracks": [
{
"title": "First song",
"artist": "Artist",
"url": "https://open.spotify.com/track/...",
"duration_ms": 201000,
"too_long": false,
"credits": 5
},
{
"title": "Second song",
"artist": "Artist",
"url": "https://open.spotify.com/track/...",
"duration_ms": 211000,
"too_long": false,
"credits": 5
}
]
}GET/api/v1/account
Your balance, free song and limits.
curl https://lyricbook.ai/api/v1/account \
-H "Authorization: Bearer $LYRICBOOK_API_KEY"{
"object": "account",
"credits": 76,
"free_song": {
"available": false,
"max_minutes": 10
},
"limits": {
"songs_in_progress": 100,
"songs_running": 6,
"songs_per_day": 500,
"songs_per_request": 50,
"max_song_minutes": 30,
"unsent_uploads": 60,
"unsent_upload_bytes": 524288000,
"upload_max_bytes": 52428800
}
}Webhooks
Set one HTTPS endpoint (a public address on port 443) on the API page. When a job made through the API ends, we POST it a JSON event:
job.completed- The lyrics are ready: fetch
data.links.lyrics. job.faileddata.error.messagesays why. The credits come back automatically.job.refunded- The credits, or the free song, went back to the account.
webhook.test- From “Send test event” on the API page. Sent once, never retried.
{
"id": "msg_8b1f0c2a4e6d4b0f9a3c5e7d1f2b4a6c",
"type": "job.completed",
"timestamp": "2026-10-20T12:02:41.512Z",
"data": {
"id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"object": "job",
"status": "completed",
"stage": null,
"input": {
"type": "youtube",
"url": "https://www.youtube.com/watch?v=VIDEO_ID"
},
"title": "Song title",
"artist": "Artist",
"duration_ms": 215000,
"artwork_url": "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
"lyrics_source": "transcribed",
"options": {
"chords": false,
"source_language": "auto",
"translate_to": "en"
},
"credits_charged": 5,
"free_song": false,
"refunded": false,
"error": null,
"metadata": {
"order": "1042"
},
"created_via": "api",
"created_at": "2026-10-20T12:00:00.000Z",
"finished_at": "2026-10-20T12:02:41.512Z",
"results_expire_at": "2026-10-21T12:00:00.000Z",
"results_deleted": false,
"links": {
"self": "https://lyricbook.ai/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"lyrics": "https://lyricbook.ai/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f/lyrics",
"web": "https://lyricbook.ai/results/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}
}
}Answer with any 2xx within 10 seconds. Otherwise we retry after about 10 seconds, 1 minute, 10 minutes, then 1, 3, 6 and 12 hours, and then stop; later retries can run up to an hour late. Redirects aren’t followed. Events can arrive twice or out of order, so skip an id you’ve already handled.
Check the signature
Events follow Standard Webhooks, so its libraries (npm install standardwebhooks, pip install standardwebhooks) verify them too. Each event has three headers: webhook-id (the event’s id), webhook-timestamp (Unix seconds) and webhook-signature: v1, and the base64 HMAC-SHA256 of {id}.{timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. Verify the raw body, before parsing it.
For 24 hours after you make a new signing secret, events carry two signatures separated by a space, one per secret. Accept the event when either matches.
import crypto from "node:crypto";
// secret: your signing secret ("whsec_..."). body: the raw request body, as text.
export function verifyWebhook(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
// Refuse events more than 5 minutes old (or ahead): replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}// Express: keep the body raw until it's verified.
app.post("/webhooks/lyricbook", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
if (!verifyWebhook(process.env.LYRICBOOK_WEBHOOK_SECRET, req.headers, body)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
// An event can arrive twice: skip an event.id you've already handled.
if (event.type === "job.completed") {
const lrc = await fetch(`${event.data.links.lyrics}?format=lrc`, {
headers: { Authorization: `Bearer ${process.env.LYRICBOOK_API_KEY}` },
}).then((r) => r.text());
}
res.sendStatus(204);
});import base64, hashlib, hmac, time
# secret: your signing secret ("whsec_..."). body: the raw request body, as bytes.
def verify_webhook(secret: str, headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
# Refuse events more than 5 minutes old (or ahead): replays.
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
content = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()
return any(
version == "v1" and hmac.compare_digest(signature, expected)
for version, _, signature in (entry.partition(",") for entry in signatures.split(" "))
)# Flask
@app.post("/webhooks/lyricbook")
def lyricbook_webhook():
body = request.get_data()
if not verify_webhook(os.environ["LYRICBOOK_WEBHOOK_SECRET"], request.headers, body):
abort(400)
event = json.loads(body)
# An event can arrive twice: skip an event["id"] you've already handled.
if event["type"] == "job.completed":
lyrics = requests.get(
event["data"]["links"]["lyrics"],
params={"format": "json"},
headers={"Authorization": f"Bearer {os.environ['LYRICBOOK_API_KEY']}"},
).json()
return "", 204Errors and limits
Every error has this shape. code is stable; message is written for people. Some errors add details, like the credits a job needs.
{
"error": {
"code": "insufficient_credits",
"message": "This needs 8 credits and you have 3.",
"required": 8,
"available": 3
}
}400invalid_body,unknown_field,invalid_url,invalid_languageand other invalid input.401unauthorized(no key) orinvalid_api_key.402insufficient_credits, withrequiredandavailable.404not_found,no_tracksorupload_not_found.409not_ready,job_failedorupload_used.410results_deletedorupload_missing.422duration_unknown,format_unavailable,no_translationorword_timing_unavailable.429rate_limited,in_flight_limit,daily_limit,upload_quota, oruploads_in_flightanduploads_busy(retry afterRetry-After).5xx- Our side, or a service we use. A job that wasn’t made isn’t charged; retry with the same Idempotency-Key.
Rate limits
Per account, all keys together: 120 requests a minute, of which 10 job requests and 60 quotes, and 120 uploads in 10 minutes. Over a limit you get 429 with a Retry-After header in seconds.
Songs: up to 100 in progress, 6 running at once and 500 a day; until the account has bought credits, 10 in progress, 6 running and 100 a day. GET /api/v1/account shows yours.
Credits and retention
The API spends your account’s credits at the site’s prices: 1 credit per started minute of audio, and 1 more to translate a song. No API fee, no separate balance, and credits never expire; the pricing page lists the bundles. Each job’s credits_charged is what it used, and a failed song is refunded automatically.
Lyrics are deleted 24 hours after a job is made, so download what you want to keep. The job itself (title, status, credits) stays in your list for 90 days. Uploaded files are deleted once transcribed, and audio from links after processing. Our processing provider keeps an encrypted copy of the audio for up to 7 days, then deletes it. Webhook deliveries are listed on the API page for 7 days. The Privacy Policy has the details, and the Terms cover API use.