Skip to main content
For developers

Developer API

Automate LrcSong with HTTP — interactive docs, code samples, and webhooks.

Sign in to create API keys. API access is included with the Premium and Elite plans.

Sign in

Quickstart — audio file to synced lyrics

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.

  1. Create a key above (it is shown once — we store only a hash and cannot show it again).
  2. Submit the file. One credit is deducted immediately.
  3. 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:

curl -H "X-API-Key: lrc_xxxxxxxxxxxx" \
  https://lrcsong.com/api/v1/me
  • 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.

POST/api/v1/lrc1 credit · 10 / minute

Submit an AI lyrics-transcription job from an audio file or a YouTube URL. Returns immediately with a job id — poll GET /jobs/{job_id}.

Body: multipart/form-data

FieldTypeRequiredNotes
audiofilenoThe audio or video file. Required unless you pass youtube_url.
youtube_urlstringnoA YouTube watch URL. Required unless you pass audio. Sending neither is a 400.
languagestringno — default autoISO code (e.g. en, ja, hi) or auto to detect.

Response

{
  "job_id": "3f2a9c1e-5b47-4c2a-9a0d-1f6b8e2d7c41",
  "status": "pending"
}

Errors specific to this endpoint

  • 400 — neither audio nor youtube_url was sent, or the YouTube URL is malformed
  • 413 — file over the size cap, or audio longer than the duration cap
  • 409 — a request with the same Idempotency-Key is still in flight
POST/api/v1/transcribe2 credits · 10 / minute

Submit a speech-to-subtitles job. Produces SRT, VTT and TXT. Note the file field is called file here, not audio.

Body: multipart/form-data

FieldTypeRequiredNotes
filefileyesThe audio or video file to transcribe.
languagestringno — default autoISO code or auto.

Response

{
  "job_id": "8c7b6a54-3210-4fed-9876-5a4b3c2d1e0f",
  "status": "pending"
}

Errors specific to this endpoint

  • 413 — file over the size cap, or audio longer than the duration cap
GET/api/v1/jobs/{job_id}Free · 120 / minute

Poll a job. Terminal states are done and failed; anything else means keep polling.

Response

{
  "job_id": "3f2a9c1e-5b47-4c2a-9a0d-1f6b8e2d7c41",
  "status": "processing",
  "type": "ai_lrc",
  "progress": 62,
  "error": null,
  "created_at": "2026-09-20T09:12:03.481922+00:00"
}

Errors specific to this endpoint

  • 404 — no job with that id belongs to this key's owner
GET/api/v1/jobs/{job_id}/resultFree · 120 / minute

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/meFree

Introspect the calling key: which account it belongs to, the key's label, and the current credit balance.

Response

{
  "user_id": "a31f0c7e-9d2b-4a86-b0f1-6e5d4c3b2a19",
  "email": "dev@example.com",
  "credits": 128,
  "key_name": "production"
}
POST/api/v1/studio/videosFree · 10 / minute

Create an AI Video Studio project from a script. Free — this transcribes and storyboards only. Poll until status is storyboard, then assemble.

Body: application/json

FieldTypeRequiredNotes
scriptstringyes1–50,000 characters of narration script.
aspectstringno — default 16:916:9 or 9:16. Anything else is a 422.
titlestringno — default Untitled videoUp to 200 characters.

Response

{
  "project_id": "vp_b4b5f0effa10447d8a0c1495e934af7a",
  "status": "processing",
  "title": "Launch announcement",
  "ready": false
}

Errors specific to this endpoint

  • 422 — aspect is not 16:9 or 9:16, or script is empty / over 50,000 characters
POST/api/v1/studio/videos/{project_id}/assemble5 credits · 10 / minute

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.

Response

{
  "job_id": "5d4c3b2a-1908-4765-8f3e-2d1c0b9a8e7f",
  "status": "pending"
}

Errors specific to this endpoint

  • 409 — the storyboard is not ready yet, or an assemble is already running for this project
  • 402 — not enough credits
GET/api/v1/studio/videos/{project_id}Free

Project status. ready:true means the finished MP4 can be downloaded.

Response

{
  "project_id": "vp_b4b5f0effa10447d8a0c1495e934af7a",
  "status": "ready",
  "title": "Launch announcement",
  "ready": true
}

Errors specific to this endpoint

  • 404 — no project with that id belongs to this key's owner
GET/api/v1/studio/videos/{project_id}/downloadFree

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.
  • failed (terminal) — Failed. Credits were refunded automatically; error says why.

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.

  1. POST /api/v1/studio/videos — free. Creates the project and starts ingest (narration + storyboard).
  2. GET /api/v1/studio/videos/{project_id} — poll until status is storyboard.
  3. POST /api/v1/studio/videos/{project_id}/assemble — the paid step. Poll the returned job id for render progress, or the project for readiness.
  4. 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.

CodeMeaning
400The request is missing a required combination of fields (e.g. neither audio nor youtube_url).
401No X-API-Key header, or the key is unknown or revoked.
402Not enough credits for this operation. Nothing was queued and nothing was charged.
404The job or project does not exist, or does not belong to this key's owner. The two are deliberately indistinguishable.
409The 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.
413The upload is over the size cap, or the audio is longer than the duration cap.
422A field failed validation. detail is an ARRAY of per-field objects, not a string.
429Either 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 / 503An 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:

{
  "detail": {
    "error": "too_many_active_jobs",
    "message": "You already have 3 jobs running.",
    "active_job_ids": ["3f2a9c1e-…", "8c7b6a54-…", "5d4c3b2a-…"],
    "limit": 3,
    "can_cancel": true
  }
}

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.

Delivery payload

{
  "event": "job.completed",
  "job_id": "3f2a9c1e-5b47-4c2a-9a0d-1f6b8e2d7c41",
  "job_type": "ai_lrc",
  "status": "done",
  "credits_used": 1,
  "result_url": "https://lrcsong.com/api/v1/jobs/3f2a9c1e-…/result",
  "timestamp": "2026-09-20T09:13:41.004512+00:00"
}

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.