Webhooks or polling on an async transcription API
Polling works for one file and turns into a hundred requests per job in production. What changes with webhooks, how to verify the signature, and what to do when your server was down.
Transcription is asynchronous because two hours of audio do not fit in a
synchronous HTTP request. The API answers immediately with
status: "processing" and the result lands minutes later. The integration
question is always the same: how do you find out it finished.
There are two answers. One is right for a single file, the other is right for production.
What polling actually costs
Calling GET /v1/transcripts/{id} every three seconds until status leaves
processing is the shortest path to seeing it work, and it is what everyone
does on day one. It costs no money: status checks are not billed.
What it costs is something else.
A two-hour VOD taking a few minutes to process is about a hundred requests per
job, all answering processing. Multiply by a thousand-file queue and your
worker becomes a full-time network client. And the volume is not the worst part:
the delay is. You learn it finished on the next tick, not when it finished. At a
three-second interval, median added delay is a second and a half; at thirty,
fifteen seconds of nothing per job.
For one file, fine. At volume, the math stops working.
What changes with a webhook
One field on creation:
curl https://api.transcrevo.com/v1/transcripts \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/podcast.mp3",
"webhookUrl": "https://your-server.com/hooks/transcrevo"
}'The API calls you once, when the transcript finishes, and it fires for both
done and failed. One read per job instead of a hundred.
The body is the summary, in the usual envelope:
{
"data": {
"id": "6b9f2b81-…",
"status": "done",
"durationSeconds": 8700,
"language": "pt",
"speakerCount": 6,
"cost": 12.08,
"currency": "USD",
"error": null,
"model": "trv-1",
"url": null,
"createdAt": "2026-06-26T18:00:00.000Z"
}
}text and words are not in the body. On a two-hour file those fields run
past 10 MB, and a webhook is not the place for that: your server would have to
accept and deserialize 10 MB inside the delivery timeout, for every job. Once
the notice arrives, fetch the transcript once from GET /v1/transcripts/{id}.
The URL has to be public and reachable. An internal address or a host that does
not resolve is rejected at creation with 400 validation and the key
webhook_url_invalid, rather than becoming a delivery that never arrives.
Verifying the signature
A public endpoint that accepts POSTs without verification accepts any POST.
Every delivery carries a Transcrevo-Signature header shaped
t=<unix>,v1=<hex>. The v1 is the HMAC-SHA256 of <t>.<raw body>, and the
secret is the account's webhook secret (whsec_...), shown in the dashboard and
rotated on its own schedule, independent from your API keys.
Keeping the two apart matters because the rotations have nothing to do with each other: a key is rotated when it leaks, a signing secret when your verifier changes. During a rotation the previous secret keeps verifying for 24 hours.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.TRANSCREVO_WEBHOOK_SECRET;
export function valid(header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
// An old notice is a replayed notice: outside five minutes, refuse.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Two details usually break this in practice. First, the raw body: if your framework already parsed the JSON and you re-serialize it to check, the signature will not match, because key order and spacing change. Keep the original bytes. Second, the time window: without it, a captured notice is valid forever.
What your endpoint should answer
Answer 2xx to confirm. Anything else, or no answer within 10 seconds, means a
retry, with growing waits: 30 s, 2 min, 10 min, 30 min, 2 h and 6 h. After the
sixth the API gives up on the notice, and the transcript stays there to be
fetched.
That shapes the right handler. Confirm fast, work afterwards. Fetching the transcript, generating captions and writing to the database inside the handler is the shortest path to blowing past 10 seconds on a large file and receiving the same notice six times.
export async function POST(request: Request) {
const raw = await request.text();
if (!valid(request.headers.get("Transcrevo-Signature") ?? "", raw)) {
return new Response("bad signature", { status: 401 });
}
const { data } = JSON.parse(raw);
await enqueue({ transcriptId: data.id, status: data.status });
return new Response(null, { status: 204 });
}And treat repeated delivery as normal. A response that took 11 seconds was
processed by you and retried by the API. The transcript id is the natural
dedup key: if you already stored that id, ignore it.
When your server was down
This is the case nobody plans for and everybody lives through. Six attempts cover roughly nine hours of downtime, which is a lot, but not unlimited.
The safety net is listing by date. GET /v1/transcripts takes since and
until, and the range is applied in the database, so sweeping the window you
were down does not mean paginating the whole account:
curl "https://api.transcrevo.com/v1/transcripts?status=done&since=2026-06-25T00:00:00Z&until=2026-06-26T00:00:00Z" \
-H "Authorization: Bearer $TRANSCREVO_API_KEY"List items carry everything except text and words, for the same reason
the webhook does not. Compare the ids against what you have stored and fetch
only the missing ones.
Running that sweep once an hour is cheap insurance: it costs nothing and turns "I lost the notice" into "I was late on the notice". Worth it even on an integration that has never gone down.
The practical summary
Polling to try it out and for low volume. Webhooks as soon as there is a queue,
with all four things in place: signature verified over the raw body, a response
in under 10 seconds, dedup by id, and a periodic since sweep for the day
your server is not there.
The notice fields and the signature format are in transcripts; the error codes in errors.