> ## Documentation Index
> Fetch the complete documentation index at: https://docs.api.rask.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart: dub a video from upload to download

> Get a Rask API key, make your first request, dub a video from upload to download, and run dub, lip-sync and on-screen in one request.

## Before you start

* **A plan with the API.** The [pricing page](https://rask.ai/pricing) shows which plans include it, in the row **API and
  integrations**. The free trial does not include it and cannot create a key; a paid trial can.
* **Minutes.** Paid work is charged in minutes from the workspace owner's balance, and every dub costs at least 1 minute. A
  new project also takes one project credit, unless the owner's project credits are unlimited. The owner buys minutes in the
  web app, at the `manage_url` of [`GET /plan`](https://docs.api.rask.ai/operations/getPlan).
* **A short video.** There is no sandbox, so this guide dubs for real, and a 10-second clip keeps the first run cheap. To try
  the API without spending, use a **Full access** key with spending off. Its estimates are free and show the price, with
  `eligible: false` and the reason `insufficient_scope`, because the key cannot start paid work.
* **For the shell:** curl, jq and uuidgen.

Once you have a key, `GET /plan` and `GET /balance` show whether you are ready, as step 2 shows.

## 1. Get a key

Open **Settings -> API keys** in the [web app](https://app.rask.ai/account/api-keys) and create a key. Choose **Full access**
and turn on **Allow spending credits**, because this guide dubs for real. A key that can spend must expire, in 1 year at most,
and the form starts it with monthly limits of 120 minutes and 10 projects; change them if you need more. The key is shown
once, so copy it now.

```bash theme={null}
export RASK_API_KEY="paste your key here"
```

[Authentication](https://docs.api.rask.ai/authentication.md) explains scopes, caps and every 401 and 403.

## 2. Make one request

The examples show each call in a tab for cURL and a tab for each official SDK. An SDK tab shows only the call, so
[install the SDK and make a client](https://docs.api.rask.ai/v3/sdks.md#install) first.

```typescript TypeScript theme={null}
const { data: me } = await rask.account.getMe();
```

```python Python theme={null}
me = client.account.get_me()
```

```go Go theme={null}
me, err := client.Account.GetMe(ctx)
if err != nil {
	return err
}
```

```java Java theme={null}
Me me = rask.account().getMe().data();
```

```php PHP theme={null}
$me = $rask->account->getMe();
```

```bash cURL theme={null}
curl https://api.rask.ai/v3/me \
  -H "Authorization: Bearer $RASK_API_KEY"
```

The answer says who is calling: the [acting user](https://docs.api.rask.ai/v3/concepts/glossary.md), the workspace the key is bound to, the
[payer](https://docs.api.rask.ai/v3/concepts/glossary.md), the key's scopes and caps, and the plan.

```json theme={null}
{
  "object": "me",
  "workspace": { "id": "b2e0c6d4-5b1a-4f7e-9a3c-2d8f1e6a7b90", "name": "Acme Marketing", "role": "owner", "is_default": true },
  "payer": { "user_id": "0d9a4e1c-7f2b-4c8d-a1e5-3b6f9c2d8e47", "is_you": true },
  "plan": { "name": "Business", "family": "business", "active": true }
}
```

The real answer has more fields. A 401 here means the key is wrong: the `code` and `hint` in the body say how.

Two more free reads say whether you are ready to dub. [`GET /plan`](https://docs.api.rask.ai/operations/getPlan) lists the plan's features in
`entitlements`, and this guide needs the entry for `auto_dub` to have `enabled: true`.
[`GET /balance`](https://docs.api.rask.ai/operations/getBalance) holds the minutes left, in `minutes.remaining_minutes`, and the project credits
left, in `projects.remaining_projects`, which is null when they are unlimited.

```typescript TypeScript theme={null}
const { data: plan } = await rask.account.getPlan();

const { data: balance } = await rask.account.getBalance();
```

```python Python theme={null}
plan = client.account.get_plan()

balance = client.account.get_balance()
```

```go Go theme={null}
plan, err := client.Account.GetPlan(ctx)
if err != nil {
	return err
}

balance, err := client.Account.GetBalance(ctx)
if err != nil {
	return err
}
```

```java Java theme={null}
Plan plan = rask.account().getPlan().data();

Balance balance = rask.account().getBalance().data();
```

```php PHP theme={null}
$plan = $rask->account->getPlan();

$balance = $rask->account->getBalance();
```

```bash cURL theme={null}
curl https://api.rask.ai/v3/plan \
  -H "Authorization: Bearer $RASK_API_KEY"
curl https://api.rask.ai/v3/balance \
  -H "Authorization: Bearer $RASK_API_KEY"
```

## 3. Dub a video

Each call that starts work needs an `Idempotency-Key`, one per piece of work. Save it before you send, and send the same key on
every retry, after a timeout or a restart too: a request that timed out may still have run, and a new key is new work, charged
again.

The steps below are the calls one by one. With an SDK, the upload is one call and so is each wait:
[Upload, wait and handle errors](https://docs.api.rask.ai/v3/sdks.md#upload-wait-and-handle-errors) has them in every language. In a shell,
[dub\_video.sh](#the-same-in-a-shell) runs the steps in order and passes each id to the next.

**1. Ask for an upload**

Send the file's name, type and exact size.

```typescript TypeScript theme={null}
const { data: upload } = await rask.uploads.createUpload({
  body: { filename: 'q3-webinar-teaser.mp4', content_type: 'video/mp4', size_bytes: 18874368 },
});
```

```python Python theme={null}
upload = client.uploads.create_upload(filename="q3-webinar-teaser.mp4", content_type="video/mp4", size_bytes=18874368)
```

```go Go theme={null}
upload, err := client.Uploads.CreateUpload(ctx, api.CreateUploadRequest{
	Filename:    "q3-webinar-teaser.mp4",
	ContentType: api.UploadContentTypeVideomp4,
	SizeBytes:   18874368,
})
if err != nil {
	return err
}
```

```java Java theme={null}
Upload upload = rask.uploads().createUpload(CreateUploadRequest.builder()
        .filename("q3-webinar-teaser.mp4")
        .contentType(UploadContentType.VIDEO_MP4)
        .sizeBytes(18874368)
        .build()).data();
```

```php PHP theme={null}
$upload = $rask->uploads->createUpload([
    'filename' => 'q3-webinar-teaser.mp4',
    'content_type' => 'video/mp4',
    'size_bytes' => 18874368,
]);
```

```bash cURL theme={null}
curl -X POST https://api.rask.ai/v3/uploads \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename": "q3-webinar-teaser.mp4", "content_type": "video/mp4", "size_bytes": 18874368}'
```

The answer holds the upload's `id`, which the next steps call `UPLOAD_ID`, and in `transfer` one presigned `url`,
`UPLOAD_URL`, with the `headers` it needs.

**2. Send the bytes**

The bytes go straight to storage. Send exactly the listed headers and no `Authorization` header.

```bash theme={null}
curl -fsS -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  -H "Content-Length: 18874368" \
  --upload-file q3-webinar-teaser.mp4
```

**3. Complete the upload**

```typescript TypeScript theme={null}
const { data: job } = await rask.uploads.completeUpload({ path: { upload_id: uploadId } });
```

```python Python theme={null}
job = client.uploads.complete_upload(upload_id)
```

```go Go theme={null}
job, err := client.Uploads.CompleteUpload(ctx, uploadID, nil)
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.uploads().completeUpload(uploadId).data();
```

```php PHP theme={null}
$job = $rask->uploads->completeUpload($uploadId);
```

```bash cURL theme={null}
curl -X POST "https://api.rask.ai/v3/uploads/$UPLOAD_ID/complete" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

This starts a `media_import` job, whose `id` the next step calls `JOB_ID`. Wait for it, and keep `result.media_id` from
the finished job.

> [!TIP]
>
> A video that is already online needs no upload. [importMedia](https://docs.api.rask.ai/operations/importMedia.md) takes a public URL from YouTube,
> Google Drive, Vimeo or an allow-listed host, and starts the same `media_import` job.

**4. Wait for a job**

Repeat until `status` is `succeeded`, `failed` or `canceled`. The request returns as soon as the job changes, or after 20
seconds. Do not ask faster than the `Retry-After` header says.

```typescript TypeScript theme={null}
const { data: job } = await rask.jobs.getJob({ path: { job_id: jobId }, query: { wait_seconds: 20 } });
```

```python Python theme={null}
job = client.jobs.get_job(job_id, wait_seconds=20)
```

```go Go theme={null}
job, err := client.Jobs.GetJob(ctx, jobID, &api.GetJobParams{WaitSeconds: rask.Ptr(int32(20))})
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.jobs().getJob(jobId, GetJobParams.builder().waitSeconds(20).build()).data();
```

```php PHP theme={null}
$job = $rask->jobs->getJob($jobId, ['wait_seconds' => 20]);
```

```bash cURL theme={null}
curl "https://api.rask.ai/v3/jobs/$JOB_ID?wait_seconds=20" \
  -H "Authorization: Bearer $RASK_API_KEY"
```

**5. Estimate the project**

Pick the target language from `GET /languages?capability=translation`. The source language is detected, unless you send
`source_language`. `MEDIA_ID` stands for the `result.media_id` you kept. The estimate is free, and it runs every check
the real request runs.

```typescript TypeScript theme={null}
const { data: estimate } = await rask.projects.estimateProject({
  body: { media_id: 'MEDIA_ID', target_language: 'es-es' },
});
```

```python Python theme={null}
estimate = client.projects.estimate_project(media_id="MEDIA_ID", target_language="es-es")
```

```go Go theme={null}
estimate, err := client.Projects.EstimateProject(ctx, api.EstimateProjectRequest{
	MediaId:        "MEDIA_ID",
	TargetLanguage: "es-es",
})
if err != nil {
	return err
}
```

```java Java theme={null}
Estimate estimate = rask.projects().estimateProject(EstimateProjectRequest.builder()
        .mediaId("MEDIA_ID")
        .targetLanguage("es-es")
        .build()).data();
```

```php PHP theme={null}
$estimate = $rask->projects->estimateProject(['media_id' => 'MEDIA_ID', 'target_language' => 'es-es']);
```

```bash cURL theme={null}
curl -X POST https://api.rask.ai/v3/projects/estimate \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"media_id": "MEDIA_ID", "target_language": "es-es"}'
```

Continue when `eligible` is true. Otherwise `reasons` says why not. `total_minutes` is the price of the dub.

**6. Create the project**

Send `total_minutes` as `expected_cost_minutes`: the `1` below stands for it. It is a cap: a higher price is refused with
409 `cost_changed` and nothing is charged. Keep it as the most you accepted to spend on this video.

Save the key in `CREATE_KEY` and send the same one if you retry. A retry with a new key after a timeout can create a second
project with a second paid dub.

```typescript TypeScript theme={null}
const { data: job } = await rask.projects.createProject({
  body: { media_id: 'MEDIA_ID', target_language: 'es-es', expected_cost_minutes: 1 },
  idempotencyKey: createKey,
});
```

```python Python theme={null}
job = client.projects.create_project(
    media_id="MEDIA_ID", target_language="es-es", expected_cost_minutes=1, idempotency_key=create_key
)
```

```go Go theme={null}
job, err := client.Projects.CreateProject(ctx, api.CreateProjectRequest{
	MediaId:             "MEDIA_ID",
	TargetLanguage:      "es-es",
	ExpectedCostMinutes: rask.Ptr(int32(1)),
}, rask.WithIdempotencyKey(createKey))
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.projects().createProject(
        CreateProjectRequest.builder().mediaId("MEDIA_ID").targetLanguage("es-es").expectedCostMinutes(1).build(),
        RequestOptions.builder().idempotencyKey(createKey).build()).data();
```

```php PHP theme={null}
$job = $rask->projects->createProject(
    ['media_id' => 'MEDIA_ID', 'target_language' => 'es-es', 'expected_cost_minutes' => 1],
    ['idempotency_key' => $createKey],
);
```

```bash cURL theme={null}
CREATE_KEY=$(uuidgen)
curl -X POST https://api.rask.ai/v3/projects \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $CREATE_KEY" \
  -d '{"media_id": "MEDIA_ID", "target_language": "es-es", "expected_cost_minutes": 1}'
```

The answer is a `project_processing` job with the `project_id`, which the next steps call `PROJECT_ID`. Wait for the job
as before, with its `id` as `JOB_ID`. The project is dubbed automatically after translation.

**7. Wait for the dub**

Translation and the dub each typically take 5 minutes, and up to 30 minutes at the 95th percentile: see
[how long a job takes](https://docs.api.rask.ai/v3/concepts/jobs.md#how-long-a-job-takes).

The `project_processing` job ends when translation ends. Its `result.auto_dub` says what happened next. With
`state: dispatched`, the dub runs as a job of its own: wait for the job in `dub_job_id`, `DUB_JOB_ID` below.

```typescript TypeScript theme={null}
const { data: job } = await rask.jobs.getJob({ path: { job_id: dubJobId }, query: { wait_seconds: 20 } });
```

```python Python theme={null}
job = client.jobs.get_job(dub_job_id, wait_seconds=20)
```

```go Go theme={null}
job, err := client.Jobs.GetJob(ctx, dubJobID, &api.GetJobParams{WaitSeconds: rask.Ptr(int32(20))})
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.jobs().getJob(dubJobId, GetJobParams.builder().waitSeconds(20).build()).data();
```

```php PHP theme={null}
$job = $rask->jobs->getJob($dubJobId, ['wait_seconds' => 20]);
```

```bash cURL theme={null}
curl "https://api.rask.ai/v3/jobs/$DUB_JOB_ID?wait_seconds=20" \
  -H "Authorization: Bearer $RASK_API_KEY"
```

With `state: skipped`, the dub did not run and was not charged. `skipped_reason` is `insufficient_balance` or
`cost_changed` when the balance or the price changed after the request, and null when the dub could not start, for
example because the key that created the project was revoked. With `state: not_requested`, the request sent
`auto_dub: false`. In both cases, dub by hand. Estimate the dub first:

```typescript TypeScript theme={null}
const { data: estimate } = await rask.dubbing.estimateDub({ path: { project_id: projectId }, body: {} });
```

```python Python theme={null}
estimate = client.dubbing.estimate_dub(project_id)
```

```go Go theme={null}
estimate, err := client.Dubbing.EstimateDub(ctx, projectID, &api.EstimateDubRequest{})
if err != nil {
	return err
}
```

```java Java theme={null}
Estimate estimate = rask.dubbing().estimateDub(projectId, EstimateDubRequest.builder().build()).data();
```

```php PHP theme={null}
$estimate = $rask->dubbing->estimateDub($projectId, []);
```

```bash cURL theme={null}
curl -X POST "https://api.rask.ai/v3/projects/$PROJECT_ID/dub/estimate" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

If it is `eligible` and its `total_minutes` is within the cap you accepted, dub with that same cap, the `1` below, under a
key of its own. If the price is higher, stop and show the new price to whoever accepted the first one: spending more is
their decision.

```typescript TypeScript theme={null}
const { data: job } = await rask.dubbing.createDub({
  path: { project_id: projectId },
  body: { expected_cost_minutes: 1 },
  idempotencyKey: dubKey,
});
```

```python Python theme={null}
job = client.dubbing.create_dub(project_id, expected_cost_minutes=1, idempotency_key=dub_key)
```

```go Go theme={null}
job, err := client.Dubbing.CreateDub(ctx, projectID, api.CreateDubRequest{
	ExpectedCostMinutes: 1,
}, rask.WithIdempotencyKey(dubKey))
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.dubbing().createDub(
        projectId,
        CreateDubRequest.builder().expectedCostMinutes(1).build(),
        RequestOptions.builder().idempotencyKey(dubKey).build()).data();
```

```php PHP theme={null}
$job = $rask->dubbing->createDub($projectId, ['expected_cost_minutes' => 1], ['idempotency_key' => $dubKey]);
```

```bash cURL theme={null}
DUB_KEY=$(uuidgen)
curl -X POST "https://api.rask.ai/v3/projects/$PROJECT_ID/dub" \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $DUB_KEY" \
  -d '{"expected_cost_minutes": 1}'
```

Then wait for that `dub` job. A project created on a trial is dubbed only once, automatically: on it,
[createDub](https://docs.api.rask.ai/operations/createDub.md) answers 403 `trial_project_dub_not_allowed`.

**8. Download the result**

The project's outputs include the dubbed video, of `kind` `dubbed_video`.

```typescript TypeScript theme={null}
const { data: outputList } = await rask.exports.listProjectOutputs({ path: { project_id: projectId } });
```

```python Python theme={null}
output_list = client.exports.list_project_outputs(project_id)
```

```go Go theme={null}
outputList, err := client.Exports.ListProjectOutputs(ctx, projectID)
if err != nil {
	return err
}
```

```java Java theme={null}
OutputList outputList = rask.exports().listProjectOutputs(projectId).data();
```

```php PHP theme={null}
$outputList = $rask->exports->listProjectOutputs($projectId);
```

```bash cURL theme={null}
curl "https://api.rask.ai/v3/projects/$PROJECT_ID/outputs" \
  -H "Authorization: Bearer $RASK_API_KEY"
```

Its `url`, `VIDEO_URL` below, is presigned, so the download needs no key, and it works for 1 hour. Read the outputs again
for a fresh one. While `available` is false, `unavailable_reason` says why.

```bash theme={null}
curl -fsS -o dubbed.mp4 "$VIDEO_URL"
```

### The same in a shell

This script runs the steps above and passes each id to the next. It needs curl 7.76 or later, jq and uuidgen. It saves its
keys, the media and the price you accepted in a file next to the video, so after a timeout or a crash you run it again and it
continues instead of creating or charging anything twice. Run it again within 24 hours: the API keeps an `Idempotency-Key`
that long, and a later run with that file can create and charge a new project.

```bash dub_video.sh theme={null}
#!/usr/bin/env bash
# Upload a video, dub it into Spanish and download the result: bash dub_video.sh clip.mp4
#
# Progress is saved in clip.mp4.dub.env. If the script stops, run it again to continue; delete the file to dub anew.
set -euo pipefail

FILE=${1:?usage: bash dub_video.sh clip.mp4}
API=${RASK_BASE_URL:-https://api.rask.ai/v3}
STATE="$FILE.dub.env"
HEADERS=$(mktemp)
trap 'rm -f "$HEADERS"' EXIT
touch "$STATE"
. "$STATE"

# api METHOD PATH [CURL OPTION...]: one call to the API and its answer. An error answer goes to stderr, and the call fails.
api() {
  local answer
  if answer=$(curl -sS --fail-with-body -X "$1" "$API$2" -H "Authorization: Bearer $RASK_API_KEY" "${@:3}"); then
    printf '%s\n' "$answer"
  else
    printf '%s\n' "$answer" >&2
    return 1
  fi
}

# wait_for_job ID: long-poll a job until it ends, never faster than its Retry-After, and stop unless it succeeded. JOB is
# the last answer.
wait_for_job() {
  local started hint pause
  while true; do
    started=$SECONDS
    JOB=$(api GET "/jobs/$1?wait_seconds=20" -D "$HEADERS")
    case $(jq -r .status <<<"$JOB") in
      succeeded) return ;;
      failed | canceled) jq -c '{id, status, error}' <<<"$JOB" >&2; exit 1 ;;
    esac
    hint=$(awk -F': *' 'tolower($1) == "retry-after" { print $2 + 0 }' "$HEADERS")
    pause=$((${hint:-5} - (SECONDS - started)))
    if [ "$pause" -gt 0 ]; then sleep "$pause"; fi
  done
}

# Each paid request has a key of its own, saved before it is sent, so a run after a timeout or a crash sends the same one.
if [ -z "${CREATE_KEY:-}" ]; then
  printf 'CREATE_KEY=%s\nDUB_KEY=%s\n' "$(uuidgen)" "$(uuidgen)" >>"$STATE"
  . "$STATE"
fi

if [ -z "${MEDIA_ID:-}" ]; then
  BODY=$(jq -n --arg name "${FILE##*/}" --argjson size "$(wc -c <"$FILE")" \
    '{filename: $name, content_type: "video/mp4", size_bytes: $size}')
  UPLOAD=$(api POST /uploads -H "Content-Type: application/json" -d "$BODY")
  UPLOAD_ID=$(jq -er .id <<<"$UPLOAD")
  UPLOAD_URL=$(jq -er .transfer.url <<<"$UPLOAD")
  # The bytes go straight to storage, with exactly the listed headers and no Authorization header.
  jq -r '.transfer.headers | to_entries[] | "\(.key): \(.value)"' <<<"$UPLOAD" >"$HEADERS"
  curl -fsS -X PUT "$UPLOAD_URL" -H @"$HEADERS" --upload-file "$FILE"
  # Each run uploads anew, so completing this upload is new work with a key of its own.
  COMPLETE=$(api POST "/uploads/$UPLOAD_ID/complete" -H "Idempotency-Key: $(uuidgen)")
  JOB_ID=$(jq -er .id <<<"$COMPLETE")
  wait_for_job "$JOB_ID"
  MEDIA_ID=$(jq -er .result.media_id <<<"$JOB")
  echo "MEDIA_ID=$MEDIA_ID" >>"$STATE"
fi
ORDER=$(jq -n --arg media "$MEDIA_ID" '{media_id: $media, target_language: "es-es"}')

# The price shown once is the most this script spends: it is the cap of every paid request below.
if [ -z "${CAP:-}" ]; then
  ESTIMATE=$(api POST /projects/estimate -H "Content-Type: application/json" -d "$ORDER")
  if [ "$(jq -r .eligible <<<"$ESTIMATE")" != true ]; then
    echo "Cannot start: $(jq -c .reasons <<<"$ESTIMATE")" >&2
    exit 1
  fi
  CAP=$(jq -er .total_minutes <<<"$ESTIMATE")
  echo "The dub costs $CAP minutes"
  echo "CAP=$CAP" >>"$STATE"
fi

PROCESSING=$(api POST /projects -H "Content-Type: application/json" -H "Idempotency-Key: $CREATE_KEY" \
  -d "$(jq --argjson cap "$CAP" '. + {expected_cost_minutes: $cap}' <<<"$ORDER")")
PROJECT_ID=$(jq -er .project_id <<<"$PROCESSING")
JOB_ID=$(jq -er .id <<<"$PROCESSING")
wait_for_job "$JOB_ID"

if [ "$(jq -r .result.auto_dub.state <<<"$JOB")" = dispatched ]; then
  DUB_JOB_ID=$(jq -er .result.auto_dub.dub_job_id <<<"$JOB")
else
  # The auto-dub was skipped and not charged. Dub by hand, within the same cap.
  ESTIMATE=$(api POST "/projects/$PROJECT_ID/dub/estimate" -H "Content-Type: application/json" -d '{}')
  PRICE=$(jq -er .total_minutes <<<"$ESTIMATE")
  if [ "$PRICE" -gt "$CAP" ]; then
    echo "The dub now costs $PRICE minutes, more than the $CAP you accepted, and nothing was charged." \
      "Project $PROJECT_ID keeps its translation: dub it once you accept the new price." >&2
    exit 1
  fi
  DUB=$(api POST "/projects/$PROJECT_ID/dub" -H "Content-Type: application/json" -H "Idempotency-Key: $DUB_KEY" \
    -d "$(jq -n --argjson cap "$CAP" '{expected_cost_minutes: $cap}')")
  DUB_JOB_ID=$(jq -er .id <<<"$DUB")
fi
wait_for_job "$DUB_JOB_ID"

OUTPUTS=$(api GET "/projects/$PROJECT_ID/outputs")
VIDEO_URL=$(jq -er 'first(.data[] | select(.kind == "dubbed_video" and .available) | .url)' <<<"$OUTPUTS") || {
  echo "Project $PROJECT_ID has no dubbed video to download: unavailable_reason in its outputs says why." >&2
  exit 1
}
curl -fsS -o dubbed.mp4 "$VIDEO_URL"
echo "Saved dubbed.mp4"
```

### The same in Python

Save this client next to the programs. The recipes in these docs use it too.

```python rask_client.py theme={null}
"""A small client for the Rask API v3. Needs `pip install requests`."""
import json
import os
import time
import uuid

import requests

API = os.environ.get("RASK_BASE_URL", "https://api.rask.ai/v3")
KEY = os.environ["RASK_API_KEY"]
FINISHED = {"succeeded", "failed", "canceled"}


class RaskProblem(Exception):
    """An error answer of the API or a failed job. `problem` holds its members: `code`, `hint`, `retry`..."""

    def __init__(self, problem):
        super().__init__(f"{problem['code']}: {problem['hint'] or problem['detail']}")
        self.problem = problem


class Progress:
    """What a program did, saved in a JSON file: its Idempotency-Keys, the ids it got and the price it accepted.

    After a timeout or a crash, run the program again. It sends the same keys, so the API replays what already ran instead of
    running and charging it again, and refuses a request that changed meanwhile with idempotency_key_reused. The API keeps a
    key 24 hours, and a key replays its job even when the job failed, so delete the file to start new work.
    """

    def __init__(self, path):
        self.path = path
        try:
            with open(path) as file:
                self.saved = json.load(file)
        except FileNotFoundError:
            self.saved = {}

    def once(self, name, make):
        """The value saved under `name`. The first time, `make()` makes it, and it is saved before anything uses it."""
        if name not in self.saved:
            self.saved[name] = make()
            with open(self.path + ".tmp", "w") as file:
                json.dump(self.saved, file, indent=2)
            os.replace(self.path + ".tmp", self.path)
        return self.saved[name]

    def key(self, name):
        """The Idempotency-Key of one piece of work, such as "create project": the same on every attempt and every run."""
        return self.once(f"key: {name}", lambda: str(uuid.uuid4()))


def send(method, path, body=None, params=None, key=None, if_match=None):
    """Send a request and return the response.

    `key` is the Idempotency-Key. Every attempt sends the same one, so a request that timed out but ran is replayed, not run
    twice. After a network error or a gateway's 5xx, only a GET or a request with a key is sent again.
    """
    headers = {"Authorization": f"Bearer {KEY}"}
    if key:
        headers["Idempotency-Key"] = key
    if if_match:
        headers["If-Match"] = if_match
    repeatable = bool(key) or method == "GET"
    for attempt in range(5):
        last = attempt == 4
        try:
            response = requests.request(method, API + path, json=body, params=params, headers=headers, timeout=60)
        except (requests.ConnectionError, requests.Timeout):
            if last or not repeatable:
                raise
            time.sleep(2**attempt)
            continue
        if response.ok:
            return response
        if "problem+json" in response.headers.get("Content-Type", ""):
            problem = response.json()
            if problem["retry"] != "same_key" or last:
                raise RaskProblem(problem)
        elif response.status_code < 500 or last or not repeatable:
            response.raise_for_status()  # A proxy answered, not the API.
        time.sleep(int(response.headers.get("Retry-After", 2**attempt)))


def call(method, path, body=None, **options):
    """`send`, returning the parsed body (None for a 204)."""
    response = send(method, path, body, **options)
    return response.json() if response.content else None


def wait_for_job(job_id, check=True):
    """Long-poll a job until it ends. With `check`, raise when it did not succeed."""
    while True:
        started = time.monotonic()
        response = send("GET", f"/jobs/{job_id}", params={"wait_seconds": 20})
        job = response.json()
        if job["status"] in FINISHED:
            if check and job["status"] != "succeeded":
                raise RaskProblem(job["error"] or {"code": job["status"], "hint": None, "detail": job["id"]})
            return job
        hint = int(response.headers.get("Retry-After", 5))
        time.sleep(max(0, hint - (time.monotonic() - started)))


def upload_file(path, content_type, purpose="project_source"):
    """Upload a local file, wait for its import and return the media id."""
    upload = call(
        "POST",
        "/uploads",
        {
            "filename": os.path.basename(path),
            "content_type": content_type,
            "size_bytes": os.path.getsize(path),
            "purpose": purpose,
        },
    )
    transfer = upload["transfer"]
    with open(path, "rb") as source:
        requests.put(transfer["url"], data=source, headers=transfer["headers"], timeout=3600).raise_for_status()
    # Each run uploads anew, so completing this upload is new work with a key of its own.
    job = call("POST", f"/uploads/{upload['id']}/complete", {}, key=str(uuid.uuid4()))
    return wait_for_job(job["id"])["result"]["media_id"]


def download(url, path):
    """Save a presigned link to a file. It needs no key."""
    with requests.get(url, stream=True, timeout=3600) as response:
        response.raise_for_status()
        with open(path, "wb") as target:
            for chunk in response.iter_content(chunk_size=1024 * 1024):
                target.write(chunk)
```

The programs save what they did in a JSON file next to the video: the keys, the uploaded media and the price shown. If one
stops on a timeout or a crash, start it again within 24 hours: it sends the same requests with the same keys, and the API
replays what already ran, so nothing is created or charged twice. The price shown is the cap of every paid request in the
program. If the price rises, the program stops and says so instead of spending more.

```python dub_video.py theme={null}
"""Upload a video, dub it into Spanish and download the result: python dub_video.py clip.mp4

Progress is saved in clip.mp4.dub.json. If the program stops, run it again to continue; delete the file to dub anew.
"""
import sys

from rask_client import Progress, call, download, upload_file, wait_for_job

path = sys.argv[1]
progress = Progress(path + ".dub.json")

media_id = progress.once("media_id", lambda: upload_file(path, "video/mp4"))
order = {"media_id": media_id, "target_language": "es-es"}


def accept_price():
    estimate = call("POST", "/projects/estimate", order)
    if not estimate["eligible"]:
        sys.exit(f"Cannot start: {estimate['reasons']}")
    print(f"The dub costs {estimate['total_minutes']} minutes")
    return estimate["total_minutes"]


# The price shown once is the most this program spends: it is the cap of every paid request below.
max_minutes = progress.once("max_minutes", accept_price)
processing = call("POST", "/projects", {**order, "expected_cost_minutes": max_minutes}, key=progress.key("create project"))
project_id = processing["project_id"]

auto_dub = wait_for_job(processing["id"])["result"]["auto_dub"]
if auto_dub["state"] == "dispatched":
    wait_for_job(auto_dub["dub_job_id"])
else:
    # The auto-dub was skipped and not charged. Dub by hand, within the same cap.
    estimate = call("POST", f"/projects/{project_id}/dub/estimate", {})
    if estimate["total_minutes"] > max_minutes:
        sys.exit(
            f"The dub now costs {estimate['total_minutes']} minutes, more than the {max_minutes} you accepted, and nothing "
            f"was charged. Project {project_id} keeps its translation: dub it once you accept the new price."
        )
    dub = call("POST", f"/projects/{project_id}/dub", {"expected_cost_minutes": max_minutes}, key=progress.key("dub"))
    wait_for_job(dub["id"])

outputs = call("GET", f"/projects/{project_id}/outputs")["data"]
video = next((output for output in outputs if output["kind"] == "dubbed_video" and output["available"]), None)
if video is None:
    sys.exit(f"Project {project_id} has no dubbed video to download: unavailable_reason in its outputs says why.")
download(video["url"], "dubbed.mp4")
print("Saved dubbed.mp4")
```

## 4. Do it all in one request

To get a dubbed video that is also lip-synced and has its on-screen text translated, add a `localization` block to the same
project. The run starts after translation, and you wait for one job.

The key also needs `lipsync:write` and `onscreen:write`, which **Full access** includes, and the plan needs the features
`lipsync`, `lipsync_paid` and `onscreen` ([`GET /plan`](https://docs.api.rask.ai/operations/getPlan) lists them). On-screen translation takes videos up
to the plan's `onscreen_max_video_seconds`. Plan for hours rather than minutes: see [how long a job takes](https://docs.api.rask.ai/v3/concepts/jobs.md#how-long-a-job-takes).

```typescript TypeScript theme={null}
const { data: estimate } = await rask.projects.estimateProject({
  body: {
    media_id: 'MEDIA_ID',
    target_language: 'es-es',
    localization: { lipsync: { model: 'legacy' }, onscreen: { ui_language: 'en' } },
  },
});
```

```python Python theme={null}
estimate = client.projects.estimate_project(
    media_id="MEDIA_ID",
    target_language="es-es",
    localization={"lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}},
)
```

```go Go theme={null}
estimate, err := client.Projects.EstimateProject(ctx, api.EstimateProjectRequest{
	MediaId:        "MEDIA_ID",
	TargetLanguage: "es-es",
	Localization: &api.ProjectLocalizationInput{
		Lipsync:  &api.LocalizationLipsyncInput{Model: api.LipsyncModelLegacy},
		Onscreen: &api.LocalizationOnscreenInput{UiLanguage: rask.Ptr("en")},
	},
})
if err != nil {
	return err
}
```

```java Java theme={null}
Estimate estimate = rask.projects().estimateProject(EstimateProjectRequest.builder()
        .mediaId("MEDIA_ID")
        .targetLanguage("es-es")
        .localization(ProjectLocalizationInput.builder()
                .lipsync(LocalizationLipsyncInput.builder().model(LipsyncModel.LEGACY).build())
                .onscreen(LocalizationOnscreenInput.builder().uiLanguage("en").build())
                .build())
        .build()).data();
```

```php PHP theme={null}
$estimate = $rask->projects->estimateProject([
    'media_id' => 'MEDIA_ID',
    'target_language' => 'es-es',
    'localization' => ['lipsync' => ['model' => 'legacy'], 'onscreen' => ['ui_language' => 'en']],
]);
```

```bash cURL theme={null}
curl -X POST https://api.rask.ai/v3/projects/estimate \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"media_id": "MEDIA_ID", "target_language": "es-es", "localization": {"lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}}}'
```

`lipsync.model` is `legacy` or `enhanced`. `enhanced` needs the plan feature `lipsync_enhanced`, and its price is the `legacy`
price times the plan's `limits.lipsync_enhanced_cost_multiplier`, rounded up: see [Lip-sync](https://docs.api.rask.ai/v3/recipes/lipsync.md).

`onscreen.ui_language` is the language of the on-screen agent's activity messages in the job. It does not affect the
translation.

`total_minutes` now covers all three stages. Send it with the same block, under a key of its own; the `7` below stands for it:

```typescript TypeScript theme={null}
const { data: job } = await rask.projects.createProject({
  body: {
    media_id: 'MEDIA_ID',
    target_language: 'es-es',
    localization: { lipsync: { model: 'legacy' }, onscreen: { ui_language: 'en' } },
    expected_cost_minutes: 7,
  },
  idempotencyKey: runKey,
});
```

```python Python theme={null}
job = client.projects.create_project(
    media_id="MEDIA_ID",
    target_language="es-es",
    localization={"lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}},
    expected_cost_minutes=7,
    idempotency_key=run_key,
)
```

```go Go theme={null}
job, err := client.Projects.CreateProject(ctx, api.CreateProjectRequest{
	MediaId:        "MEDIA_ID",
	TargetLanguage: "es-es",
	Localization: &api.ProjectLocalizationInput{
		Lipsync:  &api.LocalizationLipsyncInput{Model: api.LipsyncModelLegacy},
		Onscreen: &api.LocalizationOnscreenInput{UiLanguage: rask.Ptr("en")},
	},
	ExpectedCostMinutes: rask.Ptr(int32(7)),
}, rask.WithIdempotencyKey(runKey))
if err != nil {
	return err
}
```

```java Java theme={null}
Job job = rask.projects().createProject(
        CreateProjectRequest.builder()
                .mediaId("MEDIA_ID")
                .targetLanguage("es-es")
                .localization(ProjectLocalizationInput.builder()
                        .lipsync(LocalizationLipsyncInput.builder().model(LipsyncModel.LEGACY).build())
                        .onscreen(LocalizationOnscreenInput.builder().uiLanguage("en").build())
                        .build())
                .expectedCostMinutes(7)
                .build(),
        RequestOptions.builder().idempotencyKey(runKey).build()).data();
```

```php PHP theme={null}
$job = $rask->projects->createProject(
    [
        'media_id' => 'MEDIA_ID',
        'target_language' => 'es-es',
        'localization' => ['lipsync' => ['model' => 'legacy'], 'onscreen' => ['ui_language' => 'en']],
        'expected_cost_minutes' => 7,
    ],
    ['idempotency_key' => $runKey],
);
```

```bash cURL theme={null}
RUN_KEY=$(uuidgen)
curl -X POST https://api.rask.ai/v3/projects \
  -H "Authorization: Bearer $RASK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $RUN_KEY" \
  -d '{"media_id": "MEDIA_ID", "target_language": "es-es", "localization": {"lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}}, "expected_cost_minutes": 7}'
```

When the `project_processing` job succeeds, `result.localization_job_id` names the run. Wait for that job. Its
`result.final_output` is the finished video, with its `url`.

```python localize_video.py theme={null}
"""Upload a video, then dub, lip-sync and translate its on-screen text in one run: python localize_video.py clip.mp4

Progress is saved in clip.mp4.localize.json. If the program stops, run it again to continue; delete the file to start anew.
"""
import sys

from rask_client import Progress, call, download, upload_file, wait_for_job

path = sys.argv[1]
progress = Progress(path + ".localize.json")

media_id = progress.once("media_id", lambda: upload_file(path, "video/mp4"))
order = {
    "media_id": media_id,
    "target_language": "es-es",
    "localization": {"lipsync": {"model": "legacy"}, "onscreen": {"ui_language": "en"}},
}


def accept_price():
    estimate = call("POST", "/projects/estimate", order)
    if not estimate["eligible"]:
        sys.exit(f"Cannot start: {estimate['reasons']}")
    print(f"The run costs {estimate['total_minutes']} minutes")
    return estimate["total_minutes"]


# A stage priced above this cap later is skipped, uncharged, and fails the run instead of costing more.
max_minutes = progress.once("max_minutes", accept_price)
processing = call("POST", "/projects", {**order, "expected_cost_minutes": max_minutes}, key=progress.key("create project"))
run_id = wait_for_job(processing["id"])["result"]["localization_job_id"]

run = wait_for_job(run_id)
download(run["result"]["final_output"]["url"], "localized.mp4")
print("Saved localized.mp4")
```

If a stage fails, the run fails and the stages that succeeded stay done and paid. [One request for everything](https://docs.api.rask.ai/v3/recipes/localization-run.md)
shows how to resume it, and how to start a run on a project that already exists.

## Next

* [Choose the dubbing model](https://docs.api.rask.ai/v3/recipes/dubbing-model.md) or [clone a voice](https://docs.api.rask.ai/v3/recipes/voice-clone.md) before you dub.
* [Download every output](https://docs.api.rask.ai/v3/recipes/downloads.md): audio, subtitles, voice track and more.
* [Paid text](https://docs.api.rask.ai/v3/concepts/paid-text.md): why the transcript of a new project answers 409 until its dub is charged.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.