> For clean Markdown of any page, append `.md` to the page URL.
> For a complete documentation index, see https://docs.sarvam.ai/llms.txt.
> For full documentation content in one file, see https://docs.sarvam.ai/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.sarvam.ai/_mcp/server.

# Job Lifecycle

> Every state a Sarvam AI dubbing job passes through, how to read live-status and export-status correctly, and a complete polling script that handles partial failures.

Dubbing runs asynchronously: you start a job, then follow it until the files are ready to download. This page covers every state a job passes through and the two status endpoints you read along the way.

If you just want the five calls in order, start with the [Dubbing Overview](/api/api-guides-tutorials/dubbing/overview).

## Job statuses

| Status            | Meaning                                                                                          | Terminal |
| ----------------- | ------------------------------------------------------------------------------------------------ | -------- |
| `not_started`     | Created, but `start` has not been called. This is the status immediately after `create()`.       | No       |
| `queued`          | `start` accepted; waiting for a worker.                                                          | No       |
| `in_progress`     | The job is being processed.                                                                      | No       |
| `completed`       | Every target language finished processing.                                                       | Yes      |
| `partial_failure` | Some target languages succeeded, others failed. **Real outputs exist** for the ones that worked. | Yes      |
| `failed`          | The job could not be completed. Read `error_message`.                                            | Yes      |
| `deleted`         | The job was deleted.                                                                             | Yes      |

`partial_failure` is uncommon, but treat it as terminal alongside `completed`. It carries real output for the languages that succeeded, so code that waits only for `completed` will poll until its own timeout and leave those files uncollected.

## How a job moves

#### create() → not\_started

The job row exists and you hold a `job_id` plus a signed `upload_url`. Nothing is processing yet.

#### start() → queued

Starting is idempotent in practice: calling it on a job that is already `queued`, `in_progress`, or `completed` returns without re-queueing.

#### Processing → in\_progress

The job flips to `in_progress` once processing begins, and stays there for the bulk of the run.

#### Finished → completed, partial\_failure, or failed

Exports are then triggered automatically for the formats in `export_options`.

`completed` means the dub itself finished. The exported files may still be rendering, and `progress` can sit just short of 100 while they do, so confirm in `export-status` before downloading.

## Reading `live-status`

Alongside `status`, this endpoint reports a `progress` percentage and a human-readable `current_step_label` you can surface directly in a UI.

```json
{
  "data": {
    "job_id": "3f9a...",
    "job_name": "keynote.mp4",
    "status": "in_progress",
    "current_step": "translate",
    "current_step_label": "Translating",
    "progress": 50,
    "export": null,
    "exports": null,
    "error_message": null
  }
}
```

**`export` and `exports` swap depending on language count.** A job with one target language populates `export` (an object) and leaves `exports` `null`; a job with two or more does the reverse. An integration written against a single-language job therefore reads `null` the first time someone asks for two, so handle both keys.

Both are `null` until the job reaches `completed`. Note also that this block only ever carries a `dubbed_video_url` — there are no audio or SRT links here, which is why `export-status` is the endpoint to use for downloads.

## Reading `export-status`

`export-status` is the authoritative source for downloads. It returns a **flat array with one entry per (language, format)**, so a two-language, three-format job returns six entries in a single poll.

```json
{
  "data": {
    "exports": [
      { "id": "a1...", "target_language": "hi-IN", "export_type": "video", "status": "completed", "is_stale": false, "created_at": "...", "completed_at": "...", "download_url": "https://..." },
      { "id": "b2...", "target_language": "hi-IN", "export_type": "srt", "status": "completed", "is_stale": false, "created_at": "...", "completed_at": "...", "download_url": "https://..." },
      { "id": "c3...", "target_language": "ta-IN", "export_type": "video", "status": "in_progress", "is_stale": false, "created_at": "...", "completed_at": null }
    ]
  }
}
```

Group by `target_language` on your side, and take only entries whose own `status` is `completed`.

`download_url` is **absent** rather than `null` until an export completes, as in the third entry above. If you are reading the raw JSON, access it defensively; the Python SDK exposes it as an optional field, so it reads as `None`.

**Set `limit` deliberately.** It defaults to `5`, so a job with 2 languages × 3 formats returns only the first five of its six entries, with no indication that more exist. Pass a value comfortably above `languages × formats` (maximum `100`).

Each entry also carries `is_stale`, which becomes `true` when translation chunks for that language were edited after the export finished — the file you would download no longer matches the latest text. Re-export before publishing. Download URLs are signed and expire in roughly 24 hours, so persist the `job_id` and re-poll for a fresh link rather than caching the URL.

## A complete run

Here is one job carried from an empty script to downloaded files, a block at a time. Paste the blocks into a single file in order and you have a working program; each one explains what it does and why it is needed.

#### Install the SDK and set your key

```bash
pip install sarvamai
export SARVAM_API_KEY="your-key-here"
```

Reading the key from an environment variable keeps it out of your source code, so you can share the script without sharing your credentials. Generate a key at [Key Management](https://dashboard.sarvam.ai/key-management).

#### Set up the client and your inputs

```python
import os, time
from pathlib import Path
from sarvamai import SarvamAI

client = SarvamAI(api_subscription_key=os.environ["SARVAM_API_KEY"])

media = Path("sample.mp4")
target_language_codes = ["hi-IN", "ta-IN"]
export_options = ["video", "srt"]
```

`client` is the object every call goes through. The three values below it are the only things you would normally change: the file to dub, the languages to dub it into, and the formats you want back. Naming them once here matters, because later steps reuse them to work out exactly what to wait for.

#### Create the job

```python
created = client.dubbing.create(
    source_language_code="en-IN",
    target_language_codes=target_language_codes,
    export_options=export_options,
    voice_cloning=True,
    num_speakers=1,
    job_name=media.name,
)
job_id = created.data.job_id
```

This registers the job and reserves somewhere to put your file. Nothing is processing yet and the file has not been sent. What comes back is a `job_id`, which identifies this job in every later call, and an `upload_url`, which is a temporary link to upload the file to.

`voice_cloning=True` keeps each original speaker's voice, and `num_speakers=1` says the source has one person talking. Both are covered in [Choose a Voice](/api/api-guides-tutorials/dubbing/how-to/choose-a-voice).

#### Upload the media, then start the job

```python
client.dubbing.upload(created.data.upload_url, media)
client.dubbing.start(job_id=job_id)
```

Uploading sends the file straight to storage rather than through the API, which is why it is a separate call from `create`. Starting then tells the service the file is in place and work can begin.

Order matters: starting before the file is uploaded leaves the job sitting at `queued` with nothing to process.

#### Wait for the dub to finish

```python
TERMINAL = {"completed", "partial_failure", "failed", "deleted"}

deadline = time.monotonic() + 3600
while True:
    job = client.dubbing.get_live_status(job_id=job_id).data
    print(f"{job.progress:>3}%  {job.current_step_label}")

    if job.status in TERMINAL:
        break
    if time.monotonic() > deadline:
        raise TimeoutError(f"{job_id} did not finish in time")

    time.sleep(15)
```

Dubbing takes minutes rather than seconds, so instead of waiting on one long request you ask for the status every 15 seconds until the job stops changing.

The loop exits on any status in `TERMINAL`, not just `completed`. That matters for `partial_failure`, which means some languages finished and others did not — there are still real files to collect, so it counts as done. The `deadline` is a safety net so the loop cannot run forever.

#### Stop if the job failed outright

```python
if job.status == "failed":
    raise RuntimeError(job.error_message or "job failed")
```

`failed` is the only terminal status with nothing to collect. Everything else, `partial_failure` included, has output worth fetching, so the script continues.

#### Wait for the exported files

```python
wanted = {(lang, fmt) for lang in target_language_codes for fmt in export_options}

deadline = time.monotonic() + 3600
while True:
    exports = client.dubbing.get_export_status(job_id=job_id, limit=100).data.exports or []
    settled = {
        (e.target_language, e.export_type)
        for e in exports
        if e.status in {"completed", "failed"}
    }

    if wanted <= settled:
        break
    if time.monotonic() > deadline:
        raise TimeoutError(f"{job_id} exports did not settle in time")

    time.sleep(15)
```

A finished job does not yet mean finished files, because each output is rendered after the dub itself completes. This is a second wait, for the files rather than the dub.

`wanted` is every combination you asked for: two languages times two formats is four files. The loop keeps polling until each of those has settled, meaning it either completed or failed, so one slow file cannot make you miss the others. `limit=100` is deliberate — the default of `5` would not return every entry.

#### Collect the download links

```python
downloads = {
    (e.target_language, e.export_type): e.download_url
    for e in exports
    if e.status == "completed" and not e.is_stale
}

for (lang, fmt), url in sorted(downloads.items()):
    print(f"{lang} {fmt}: {url}")
```

This keeps only the files that actually completed, and skips any marked `is_stale`, which means the translation was edited after that file was made. The links are signed and valid for roughly 24 hours, so download them now or re-poll `export-status` later for fresh ones.

`client.dubbing.upload()` requires `sarvamai` 0.1.31a1 or newer.

Poll from a background worker rather than an HTTP request handler, and always cap total wait time as above. The API does not mandate an interval; the 15 seconds here is a starting point to tune against your own file lengths, not a recommended setting.

You do not need to add retry logic for server errors. The SDK already retries `429`, `408`, `409`, and `5xx` responses with exponential backoff, honouring `Retry-After`, twice by default. Raise it per call with `request_options={"max_retries": 5}`. A dropped connection is not retried, so wrap the calls in your own `try` / `except httpx.TransportError` if the script runs unattended.

#### The whole script in one piece

```python
import os, time
from pathlib import Path
from sarvamai import SarvamAI

client = SarvamAI(api_subscription_key=os.environ["SARVAM_API_KEY"])

media = Path("sample.mp4")
target_language_codes = ["hi-IN", "ta-IN"]
export_options = ["video", "srt"]

created = client.dubbing.create(
    source_language_code="en-IN",
    target_language_codes=target_language_codes,
    export_options=export_options,
    voice_cloning=True,
    num_speakers=1,
    job_name=media.name,
)
job_id = created.data.job_id

client.dubbing.upload(created.data.upload_url, media)
client.dubbing.start(job_id=job_id)

TERMINAL = {"completed", "partial_failure", "failed", "deleted"}

deadline = time.monotonic() + 3600
while True:
    job = client.dubbing.get_live_status(job_id=job_id).data
    print(f"{job.progress:>3}%  {job.current_step_label}")

    if job.status in TERMINAL:
        break
    if time.monotonic() > deadline:
        raise TimeoutError(f"{job_id} did not finish in time")

    time.sleep(15)

if job.status == "failed":
    raise RuntimeError(job.error_message or "job failed")

# partial_failure still has usable outputs, so keep going either way.
wanted = {(lang, fmt) for lang in target_language_codes for fmt in export_options}

deadline = time.monotonic() + 3600
while True:
    exports = client.dubbing.get_export_status(job_id=job_id, limit=100).data.exports or []
    settled = {
        (e.target_language, e.export_type)
        for e in exports
        if e.status in {"completed", "failed"}
    }

    if wanted <= settled:
        break
    if time.monotonic() > deadline:
        raise TimeoutError(f"{job_id} exports did not settle in time")

    time.sleep(15)

downloads = {
    (e.target_language, e.export_type): e.download_url
    for e in exports
    if e.status == "completed" and not e.is_stale
}

for (lang, fmt), url in sorted(downloads.items()):
    print(f"{lang} {fmt}: {url}")
```

## Troubleshooting

Almost everything below is a configuration detail rather than a dub that went wrong: a call that was never made, a URL that has aged out, or a parameter the API rejected up front.

| Symptom                                    | Cause                                                                                                                                                                           | Fix                                                                                                                                                          |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Stuck in `not_started`                     | `start` was never called.                                                                                                                                                       | Call `start` after uploading.                                                                                                                                |
| Stuck in `queued`                          | The media was never uploaded, so there is nothing to process.                                                                                                                   | Upload to `upload_url`, then start.                                                                                                                          |
| `403` on upload                            | The signed URL expired, or the request is missing `x-ms-blob-type: BlockBlob`.                                                                                                  | Check `expires_in_hours` and upload promptly; create a new job for a fresh URL. Never send your API key to the storage URL.                                  |
| `422` on create                            | An invalid language code (Odia is `or-IN`, not `od-IN`), the source language repeated in `target_langs`, an unknown `voice_id`, or `voice_cloning: false` without a `voice_id`. | See [Language Codes](/api/api-guides-tutorials/dubbing/how-to/specify-language-codes) and [Voices](/api/api-guides-tutorials/dubbing/how-to/choose-a-voice). |
| `export-status` empty on a `completed` job | `editor_flow: true` suppresses auto-export, leaving exports for a human to trigger in Creator Studio.                                                                           | Keep `editor_flow` at its default of `false` for API integrations.                                                                                           |
| Fewer exports than expected                | `limit` defaults to `5`.                                                                                                                                                        | Pass an explicit `limit` above `languages × formats`.                                                                                                        |

Log the `job_id` on every call. It is how a dub is traced end to end, and it is the first thing to include in a support request.

## Related

#### [Dubbing Overview](/api/api-guides-tutorials/dubbing/overview)

The five calls that make up an integration.

#### [Choose Export Formats](/api/api-guides-tutorials/dubbing/how-to/choose-export-formats)

Decide which formats each language produces.