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

# Async Jobs & Long Videos

> Transcribe, extract and analyze videos of any length with async jobs, polling and webhooks.

## Overview

The speech-to-text endpoints run for as long as the media is long. A synchronous request holds the HTTP connection open for the whole download and transcription — reliable for short clips, not for long ones: clients, reverse proxies and marketplace gateways all time out.

That is why every speech-to-text endpoint has an **async twin**. You submit the job, get a `task_id` back immediately, and collect the result by polling or through a [webhook](/webhooks).

<Warning>
  **Synchronous calls are limited to 10 minutes of media.** Longer media is rejected with `400 video_too_long`. Use the async endpoints for anything that may run past 10 minutes — they have **no duration cap**, and they work just as well for short videos.
</Warning>

## Which endpoint to use

| Synchronous (≤ 10 min)                                                                  | Async submit (any length)                                                                            | Poll result                      |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------- |
| [`POST /transcribe`](/api-reference/endpoint/transcribe)                                | [`POST /transcribe/async`](/api-reference/endpoint/transcribe#async-mode-long-videos)                | `GET /transcribe/{task_id}`      |
| [`POST /extract/video`](/api-reference/endpoint/extract-video) (with `transcribe=true`) | [`POST /extract/video/async`](/api-reference/endpoint/extract-video#async-mode-long-videos)          | `GET /extract/video/{task_id}`   |
| [`POST /tweet/statement`](/api-reference/endpoint/tweet-claim-analysis)                 | [`POST /tweet/statement/async`](/api-reference/endpoint/tweet-claim-analysis#async-mode-long-videos) | `GET /tweet/statement/{task_id}` |

Each async endpoint accepts **exactly the same body** as its synchronous counterpart, plus an optional `webhook_url`. The result you get back is **identical** to the synchronous response's `data` block, so the same parsing code works for both.

<Tip>
  If you don't know the video's duration in advance, just use the async endpoint every time. There is no extra cost.
</Tip>

<Note>
  [TikTok profile scrape](/api-reference/endpoint/tiktok-profile) and [TikTok keyword search](/api-reference/endpoint/tiktok-search) are async by design and follow the same polling and webhook model.
</Note>

## How it works

<Steps>
  <Step title="Submit the job">
    `POST` to the async endpoint. The API validates the request and returns `202 Accepted` with a `task_id` and a `check_status_url`.
  </Step>

  <Step title="Wait for the result">
    Either poll `check_status_url` every few seconds while `task_status` is `processing`, or pass a `webhook_url` (or configure a default one in [Studio → API](https://vidnavigator.com/studio/api)) and get called back when the job finishes.
  </Step>

  <Step title="Read the result">
    When `task_status` is `completed`, `data.result` holds the result. When it is `failed`, `data.error` carries the error code and HTTP status the synchronous endpoint would have returned.
  </Step>
</Steps>

## 1. Submit the job

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.vidnavigator.com/v1/transcribe/async" \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "video_url": "https://www.tiktok.com/@user/video/1234567890",
      "webhook_url": "https://example.com/hooks/vidnavigator"
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.vidnavigator.com/v1/transcribe/async",
      headers={"X-API-Key": "YOUR_API_KEY"},
      json={"video_url": "https://www.tiktok.com/@user/video/1234567890"},
  )
  response.raise_for_status()
  task = response.json()["data"]
  print(task["task_id"], task["check_status_url"])
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch('https://api.vidnavigator.com/v1/transcribe/async', {
    method: 'POST',
    headers: {
      'X-API-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      video_url: 'https://www.tiktok.com/@user/video/1234567890'
    })
  });

  const { data: task } = await response.json();
  console.log(task.task_id, task.check_status_url);
  ```
</CodeGroup>

### Accepted response (202)

```json theme={null}
{
  "status": "success",
  "data": {
    "task_id": "550e8400-e29b-41d4-a716-446655440000",
    "task_status": "processing",
    "job_type": "transcribe",
    "expires_at": "2026-09-23T13:00:00Z",
    "check_status_url": "/v1/transcribe/550e8400-e29b-41d4-a716-446655440000",
    "webhook_url": "https://example.com/hooks/vidnavigator",
    "message": "Transcription job accepted.",
    "docs_url": "https://docs.vidnavigator.com/guides/async-jobs"
  }
}
```

| Field              | Description                                                                                                  |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `task_id`          | Identifier of the job.                                                                                       |
| `task_status`      | Always `processing` at submit time.                                                                          |
| `job_type`         | `transcribe`, `extract_video` or `tweet_statement`.                                                          |
| `expires_at`       | When the result is deleted. Provisional until the job finishes, then pushed out to **finish time + 1 hour**. |
| `check_status_url` | Relative URL to poll (prefix it with `https://api.vidnavigator.com`).                                        |
| `webhook_url`      | The URL the result will be POSTed to, or `null` if no webhook applies.                                       |

## 2. Poll for the result

Polling is **free** — the `GET` endpoints consume no credits. A few seconds between polls is plenty.

<CodeGroup>
  ```python Python theme={null}
  import time
  import requests

  BASE = "https://api.vidnavigator.com"
  HEADERS = {"X-API-Key": "YOUR_API_KEY"}

  def wait_for_job(check_status_url, interval=5):
      while True:
          job = requests.get(BASE + check_status_url, headers=HEADERS).json()["data"]
          if job["task_status"] == "completed":
              return job["result"]
          if job["task_status"] == "failed":
              err = job["error"]
              raise RuntimeError(f'{err["error"]} ({err["http_status"]}): {err["message"]}')
          time.sleep(interval)

  result = wait_for_job(task["check_status_url"])
  print(result["video_info"]["title"])
  for segment in result["transcript"]:
      print(f'[{segment["start"]:.2f}s] {segment["text"]}')
  ```

  ```javascript JavaScript theme={null}
  const BASE = 'https://api.vidnavigator.com';
  const HEADERS = { 'X-API-Key': 'YOUR_API_KEY' };

  async function waitForJob(checkStatusUrl, intervalMs = 5000) {
    while (true) {
      const { data: job } = await (await fetch(BASE + checkStatusUrl, { headers: HEADERS })).json();
      if (job.task_status === 'completed') return job.result;
      if (job.task_status === 'failed') {
        throw new Error(`${job.error.error} (${job.error.http_status}): ${job.error.message}`);
      }
      await new Promise(r => setTimeout(r, intervalMs));
    }
  }

  const result = await waitForJob(task.check_status_url);
  console.log(result.video_info.title);
  ```
</CodeGroup>

### Job states

| `task_status` | Meaning                                                                                      |
| ------------- | -------------------------------------------------------------------------------------------- |
| `processing`  | Accepted and being worked on.                                                                |
| `completed`   | Terminal. `data.result` holds the result.                                                    |
| `failed`      | Terminal. `data.error` holds `error`, `message` and `http_status`. All charges are reverted. |

### Completed response

```json theme={null}
{
  "status": "success",
  "data": {
    "task_id": "550e8400-e29b-41d4-a716-446655440000",
    "task_status": "completed",
    "job_type": "transcribe",
    "request": {
      "video_url": "https://www.tiktok.com/@user/video/1234567890",
      "transcript_text": false,
      "all_videos": false
    },
    "created_at": "2026-09-23T12:00:00Z",
    "started_at": "2026-09-23T12:00:01Z",
    "completed_at": "2026-09-23T12:03:40Z",
    "expires_at": "2026-09-23T13:03:40Z",
    "check_status_url": "/v1/transcribe/550e8400-e29b-41d4-a716-446655440000",
    "webhook": {
      "status": "delivered",
      "attempts": 1,
      "response_status": 200,
      "last_error": null,
      "delivered_at": "2026-09-23T12:03:41Z"
    },
    "result": {
      "video_info": { "title": "A long podcast episode", "duration": 3540.0 },
      "transcript": [
        { "text": "Welcome to the show.", "start": 0.5, "end": 2.1 }
      ]
    },
    "error": null
  }
}
```

### Failed response

```json theme={null}
{
  "status": "success",
  "data": {
    "task_id": "550e8400-e29b-41d4-a716-446655440000",
    "task_status": "failed",
    "job_type": "transcribe",
    "result": null,
    "error": {
      "error": "transcript_not_available",
      "message": "Transcript could not be generated for this video.",
      "http_status": 404
    }
  }
}
```

<Note>
  The poll request itself returns `200` even when the job failed — check `task_status`. `data.error` uses the same codes as the synchronous endpoint, so existing error handling can be reused unchanged. See [Errors](/api-reference/errors).
</Note>

### Retention

* Results are kept for **1 hour after the job finishes** (not from submission), so even a long transcription leaves a full hour to collect it.
* Reading a task does **not** consume or delete it — re-read the same `task_id` as often as you like until it expires.
* After expiry, the poll endpoint returns `404 task_not_found`.

## Webhooks instead of polling

Pass `webhook_url` on the submit request, or configure an account-level default endpoint in [Studio → API](https://vidnavigator.com/studio/api). The per-request value wins; pass `"webhook_url": ""` to opt a single job out of the default.

See the full contract — payload, signature verification, retries — in the [Webhooks guide](/webhooks).

<Tip>
  Webhooks are best effort. Treat polling as the source of truth: if a callback never arrives, the result is still available on `check_status_url`.
</Tip>

## Billing

Billing is **identical** to the synchronous endpoint — the async mode costs nothing extra — and happens in the background worker.

* `include_usage` is **not** accepted on the submit endpoint (the `202` fires before any charge is made). Pass `include_usage=true` on the poll endpoint instead: it replays the final charges once `task_status=completed`.
* A failed job has all of its charges reverted.
* Polling is free.

See [Usage & Costs](/api-reference/usage-costs) for unit prices.

## Submit-time errors

These are returned by the `POST` itself; no task is created.

| Status | `error`                                                       | When                                                                                                                                                                                                                                                              |
| ------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `missing_parameter`, `invalid_parameter`, `invalid_schema`, … | Invalid body. For `/extract/video/async` the schema is validated at submit time, so an invalid schema is rejected before any transcription is billed.                                                                                                             |
| `401`  | —                                                             | Invalid or deactivated API key.                                                                                                                                                                                                                                   |
| `402`  | `limit_exceeded`                                              | Less than **60 seconds** of transcription credit left. This is a spam gate, not a quote: the real charge is computed when the job runs, and a job whose video exceeds your remaining balance still fails with the same credit error a synchronous call would get. |
| `403`  | —                                                             | The API key lacks permission for this endpoint.                                                                                                                                                                                                                   |
| `429`  | `too_many_active_jobs`                                        | Too many jobs already running for this account. Wait for some to finish, then retry.                                                                                                                                                                              |

## Migrating from synchronous calls

1. Switch the URL: `/transcribe` → `/transcribe/async`, `/extract/video` → `/extract/video/async`, `/tweet/statement` → `/tweet/statement/async`. Keep the same body.
2. Remove `include_usage` from the submit body and pass it as a query parameter on the poll endpoint if you need it.
3. Poll `check_status_url` (or receive the webhook) and read `data.result` — it has the same shape as the synchronous `data` block.
4. Map `data.error.error` to your existing error handling.
