Three calls: submit, poll, collect. Every AI operation on this platform is asynchronous — a submit returns a job_id in milliseconds and the work happens on a worker.
Create a key above (it is shown once — we store only a hash and cannot show it again).
Submit the file. One credit is deducted immediately.
Poll every few seconds until status is done or failed, then read the result.
export LRCSONG_API_KEY="lrc_xxxxxxxxxxxx"
# 1. Submit — returns { "job_id": "...", "status": "pending" }
JOB=$(curl -sS -X POST "https://lrcsong.com/api/v1/lrc" \
-H "X-API-Key: $LRCSONG_API_KEY" \
-H "Idempotency-Key: my-batch-0001" \
-F "audio=@song.mp3" | jq -r .job_id)
# 2. Poll — "pending" and "processing" are non-terminal
while :; do
STATUS=$(curl -sS "https://lrcsong.com/api/v1/jobs/$JOB" \
-H "X-API-Key: $LRCSONG_API_KEY" | jq -r .status)
[ "$STATUS" = "done" ] || [ "$STATUS" = "failed" ] && break
sleep 3
done
# 3. Collect — 409s unless the job is done, so branch on the status first
[ "$STATUS" = "failed" ] && { echo "job failed"; exit 1; }
curl -sS "https://lrcsong.com/api/v1/jobs/$JOB/result" \
-H "X-API-Key: $LRCSONG_API_KEY" | jq -r .lrc
Poll no faster than about once per second — the polling bucket allows 120 requests a minute per key, and a 3-second interval is plenty for jobs that take tens of seconds. Prefer not to poll at all? Configure a and we will call you when the job settles.
Before spending a credit on transcription, it is worth calling GET /api/v1/lyrics/search — it is free, instant, and for a well-known track it often already has the synced lyrics.
Base URL and conventions
Base URL: https://lrcsong.com/api/v1. The version is in the path; there is no version header.
Uploads are multipart/form-data. Let your HTTP client set the Content-Type — setting it by hand omits the multipart boundary, and the request fails validation.
Studio project creation is the one JSON body on the v1 surface; everything else is either multipart or a plain GET.
Every response carries X-Request-ID. Send your own value in that header to have it echoed back, and quote it when you report a problem — it is how we find your request in our logs.
Times are ISO-8601 UTC strings; durations and timestamps inside results are seconds as floating-point numbers.
Identifiers: API keys are prefixed lrc_, Studio projects vp_, and job ids are UUIDs. Store them as opaque strings — do not parse, shorten or reconstruct them.
No pagination. The only list endpoint is lyrics search, which returns at most 20 results and takes no cursor.
Authentication
Send your API key in the X-API-Key header on every request:
A key is shown once, at creation. We keep only a SHA-256 hash, so a lost key must be revoked and replaced.
A missing header is 401 Missing X-API-Key header; an unknown or revoked key is 401 Invalid or revoked API key. Revocation takes effect immediately.
Keys carry the full permissions of the account that owns them and never expire. Treat one like a password: keep it server-side, and revoke it rather than rotating it in place.
The unversioned endpoints further down accept the same key as Authorization: Bearer lrc_…. An API key is only ever accepted in a header — passing one as a ?token= query parameter is rejected on purpose, so a permanent credential cannot end up in an access log or a Referer header.
Endpoints — /api/v1
The versioned surface has frozen response contracts — fields are only ever added, never removed or renamed. Build integrations against these. Machine-readable schema: openapi.json · interactive docs.
Fetch a finished lyrics bundle: ready-to-save LRC text, plain lyrics, line timings and word timings.
Response
{
"job_id": "3f2a9c1e-5b47-4c2a-9a0d-1f6b8e2d7c41",
"language": "en",
"duration_s": 213.4,
"lrc": "[00:12.35]We were born in the static\n[00:16.02]...",
"plain_lyrics": "We were born in the static\n...",
"lines": [{ "start": 12.35, "end": 15.90, "text": "We were born in the static" }],
"words": [{ "start": 12.35, "end": 12.58, "text": "We" }]
}
Errors specific to this endpoint
409 — the job exists but is not done yet (the body names the current status)
404 — unknown job, or the job finished with no readable result
GET/api/v1/lyrics/searchFree · 30 / minute
Search the LRCLIB community database for existing synced lyrics. Free, instant, no job — try this before spending a credit on transcription.
Field
Type
Required
Notes
q
string
no
Free-text query. Pass at least one of q, track_name or artist_name.
track_name
string
no
Exact-ish track title.
artist_name
string
no
Artist name.
Response
{
"results": [
{
"id": 3396226,
"track_name": "Static",
"artist_name": "Steve Lacy",
"album_name": "Gemini Rights",
"duration": 213.0,
"has_synced": true,
"synced_lyrics": "[00:12.35]We were born in the static\n...",
"plain_lyrics": "We were born in the static\n..."
}
]
}
Errors specific to this endpoint
400 — none of q, track_name or artist_name was supplied
GET/api/v1/meFree
Introspect the calling key: which account it belongs to, the key's label, and the current credit balance.
Render the current storyboard into a video. This is the paid step. Returns a job id — poll GET /jobs/{job_id} for render progress, or the project for readiness.
Download the finished MP4. The body is the file, not JSON. It is served directly today, but enable redirect-following in your HTTP client anyway — an operator can switch the deployment to object storage, where this answers 302 to a short-lived signed URL.
Response
HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Disposition: attachment; filename="studio_b4b5f0ef.mp4"
<binary>
Errors specific to this endpoint
409 — nothing has been assembled yet
410 — the render has been swept from disk and is no longer available
Job lifecycle
status takes exactly these four values:
pending — Accepted and queued. Credits are already deducted.
processing — A worker has it; progress climbs from 0 to 100.
done (terminal) — Finished. The result endpoint will answer.
Credits are deducted at submission and refunded automatically when a job fails, in the same transaction that marks it failed — you are never charged for a job that did not produce a result, and you never need to ask for the refund.
A job that has been pending or processing for more than 65 minutes is considered stale and stops counting against your concurrency cap.
AI Video Studio workflow
Turning a script into a finished MP4 is four calls, and the ordering matters — an assemble sent before the storyboard exists is a 409, not a queue.
POST /api/v1/studio/videos — free. Creates the project and starts ingest (narration + storyboard).
GET /api/v1/studio/videos/{project_id} — poll until status is storyboard.
POST /api/v1/studio/videos/{project_id}/assemble — the paid step. Poll the returned job id for render progress, or the project for readiness.
GET /api/v1/studio/videos/{project_id}/download — once ready is true.
Project status values:
draft — Created; ingest (transcribe + storyboard) is running.
storyboard — Storyboard ready. This is when assemble will succeed.
assembling — A render is in flight.
ready — A composite exists — the download endpoint will serve it.
archived — Archived by its owner.
One wrinkle worth knowing: the create call answers "status": "processing", which is a synthetic value meaning “ingest has been queued”. It is not one of the five lifecycle values above — poll the project to see the real state.
Limits and quotas
File size: 100 MB. Over that is a 413. Our reverse proxy refuses bodies over 120 MB before the application sees them, and that refusal is an HTML page with no JSON detail — so guard the size client-side rather than parsing that response.
Audio duration: 30 minutes per transcription job. Longer is a 413 naming the measured length. Split long recordings and submit the parts.
Formats: MP3, FLAC, WAV, OGG, AAC, M4A, MP4, WebM audio; MP4, WebM, MOV, MKV, AVI, WMV, MPEG, 3GP video. Audio is extracted from video automatically. If your uploader sends application/octet-stream we fall back to the filename extension, so always send a real filename.
Concurrency: 3 jobs in flight by default (higher on paid plans). A fourth submit returns 429 with a structured body — see errors below.
Script length: 50,000 characters for a Studio project.
Lyrics search returns at most 20 results.
Credits
Operations are billed in credits, deducted at submission, at the same prices as the web app — the per-endpoint cost is in the table above. Polling, results, lyrics search, /me, Studio project creation and downloads are all free. GET /api/v1/me returns your live balance; when it is not enough for an operation the submit fails with 402 and nothing is queued or charged.
Idempotency
A retried submit after a dropped connection would otherwise charge twice and run the job twice. Send an Idempotency-Key header on POST /api/v1/lrc and a repeat of the same key replays the original response instead:
Keys are scoped to your account and remembered for 10 minutes.
8–200 characters of A–Z a–z 0–9 _ . : -. A malformed key is ignored, not rejected — so a bad key silently disables the protection. Validate yours once when you build the integration.
Retrying while the first attempt is still running returns 409. Back off and retry; do not treat it as a failure.
Currently supported on POST /api/v1/lrc only. /transcribe and the Studio endpoints ignore the header — retry those only when you know the first attempt never reached us.
Errors
Failures are ordinary HTTP status codes with a JSON body. Check the status code first: a job that ran and failed still answers 200 with "status": "failed", which is not the same thing as a request that was rejected.
Code
Meaning
400
The request is missing a required combination of fields (e.g. neither audio nor youtube_url).
401
No X-API-Key header, or the key is unknown or revoked.
402
Not enough credits for this operation. Nothing was queued and nothing was charged.
404
The job or project does not exist, or does not belong to this key's owner. The two are deliberately indistinguishable.
409
The resource is not in a state that allows this call — a result asked for before the job is done, a second assemble on a rendering project, or a replayed Idempotency-Key whose first attempt is still running.
413
The upload is over the size cap, or the audio is longer than the duration cap.
422
A field failed validation. detail is an ARRAY of per-field objects, not a string.
429
Either a rate limit (detail is a string, Retry-After tells you how long) or the concurrent-job cap (detail is an object — see below).
502 / 503
An upstream dependency or the job broker was unavailable. Safe to retry; nothing was charged.
Error body shapes
There are three, and a parser that assumes detail is always a string will throw on the other two. The ordinary case is a string:
{ "detail": "Job is processing, not done" }
A field-level validation failure (422) makes it an array:
{
"detail": [
{
"type": "string_too_long",
"loc": ["body", "script"],
"msg": "String should have at most 50000 characters"
}
]
}
And the concurrency cap makes it an object — deliberately, so a client can offer “cancel my stuck jobs” instead of just waiting:
Both of those are 429. Tell them apart by the shape of detail, or by the presence of Retry-After: a rate-limit 429 always carries it, the concurrency 429 never does.
Rate limits
Limits are per API key, not per plan — every plan gets the same ceilings. They are separate counters per group, so polling never eats into your submit budget:
10 / minute — job submissions (POST /api/v1/lrc, POST /api/v1/transcribe)
120 / minute — job polling (GET /api/v1/jobs/{job_id} and its result)
30 / minute — lyrics search
10 / minute — Studio project create and assemble. This one counts per account, not per key, so two keys on the same account share it.
The window is fixed, not sliding: the counter starts at your first request and resets together. Exceeding a limit returns 429 with Retry-After in whole seconds — honour it rather than retrying on a fixed schedule.
Two overlaps to size against. Submissions also spend a per-account counter shared with the web app, at the same 10 per minute — if somebody is using the web UI on the same account while your integration runs, you share that budget. And the concurrency cap above is per account as well: three jobs in flight across every key you hold, not three per key. Need more throughput for a production integration? Get in touch — we raise limits per key.
Webhooks
Configure an endpoint to be called when a job settles, instead of polling for it. Use the to register and test your URL — the test button sends the same body shape as a real delivery.
job.completed — job finished successfully
job.failed — job encountered an error (credits are refunded automatically)
Your URL must be a public http(s) address. Loopback, private-range, link-local and cloud-metadata hosts are refused, and redirects are not followed — so a local endpoint behind a tunnel works, localhost does not.
On job.failed the same body carries an additional error string (truncated to 500 characters). result_url still needs your API key — the webhook tells you the work is done, it does not hand out the result.
Verifying the signature
Every delivery carries X-LrcSong-Signature: t=<unix>,v1=<hex>, where hex is HMAC-SHA256 of "<t>.<raw body>" keyed with your signing secret. The timestamp is inside the signed material, so a captured delivery cannot be replayed later — reject a t that is far from your own clock.
Sign the raw request bytes. Parsing the JSON and re-serialising it changes key order and whitespace, which changes the digest and fails every check.
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sent = int(parts["t"]), parts["v1"]
if abs(int(time.time()) - ts) > tolerance_s:
return False # too old — replay
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sent) # constant-time
Headers, retries and failure
X-LrcSong-Event — the event name, also in the body.
X-LrcSong-Delivery — carries the job id and is stable across retries. Use it as your idempotency key so a redelivered event is not processed twice.
User-Agent: LrcSong-Webhooks/1.0.
Reply 2xx within 10 seconds to acknowledge. Acknowledge first and do the work afterwards — a slow handler reads as a failed delivery.
Anything else is retried up to 4 times with jittered backoff starting at 30 seconds and doubling to a 10-minute cap.
An endpoint that fails 20 deliveries in a row is disabled automatically; saving the URL again re-enables it. Poll as a fallback if a missed event would cost you something.
Data retention
Results are kept indefinitely by default — a job row and its lyrics stay readable through /jobs/{job_id}/result until you delete it.
Uploaded source media is deleted about 24 hours after upload. That does not affect the result, which is stored separately — but it does mean a re-run needs the file again.
Rendered Studio videos live in the render cache, which is pruned over time. Download what you need; a swept render answers 410.
Versioning and stability
/api/v1 response models are frozen and enforced by a test in our build: a field may be added as optional, and nothing may be removed, renamed, retyped or repurposed. Any breaking change would ship as /api/v2 alongside this one. So parse defensively — ignore fields you do not recognise rather than rejecting the response — and you will not need to touch your integration for a v1 change.
Internal endpoints (unversioned — may change)
The endpoints below power the web app. They accept the same API key as Authorization: Bearer lrc_…, but their shapes can change without notice — prefer /api/v1 for anything you don’t want to babysit.
POST /api/converters/convert — convert between LRC/SRT/VTT/ASS/TTML.
POST /api/tags/mp3/read / /api/tags/flac/read — read metadata.
POST /api/audio/convert-format — MP3 ↔ FLAC ↔ WAV ↔ OGG.
POST /api/translate/lyrics — translate lyrics with timing preserved.
POST /api/romanize/cjk — romanize CJK text (pinyin/romaji/RR).
POST /api/audio/separate-stems — vocal/instrumental separation (2/4/6 stems).
GET /api/api-keys/usage — your API usage statistics.
POST /api/api-keys/webhooks/test — test webhook delivery.
Collecting a transcription
GET /api/v1/jobs/{job_id}/result is lyrics-shaped — LRC text, lines and words. A /api/v1/transcribe job produces subtitle files instead, which that frozen contract has no field for, so it answers 200 with empty strings. Collect a transcription from the internal endpoints instead, with the same key:
# Status + the formats that were produced + download links
curl "https://lrcsong.com/api/transcribe/jobs/$JOB" \
-H "Authorization: Bearer $LRCSONG_API_KEY"
# The subtitle file itself: format = srt | vtt | txt
curl "https://lrcsong.com/api/transcribe/download/$JOB?format=srt" \
-H "Authorization: Bearer $LRCSONG_API_KEY" -o captions.srt
Poll GET /api/v1/jobs/{job_id} as usual — only the collection step differs.