Safe retries
Idempotency-Key, the retryable field, long polling with ?wait=, cancellation, cursors and the limits the API reports about itself: what an integration needs to defend itself.
A transcription integration runs with nobody watching: the network blips, a deploy restarts the process mid-call, a backlog releases all at once. This page is what the API offers so none of that turns into audio charged twice or a job lost.
Retrying creation without paying twice
POST /v1/transcripts creates AND charges. A response lost on the way back
leaves you unsure whether the job exists, and retrying blindly creates a second
one. Idempotency-Key closes that window:
curl https://api.transcrevo.com/v1/transcripts \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
-H "Idempotency-Key: 8e1f0f1a-3c1a-4a1f-9f0a-2b7d5c9e1234" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/audio.mp3", "speakers": true}'The same key returns the same transcript, without creating or charging
another. The same key with a different body is 409 idempotency_conflict: that
is a bug on your side, not a retry, and returning the old job would make the new
audio disappear silently.
Pick the key from what is stable on your side. With uploads, key it on the
FIRST uploadId: it changes on every new upload attempt, so using your own media
id would return 409 forever after the first time.
Knowing what is worth retrying
Every error carries retryable:
{ "error": { "code": "rate_limited", "message": "…", "retryable": true, "requestId": "…" } }const { error } = await createTranscript(input);
if (error && !error.retryable) throw new Error(`${error.code}: ${error.message} (${error.requestId})`);
// retryable: exponential backoff, or the Retry-After header when it is presentWithout that field, every integration keeps its own table of "which errors
deserve a retry", and the one that gets it wrong repeats forever what will never
pass. retryable: true does not mean "retry now": on rate_limited the
Retry-After header says when.
Keep the requestId (also in the X-Request-Id header) in your logs. It is how
we find, on our side, what happened on that exact call.
Waiting for the result without polling
GET /v1/transcripts/{id}?wait=30The response is held until the transcript finishes, and comes back the moment
it does. One request instead of a polling loop. The ceiling is 60 seconds; the
wait expiring is not an error (you get the transcript still in processing and
call again).
While it is processing, the response carries Retry-After with the interval
worth waiting before asking again.
For many files at once, webhookUrl is still cheaper: one delivery per job
instead of one open connection per job.
Cancelling
POST /v1/transcripts/{id}/cancelStops a transcript that is still processing. It becomes canceled, terminal,
and is never charged; the credit it was holding (reserved in
GET /v1/balance) is released immediately.
Cancelling is about the charge, not the machine: audio already on a GPU finishes the work in flight and the result is discarded. None of it reaches you or your invoice.
Paging a long history
GET /v1/transcripts?cursor=<nextCursor>&perPage=100The cursor never skips or repeats a transcript when new ones arrive while you
page, which is exactly what offsets do. It also does not count the account total:
total comes back null under a cursor, because that count is the cost the
cursor exists to avoid.
?page= keeps working for integrations already built on it.
Asking for the limits instead of hardcoding them
GET /v1/limitsMaximum audio duration, accepted formats, upload / chunk / request-body ceilings, glossary limits, and how many transcripts this account can run at once. Reading it at startup avoids discovering a ceiling after the file is already uploaded.
Bursts
Every response from a limited route carries RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset. With those, your queue slows down
before hitting the ceiling instead of finding it by being refused. Codes and
details in Errors.