API guide

Dub long videos from your own software. Version 1, October 2026.

The API lets your software send a video or audio file to Lisan AI, approve the price, and download the Arabic dub, without using the website. It does the same work, with the same voices, quality and credits, as Dub Long Video on the website. Jobs you start through the API normally also appear in your projects on the website, where you can review and correct the Arabic text before the dubbing is paid for.

Before you start: you need an account on a plan that includes Dub Long Video, some credits (1 credit = $0.01), and Python 3 on your computer for the tool below. A 30-second test clip costs roughly 50 credits (about $0.50) from start to finish. This number is an estimate for a short clip with two speakers; your own quote shows the exact price before anything is charged.

1. Create a key

  1. Log in, open Account and choose the API keys tab. (The tab appears when the API is switched on for your account's plan.)
  2. Give the key a name, choose a daily credit cap, and tick the boxes accepting the API terms and confirming you have the right to dub the files you send.
  3. Leave This key may start paid dubbing ticked, then press Create key.
  4. Copy the key at once. It starts with lsn_live_ and is shown only once. We keep no copy, so a lost key cannot be recovered: revoke it and create a new one.

The daily cap is your safety net. If a key leaks, it can never spend more than this number of credits in a day. When you approve a step, the whole most that step can cost counts against the cap, even if less is finally charged. A key without the dubbing permission can only read job status and your balance.

Keep the key secret. Use it only from your own server or computer, never in a web page, a mobile app or a public repository. Anyone who has it can spend up to its daily cap.

2. Try it in five minutes

Copy the tool at the bottom of this page (or press Download there) and save it as api_try.py. In a Windows Command Prompt:

set LISAN_URL=https://lisanai.org
set LISAN_KEY=lsn_live_your_key_here
python api_try.py balance
python api_try.py start "C:\path\to\clip.mp4"

On macOS or Linux use export LISAN_URL=... and export LISAN_KEY=... instead of set.

balance costs nothing and proves that the address, the key and your plan work. start uploads the file (at least 20 seconds long) and shows the price of each paid step. Nothing is charged until you type yes. After the analysis is approved, check progress with python api_try.py status JOB_ID. When it says editing, open the project on the website, review the Arabic text, then run python api_try.py dub JOB_ID and later python api_try.py get JOB_ID to download the files.

3. How a long dub works

StepWhat you doWhat it costs
UploadCreate the job, then send the file in parts.Free
estimateAsk for a quote and approve it. You then see the estimate for the whole job.A small fixed fee, which counts toward the total
analysisAsk for a quote and approve it. We listen, find the speakers and translate. The job ends in the state editing.Analysis and processing fees, quoted in advance
ReviewOn the website, read and correct the Arabic text and the speakers. (Editing through the API comes later.)Free
dubAsk for a quote and approve it. The exact price is fixed by the reviewed text.Voices, voice clones and assembly, plus an optional music repair, charged only for repairs that really happen
DownloadWhen the job is done, download the finished file and, if you asked for them, the separate voice and music tracks.Free

4. Prices and your money

5. Limits and safe retries

6. Calling the API directly

Base address: https://lisanai.org/v1. Every call needs the header Authorization: Bearer YOUR_KEY. Bodies are JSON, answers are JSON. Example: start a job.

curl -X POST https://lisanai.org/v1/jobs \
  -H "Authorization: Bearer $LISAN_KEY" \
  -H "Idempotency-Key: my-first-try-0001" \
  -H "Content-Type: application/json" \
  -d @job.json

where job.json contains:

{"kind": "long", "filename": "clip.mp4", "size_bytes": 1234567, "rights_confirmed": true, "terms_version": "2026-10-1"}

(On Windows Command Prompt use curl.exe, %LISAN_KEY% and write the command on one line.) The reply is the new job, including its id. The full list of calls available today:

CallPurposeNeeds
GET /v1/account/balanceYour creditsaccount
POST /v1/jobsCreate a long-dub jobdub
GET /v1/jobsList your API jobs (limit, cursor)read
POST /v1/jobs/{id}/source-languageResume a paused analysis with the original language you choose; no extra chargedub
GET /v1/jobs/{id}Status, progress, last payment, errorsread
DELETE /v1/jobs/{id}Delete a job and its files (not while it is processing)dub
GET /v1/jobs/{id}/uploadPart size and which parts arrivedread
PUT /v1/jobs/{id}/upload/chunks/{index}Send one part of the file (raw bytes, parts numbered from 0)dub
POST /v1/jobs/{id}/quotesGet a quote for estimate, analysis or dubdub
POST /v1/jobs/{id}/upload/finishApprove the estimate quotedub
GET /v1/jobs/{id}/estimateThe estimate of the whole jobread
POST /v1/jobs/{id}/acceptApprove an analysis or dub quotedub
GET /v1/jobs/{id}/resultsWhich result files existread
GET /v1/jobs/{id}/results/{kind}Download mixed (the finished dub), dialogue or backgroundread

Original language

When creating a job, you may include source_language: en (English), es (Spanish), fr (French), pt (Portuguese), it (Italian), de (German), nl (Dutch) or pl (Polish). If omitted, it is English. The result is Arabic and the same prices apply. The example tool below starts English projects; use the request field for the other languages. Any subtitle you attach on the website should match the original language.

If polling returns status: "language_mismatch", the analysis is paused for your choice. The optional language_mismatch.detected_language is a suggested supported language; it can be null when the speech appears to be outside the supported list. After checking your file, send your choice to POST /v1/jobs/{id}/source-language with the usual authorization and a new Idempotency-Key. The body is, for example, {"source_language":"fr"}. Poll the same job afterward. This resumes its existing analysis and does not start another payment.

7. Errors

A failed call returns an HTTP status and a body like {"error":{"code":"quote_changed","message":"...","request_id":"req_..."}}. Use the code in your software and quote the request_id if you contact support. "Retry" means: send the same request again with the same Idempotency-Key.

CodeHTTPRetryMeaning
invalid_request400noCheck the request fields and try again.
invalid_key401noUse an active API key in the Authorization header.
scope_required403noThis key does not have permission for this action.
plan_required403noYour account plan does not include this action.
consent_required403noAccept the current terms and confirm your rights in your account.
not_found404noThis resource could not be found.
insufficient_credits402noYour credit balance is too low for this action.
idempotency_conflict409noThis request key was already used for different content.
job_not_ready409noThis job is not ready for this action. Check its status.
revision_conflict409noThis project changed. Reload it before editing.
quote_changed409noThe price changed. Request a new quote before accepting.
max_credits_exceeded409noThe price exceeds your credit limit. Nothing was charged.
upload_incomplete409yesUpload the missing chunks before finishing.
result_expired410noThis result is no longer stored. Start a new job.
upload_too_large413noThis file exceeds the upload limit. Use a smaller file.
unsupported_media415noThis file could not be read. Use a supported audio or video file.
rate_limited429yesToo many requests. Wait before trying again.
concurrency_limited429yesWait for an active job to finish before starting another.
daily_cap_exceeded429yesThis key reached its daily credit limit. Check your account settings.
storage_full409noYour storage is full. Remove an old result before continuing.
service_unavailable503yesThis action is temporarily unavailable. Retry with the same request key.
internal_error500yesThis action could not be completed. Check the job before retrying.

8. Not available yet

Short dubs (under 20 seconds), editing the text and speakers through the API, voice libraries, subtitles, usage reports and webhooks. Today: use the website for the review step, and ask for progress by reading the job status.

9. The test tool

This is the whole api_try.py. It uses only Python's standard library and sends your key only to the address you set in LISAN_URL. Read it before you run it; it is a good starting point for your own software.

"""Try the Lisan AI public API by hand, step by step (long dubbing). Needs only Python 3, nothing to install.

Setup (Windows Command Prompt), once per window:
    set LISAN_URL=https://YOUR-SITE-ADDRESS
    set LISAN_KEY=lsn_live_...your key...

Commands:
    python api_try.py balance
    python api_try.py start  "C:\\path\\to\\clip.mp4"     upload, get the estimate, ask before each charge
    python api_try.py status JOB_ID
    python api_try.py dub    JOB_ID                      after you reviewed the text on the website
    python api_try.py get    JOB_ID                      download the finished files into this folder
    python api_try.py list

Every step that costs credits shows you the price and waits for you to type yes. Nothing is charged before that.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid

TERMS = "2026-10-1"          # version of the API terms the server expects (shown in Account > API keys)
BASE = os.environ.get("LISAN_URL", "").rstrip("/")
KEY = os.environ.get("LISAN_KEY", "")


def die(msg):
    print(msg)
    sys.exit(1)


def call(method, path, body=None, raw=None, headers=None, expect_json=True):
    h = {"Authorization": "Bearer " + KEY, "Accept": "application/json", "User-Agent": "lisan-api-try/1"}
    data = None
    if method in ("POST", "PUT", "DELETE"):
        h["Idempotency-Key"] = "try-" + uuid.uuid4().hex
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        h["Content-Type"] = "application/json"
    if raw is not None:
        data = raw
        h["Content-Type"] = "application/octet-stream"
    h.update(headers or {})
    req = urllib.request.Request(BASE + "/v1" + path, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            payload = r.read()
            return r.status, (json.loads(payload) if expect_json and payload else payload)
    except urllib.error.HTTPError as ex:
        payload = ex.read()
        try:
            return ex.code, json.loads(payload)
        except Exception:
            return ex.code, {"error": {"code": "unreadable", "message": payload[:200].decode("utf-8", "replace")}}
    except Exception as ex:
        die("Could not reach the server (%s). Check LISAN_URL and your internet." % type(ex).__name__)


def need(status, data, what):
    if 200 <= status < 300:
        return data
    err = (data or {}).get("error", {}) if isinstance(data, dict) else {}
    die("%s failed (%s): %s - %s" % (what, status, err.get("code", "?"), err.get("message", "")))


def yes(question):
    return input(question + " Type yes to continue: ").strip().lower() in ("yes", "y")


def pay(job_id, step, extra=None):
    """Quote a step, show the price, ask, then approve with the price as the ceiling."""
    q = need(*call("POST", "/jobs/%s/quotes" % job_id, dict({"step": step}, **(extra or {}))), "Quote for " + step)
    print("\n--- %s ---" % step.upper())
    print(q["notice"])
    for item in q["breakdown"]:
        print("   %-18s %5d credits" % (item["code"], item["credits"]))
    print("   You will be charged: %d credits (%.2f USD). Of that, up to %d is the optional music repair, charged only if it happens." % (
        q["quoted_credits"], q["quoted_credits"] / 100.0, q["additional_max_credits"]))
    print("   This quote is valid until", q["expires_at"])
    if q["quoted_credits"] == 0:
        print("   Nothing to pay for this step.")
    if not yes("Approve this step?"):
        die("Stopped. Nothing was charged for this step.")
    body = {"quote_id": q["id"], "quoted_credits": q["quoted_credits"], "max_credits": q["quoted_credits"], "terms_version": q["terms_version"]}
    path = "/jobs/%s/upload/finish" % job_id if step == "estimate" else "/jobs/%s/accept" % job_id
    job = need(*call("POST", path, body), "Approval of " + step)
    op = job.get("current_operation") or {}
    print("   Approved. Status: %s / %s. Credits taken so far for this step: %s" % (job["status"], job["stage"], op.get("credits_debited")))
    return job


def show_job(j):
    print("Job %s: %s (%s), %s%%, revision %s" % (j["id"], j["status"], j["stage"], j["percent"], j["revision"]))
    if j.get("duration_seconds"):
        print("   length: %.0f seconds" % j["duration_seconds"])
    op = j.get("current_operation")
    if op:
        print("   last paid step: %s, %s, taken %s, returned %s credits" % (op["step"], op["state"], op["credits_debited"], op["credits_refunded"]))
    if j.get("error"):
        print("   error:", j["error"].get("code"), "-", j["error"].get("message"))
    print("   result ready:", j["result_available"])


def cmd_balance():
    print(need(*call("GET", "/account/balance"), "Balance"))


def cmd_list():
    for j in need(*call("GET", "/jobs?limit=20"), "List")["jobs"]:
        show_job(j)


def cmd_status(job_id):
    show_job(need(*call("GET", "/jobs/" + job_id), "Status"))


def cmd_start(path):
    if not os.path.isfile(path):
        die("File not found: " + path)
    size = os.path.getsize(path)
    print("File: %s (%.1f MB). It must be at least 20 seconds long." % (os.path.basename(path), size / 1048576.0))
    if not yes("Do you confirm you have the right to dub this file, and accept the API terms shown in your account?"):
        die("Stopped.")
    job = need(*call("POST", "/jobs", {"kind": "long", "filename": os.path.basename(path), "size_bytes": size,
                                        "rights_confirmed": True, "terms_version": TERMS}), "Create job")
    jid = job["id"]
    print("Job created:", jid, "(nothing charged yet)")
    up = need(*call("GET", "/jobs/%s/upload" % jid), "Upload info")
    with open(path, "rb") as f:
        for i in range(up["total_chunks"]):
            chunk = f.read(up["chunk_bytes"])
            need(*call("PUT", "/jobs/%s/upload/chunks/%d" % (jid, i), raw=chunk), "Upload part %d" % i)
            print("   uploaded part %d of %d" % (i + 1, up["total_chunks"]))
    pay(jid, "estimate")
    est = need(*call("GET", "/jobs/%s/estimate" % jid), "Estimate")
    print("\nEstimate for the WHOLE job: about %d credits (%.2f USD); already paid %d. %s" % (
        est["estimated_total_credits"], est["estimated_total_credits"] / 100.0, est["already_paid_credits"], est["notice"]))
    pay(jid, "analysis")
    print("\nThe server now analyses the file (listening, speakers, translation). Check progress with:")
    print("    python api_try.py status " + jid)
    print("When the status says 'editing', open the project on the website, review and correct the text, then run:")
    print("    python api_try.py dub " + jid)


def cmd_dub(job_id):
    j = need(*call("GET", "/jobs/" + job_id), "Status")
    if j["status"] != "editing":
        die("The job is '%s', not 'editing'. Wait until the analysis is finished (python api_try.py status %s)." % (j["status"], job_id))
    keep = yes("Keep the original music under the speech (a repair fee may apply, shown next)?")
    tracks = yes("Also keep the dubbed voices and the music as separate files?")
    pay(job_id, "dub", {"keep_music": keep, "tracks": tracks})
    print("\nDubbing started. Check with: python api_try.py status " + job_id)


def cmd_get(job_id):
    res = need(*call("GET", "/jobs/%s/results" % job_id), "Results")["results"]
    if not res:
        die("No result yet.")
    for r in res:
        status, data = call("GET", "/jobs/%s/results/%s" % (job_id, r["kind"]), expect_json=False)
        if status != 200:
            print("Could not download", r["kind"], status)
            continue
        name = "%s_%s%s" % (job_id[:8], r["kind"], ".mp4" if r["media_type"] == "video/mp4" else ".m4a" if r["media_type"] == "audio/mp4" else ".mp3")
        with open(name, "wb") as f:
            f.write(data)
        print("Saved", name, "(%d bytes)" % len(data))


def main():
    if not BASE.startswith("http") or not KEY.startswith("lsn_live_"):
        die("Set LISAN_URL and LISAN_KEY first (see the top of this file).")
    a = sys.argv[1:]
    if not a:
        die(__doc__)
    cmd = a[0]
    if cmd == "balance":
        cmd_balance()
    elif cmd == "list":
        cmd_list()
    elif cmd in ("status", "dub", "get", "start") and len(a) == 2:
        {"status": cmd_status, "dub": cmd_dub, "get": cmd_get, "start": cmd_start}[cmd](a[1])
    else:
        die(__doc__)


if __name__ == "__main__":
    main()