Quick start
- Make a key on your account’s API page.
- Send a link. You get the job 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://www.lyricai.io/api/v1/jobs \
-H "Authorization: Bearer $LYRICAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"items": [
"https://open.spotify.com/track/TRACK_ID",
"https://youtu.be/VIDEO_ID"
],
"translate_to": "en",
"metadata": { "order": "1042" }
}'Authentication
Every request carries an API key in the Authorization header. Keys start with lyr_live_ and are shown once, when you make them; we keep only a hash. An account can have up to 10 keys. Revoking one stops it at once.
Authorization: Bearer lyr_live_...A key can spend the account’s credits, so call the API from your server, never from a browser or an app you ship. The API reads no cookies: being signed in to the site doesn’t authorize a request, and a key doesn’t work on the site.
Make jobs
POST /api/v1/jobs
One job per song: a Spotify album becomes a job for each of its songs. Up to 50 songs per request, each up to 30 minutes. All new songs are charged together, or none is.
itemsarray, required- Spotify song or album links and YouTube video links, as strings, or objects:
{ "url", "skip_published_lyrics" }to transcribe a Spotify song even when its lyrics are published, or{ "upload_id", "duration_ms", "title" }for a file (see Upload audio). 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. timestampsboolean- Time each line. Default
true. metadataobject- Up to 1024 bytes of your own JSON, returned with the job and its webhooks.
Language codes
arArabicbnBengaliyueCantonesezhChinese (Mandarin)csCzechdaDanishnlDutchenEnglishfiFinnishfrFrenchdeGermanelGreekheHebrewhiHindihuHungarianidIndonesianitItalianjaJapanesekoKoreanmsMalaynoNorwegianfaPersianplPolishptPortugueseroRomanianruRussianesSpanishswSwahilisvSwedishtlTagalogtaTamilthThaitrTurkishukUkrainianurUrduviVietnamese
A Spotify song whose lyrics are published (with timings, when timestamps is true) comes back completed at once, free. With translate_to it costs 1 credit and is queued for translation. Unknown fields are refused.
{
"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), and message says why.
The job object
status is queued, processing, completed or failed. While processing, stage is downloading, separating, transcribing or translating. lyrics_source is transcribed for lyrics written down from the recording, or names where published lyrics came from: lrclib or lyrics.ovh. credits_charged is final once the job completes: a recording shorter than its listed length gets the difference back. A failed job has error.message and is refunded on its own (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": {
"timestamps": true,
"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://www.lyricai.io/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"lyrics": "https://www.lyricai.io/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f/lyrics",
"web": "https://www.lyricai.io/results/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}
}When lyrics_source names a published source, credit it wherever you show the lyrics: LRCLIB or lyrics.ovh.
Get and list jobs
GET /api/v1/jobs/{id}
curl https://www.lyricai.io/api/v1/jobs/JOB_ID \
-H "Authorization: Bearer $LYRICAI_API_KEY"GET /api/v1/jobs
The account’s jobs, newest first, made here or on the site. limit is 1 to 100 (default 20), status filters, and cursor takes the next_cursor of the page before.
curl "https://www.lyricai.io/api/v1/jobs?limit=20&status=completed" \
-H "Authorization: Bearer $LYRICAI_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"
}Download lyrics
GET /api/v1/jobs/{id}/lyrics
formatstringjson(default),lrc,srt,txtormd. LRC and SRT need line timings.viewstringoriginal(default),translationorboth, each translated line under its original. JSON always holds both.
curl "https://www.lyricai.io/api/v1/jobs/JOB_ID/lyrics?format=lrc&view=both" \
-H "Authorization: Bearer $LYRICAI_API_KEY" -o song.lrc{
"title": "Song title",
"artist": "Artist",
"provenance": "LyricAI automatic transcription and machine translation",
"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.
Upload audio
POST /api/v1/uploads
Files go straight to storage: 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 the file’s length as duration_ms when you have it. Without it we read the length from the file, and answer 422 if it can’t be read. Either way 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 the balance can’t cover them).
# 1. Ask where to send the file
curl https://www.lyricai.io/api/v1/uploads \
-H "Authorization: Bearer $LYRICAI_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://www.lyricai.io/api/v1/jobs \
-H "Authorization: Bearer $LYRICAI_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 a link
GET /api/v1/quote?url=...&translate_to=...
What a link holds and what transcribing it costs, before anything is charged. Add translate_to (a language code, as for jobs) to price it translated: 1 credit more a song. It’s the most a job can cost: a Spotify song with published lyrics costs published_lyrics_credits instead (free, or 1 credit translated), and an unused free song covers one song of up to 10 minutes.
curl "https://www.lyricai.io/api/v1/quote?url=https%3A%2F%2Fopen.spotify.com%2Falbum%2FALBUM_ID&translate_to=en" \
-H "Authorization: Bearer $LYRICAI_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,
"published_lyrics_credits": 1,
"message": "This prices every song as transcribed. Songs whose lyrics are already published cost less: free, or 1 credit translated. Which songs those are is known only once the jobs are made.",
"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
}
]
}Account
GET /api/v1/account
The credits the API spends, and the account’s limits.
curl https://www.lyricai.io/api/v1/account \
-H "Authorization: Bearer $LYRICAI_API_KEY"{
"object": "account",
"credits": 76,
"free_song": {
"available": false,
"max_minutes": 10
},
"limits": {
"songs_in_progress": 100,
"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 endpoint on the API page: an HTTPS URL on a public address, port 443. For each job made through the API we send a POST with a JSON body when it’s done:
job.completed- The lyrics are ready: fetch
data.links.lyrics. job.faileddata.error.messagesays why. The credits come back on their own.job.refunded- The credits, or the free song, went back to the account.
webhook.test- From “Send test event” on the API page. Tried once.
{
"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": {
"timestamps": true,
"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://www.lyricai.io/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"lyrics": "https://www.lyricai.io/api/v1/jobs/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f/lyrics",
"web": "https://www.lyricai.io/results/6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}
}
}Answer with any 2xx within 10 seconds. Anything else, or no answer, is tried again after about 10 seconds, 1 minute, 10 minutes, then 1, 3, 6 and 12 hours, and then given up. Later retries can run up to an hour late. Redirects aren’t followed. An event can arrive more than once, so skip an id you’ve already handled, and don’t rely on the order they arrive in.
Check the signature
Events are signed the Standard Webhooks way, so its libraries (npm install standardwebhooks, pip install standardwebhooks) verify them too. Three headers come with each event: 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.
After you make a new signing secret, events carry two signatures, separated by a space, for 24 hours: one with each 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/lyricai", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
if (!verifyWebhook(process.env.LYRICAI_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.LYRICAI_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/lyricai")
def lyricai_webhook():
body = request.get_data()
if not verify_webhook(os.environ["LYRICAI_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['LYRICAI_API_KEY']}"},
).json()
return "", 204Errors
Errors have one shape, with an HTTP status. 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
}
}400- The request isn’t valid:
invalid_body,unknown_field,invalid_url,invalid_language... 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_unavailableorno_translation.429rate_limited,in_flight_limit,daily_limitorupload_quota.5xx- Our side, or a service we use. Nothing is charged for a job that wasn’t made; retry with the same Idempotency-Key.
Rate limits
Up to 120 requests a minute per account, all keys together; 10 job requests and 60 quotes a minute; 120 uploads in 10 minutes. Over a limit you get 429 with a Retry-After header in seconds.
Accounts also have limits on songs: up to 100 in progress and 500 a day, or 1 in progress and 100 a day until the account has bought credits. GET /api/v1/account shows them.
Idempotency
Send an Idempotency-Key header (any printable text up to 255 characters, such as a UUID or your order id) with POST /api/v1/jobs. Retrying with the same key returns the jobs it already made, without charging again, so a timeout is safe to retry. Keys are per account. A song added to a retry is made as a new job.
Pricing
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. There is no API fee and no separate balance. The pricing page lists the packs, and credits never expire.
GET /api/v1/quote prices a link before you send it, a 402 says how many credits a request needs, and each job’s credits_charged is what it used. A failed song is refunded on its own. Your free song, if unused, covers one song up to 10 minutes.
Data retention
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 history for 90 days. Uploaded files are deleted once transcribed, and audio from links isn’t kept. Webhook deliveries are listed on the API page for 7 days. The Privacy Policy has the details, and the Terms cover API use.