Developer Guide

Async Transcription: Polling, Retries, and Handling Long Jobs Properly

Transcription is a long-running job, and long-running jobs are where naive client code goes wrong. Here is how to poll properly, why our timeouts need to be generous, and how to build something that survives your process dying.

The shape of the API

There are two calls. POST /api/v1/transcribe accepts a file and returns a job ID immediately — it does not wait for the transcription. GET /api/v1/jobs/{id} returns the current state, and once the status is completed, the transcript comes with it.

There is no webhook or callback. We do not push a notification to your endpoint when a job finishes; you poll for it. That is a real limitation and worth knowing before you design around it. The account does receive an email with the transcript, which is a fallback for humans but not something to build on.

Design for polling, not for push

If your architecture requires a callback — say, a serverless function that must not stay warm — you will need a scheduled poller reading job IDs from a queue rather than a function that waits. That is a different shape from a webhook integration and it is better to discover it now than halfway through.

Why timeouts need to be generous

This is the single most common integration mistake, and it is caused by a real operational detail worth understanding.

The transcription fleet keeps a permanent instance running during extended Australian business hours and scales to zero outside them. During business hours a job typically completes in under two minutes. Outside them, the first job of the day has to wait for an instance to start, which can add five to fifteen minutes before processing even begins.

A client with a 60-second timeout works perfectly in testing at 2pm on a Tuesday and fails every night. Allow at least 20 minutes.

Your timeout does not cancel anything

Giving up on polling does not stop the job. It completes regardless, the credit is spent either way, and the transcript stays retrievable by job ID. So a timeout should mean 'stop waiting and check later', not 'this failed'. Persist the job ID before you start polling and you can always come back for it.

Polling with backoff

Polling every second for twenty minutes is 1,200 requests to learn one thing. Start responsive, then back off — most jobs finish early, and the ones that do not are not going to finish sooner because you asked more often.

Python — a polling loop worth copying
import time
import requests

BASE = "https://api.icana.ai/api/v1"


class TranscriptionFailed(Exception):
    """The job ran and did not produce a transcript."""


class StillRunning(Exception):
    """Gave up waiting. The job is not finished, but it is not lost either."""


def wait_for(job_id: str, api_key: str, timeout: float = 1_200) -> dict:
    headers = {"X-API-Key": api_key}
    deadline = time.monotonic() + timeout
    delay = 3.0

    while time.monotonic() < deadline:
        response = requests.get(f"{BASE}/jobs/{job_id}", headers=headers, timeout=30)

        # Transient server-side problems are worth retrying; client errors are not.
        if response.status_code >= 500:
            time.sleep(delay)
            delay = min(delay * 2, 30)
            continue
        response.raise_for_status()

        job = response.json()
        if job["status"] == "completed":
            return job
        if job["status"] == "failed":
            raise TranscriptionFailed(job.get("error_message") or "unknown error")

        time.sleep(delay)
        delay = min(delay * 1.5, 30)

    raise StillRunning(job_id)

Two details matter. Separating StillRunning from TranscriptionFailed means your caller can distinguish 'come back later' from 'this will never work', which are very different for a batch job. And retrying only on 5xx avoids the classic mistake of retrying a 401 forty times.

Status codes worth handling distinctly

CodeMeaningWhat to do
401Bad or missing API keyFail immediately, do not retry
402Insufficient creditStop the whole batch, not just this file
400Rejected before processing — unsupported format, unreadable audio, or over the 100 MB limitRead detail; retrying will not help
413Rejected by the proxy above ~110 MB, before the app sees itSplit the recording and resubmit
429Rate limitedBack off and retry
5xxServer-side problemBack off and retry

A 400 is the one you will meet most often, and its detail field says which of the three causes applied. The 402 case is the one that costs people. In a batch of two hundred files, running out of credit at file thirty means the remaining hundred and seventy all fail in sequence unless you treat 402 as fatal to the batch.

Making it resumable

For anything beyond a handful of files, the durable pattern is to separate submission from collection and persist the job IDs in between. This is what makes the process survivable.

Submit, persist, collect
import json
import pathlib

STATE = pathlib.Path("jobs.json")


def submit_all(paths, api_key):
    """Submit everything, recording job IDs as we go."""
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    for path in paths:
        if str(path) in state:
            continue  # already submitted on a previous run
        with open(path, "rb") as handle:
            job = requests.post(
                f"{BASE}/transcribe",
                headers={"X-API-Key": api_key},
                files={"file": handle},
                data={"num_speakers": 2},
                timeout=300,
            ).json()
        state[str(path)] = job["id"]
        STATE.write_text(json.dumps(state, indent=2))  # persist after each
    return state


def collect_all(state, api_key):
    """Collect results. Safe to run repeatedly."""
    for path, job_id in state.items():
        out = pathlib.Path(path).with_suffix(".txt")
        if out.exists():
            continue  # already collected
        try:
            job = wait_for(job_id, api_key)
        except StillRunning:
            print(f"still running, will retry later: {path}")
            continue
        except TranscriptionFailed as exc:
            print(f"failed: {path} — {exc}")
            continue
        out.write_text(job["diarization"] or job["transcription"] or "")

Writing the state file after every submission rather than at the end is the part that matters. If the process dies at file 150 of 200, you have 150 job IDs on disk and nothing has been lost — the jobs are running server-side regardless, and collect_all can be run repeatedly until everything lands.

Both functions are idempotent, which means the recovery procedure for almost any failure is simply to run the script again.

Submit first, then wait

One more thing that catches people building batch jobs: do not submit a file, wait for it, then submit the next. Jobs process in parallel server-side, so submitting everything up front means total time is bounded by your longest recording rather than the sum of all of them.

For a hundred one-hour files the difference is between roughly the time of one file and roughly a hundred times that. It is the single biggest performance decision in a batch integration and it is one line of restructuring.

Frequently asked questions

Does the transcription API support webhooks?

No. There is no callback when a job completes — you poll GET /api/v1/jobs/{id} until the status is completed. The account does receive an email with the transcript, but that is a fallback for humans rather than something to build an integration on. If your architecture needs push notification, plan for a scheduled poller reading job IDs from a queue.

How long should my polling timeout be?

At least 20 minutes. Jobs usually complete in under two minutes during Australian business hours, but the transcription fleet scales to zero outside them, so a cold start can add five to fifteen minutes before processing begins. A 60-second timeout works in daytime testing and fails every night.

What happens if my client times out or crashes while polling?

Nothing happens to the job — it completes regardless, and the transcript stays retrievable by job ID. Credit is consumed either way. This is why you should persist the job ID before polling: a timeout means 'check back later', not 'this failed'.

How often should I poll?

Start around every three seconds and back off gradually to a maximum of about thirty. Most jobs finish early, so a responsive start is worth having, but a job that is still running after five minutes will not finish sooner because you asked more often. Retry on 5xx responses; do not retry on 401 or 402.

How do I transcribe a large batch efficiently?

Submit every file first, then collect the results. Jobs process in parallel server-side, so total time is bounded by your longest recording rather than the sum of all of them. Submitting one at a time and waiting for each is the biggest performance mistake in a batch integration.

What should I do when a job status comes back as failed?

Treat it differently from a failed HTTP request — the submission worked, the transcription did not. Check the error_message field, which usually points at unreadable, silent or corrupt audio. Retrying the same file rarely helps; fix or skip it.

Build against it with free credit

90 minutes of transcription free, no credit card required. Enough to test your retry and timeout handling properly.