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.
1. Create a key
- Log in, open Account and choose the API keys tab. (The tab appears when the API is switched on for your account's plan.)
- 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.
- Leave This key may start paid dubbing ticked, then press Create key.
- 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
| Step | What you do | What it costs |
|---|---|---|
| Upload | Create the job, then send the file in parts. | Free |
estimate | Ask for a quote and approve it. You then see the estimate for the whole job. | A small fixed fee, which counts toward the total |
analysis | Ask 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 |
| Review | On the website, read and correct the Arabic text and the speakers. (Editing through the API comes later.) | Free |
dub | Ask 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 |
| Download | When 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
- A quote is a promise, not a charge. Asking for a quote never costs credits. It lists the exact amount and what it is made of.
- You set a ceiling. To approve, you send the quote's number and a
max_creditsyou accept. If the live price is higher than your ceiling, the answer ismax_credits_exceeded; if the price changed in any other way (for example the text was edited), the answer isquote_changed. In both cases nothing is charged. Ask for a new quote and decide again. - Each step is paid once. Approving the same step again, with the same key or another, never charges twice. A quote lasts 10 minutes.
- Estimate versus exact price. The estimate of the whole job is a forecast. The price of the
dubstep is exact, because it is calculated from the text you reviewed. - Refunds follow the website. If a paid step fails on our side, the credits are returned in the same way as on the website. The job shows credits taken and returned.
- API prices are shown in each quote and can differ from the website's prices. The quote is always the price you pay.
5. Limits and safe retries
- Your account page shows the limits for your keys: requests per minute, how many dubs one key may run at once, and the daily cap. When you go over, the answer is
rate_limitedorconcurrency_limited; wait and try again. - Every request that changes something must carry an
Idempotency-Keyheader: 16 to 128 letters, digits,_or-. If a request fails with no clear answer (for example your connection drops), send the same request with the same key. You get the original answer back instead of a second action. - If a payment answer is lost, the call returns
service_unavailable. Ask for the job's status first, and retry the approval: the system makes sure the step is charged only once.
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:
| Call | Purpose | Needs |
|---|---|---|
GET /v1/account/balance | Your credits | account |
POST /v1/jobs | Create a long-dub job | dub |
GET /v1/jobs | List your API jobs (limit, cursor) | read |
POST /v1/jobs/{id}/source-language | Resume a paused analysis with the original language you choose; no extra charge | dub |
GET /v1/jobs/{id} | Status, progress, last payment, errors | read |
DELETE /v1/jobs/{id} | Delete a job and its files (not while it is processing) | dub |
GET /v1/jobs/{id}/upload | Part size and which parts arrived | read |
PUT /v1/jobs/{id}/upload/chunks/{index} | Send one part of the file (raw bytes, parts numbered from 0) | dub |
POST /v1/jobs/{id}/quotes | Get a quote for estimate, analysis or dub | dub |
POST /v1/jobs/{id}/upload/finish | Approve the estimate quote | dub |
GET /v1/jobs/{id}/estimate | The estimate of the whole job | read |
POST /v1/jobs/{id}/accept | Approve an analysis or dub quote | dub |
GET /v1/jobs/{id}/results | Which result files exist | read |
GET /v1/jobs/{id}/results/{kind} | Download mixed (the finished dub), dialogue or background | read |
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.
| Code | HTTP | Retry | Meaning |
|---|---|---|---|
invalid_request | 400 | no | Check the request fields and try again. |
invalid_key | 401 | no | Use an active API key in the Authorization header. |
scope_required | 403 | no | This key does not have permission for this action. |
plan_required | 403 | no | Your account plan does not include this action. |
consent_required | 403 | no | Accept the current terms and confirm your rights in your account. |
not_found | 404 | no | This resource could not be found. |
insufficient_credits | 402 | no | Your credit balance is too low for this action. |
idempotency_conflict | 409 | no | This request key was already used for different content. |
job_not_ready | 409 | no | This job is not ready for this action. Check its status. |
revision_conflict | 409 | no | This project changed. Reload it before editing. |
quote_changed | 409 | no | The price changed. Request a new quote before accepting. |
max_credits_exceeded | 409 | no | The price exceeds your credit limit. Nothing was charged. |
upload_incomplete | 409 | yes | Upload the missing chunks before finishing. |
result_expired | 410 | no | This result is no longer stored. Start a new job. |
upload_too_large | 413 | no | This file exceeds the upload limit. Use a smaller file. |
unsupported_media | 415 | no | This file could not be read. Use a supported audio or video file. |
rate_limited | 429 | yes | Too many requests. Wait before trying again. |
concurrency_limited | 429 | yes | Wait for an active job to finish before starting another. |
daily_cap_exceeded | 429 | yes | This key reached its daily credit limit. Check your account settings. |
storage_full | 409 | no | Your storage is full. Remove an old result before continuing. |
service_unavailable | 503 | yes | This action is temporarily unavailable. Retry with the same request key. |
internal_error | 500 | yes | This 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()