Webhook ou polling numa API de transcrição assíncrona
Consultar em laço funciona para um arquivo e vira cem requisições por job em produção. O que muda com webhook, como conferir a assinatura e o que fazer quando o seu servidor estava fora do ar.
Transcrição é assíncrona porque áudio de duas horas não cabe numa requisição
HTTP síncrona. A API responde na hora com status: "processing" e o resultado
chega minutos depois. A pergunta de integração é sempre a mesma: como você
descobre que terminou.
Existem duas respostas. Uma delas é a certa para um arquivo, e a outra é a certa para produção.
O que polling custa de verdade
Consultar GET /v1/transcripts/{id} de três em três segundos até o status
sair de processing é o caminho mais curto para ver funcionando, e é o que
qualquer um faz no primeiro dia. Ele não custa dinheiro: consultar status não é
cobrado.
O que ele custa é outra coisa.
Um VOD de duas horas levando alguns minutos para processar são cerca de cem
requisições por job, todas respondendo processing. Multiplique por uma fila de
mil arquivos e o seu worker vira um cliente de rede em tempo integral. E o pior
não é o volume: é o atraso. Você descobre que terminou no próximo tique, não
quando terminou. Com intervalo de três segundos, a mediana de atraso é um
segundo e meio; com intervalo de trinta, quinze segundos parados por job.
Para um arquivo, tudo bem. Para volume, a conta não fecha.
O que muda com webhook
Um campo na criação:
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://seu-servidor.com/hooks/transcrevo"
}'A API chama você uma vez, quando a transcrição termina, e vale tanto para done
quanto para failed. Uma leitura por job em vez de uma centena.
O corpo é o resumo, no mesmo envelope de sempre:
{
"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 e words não vêm no corpo. Num áudio de duas horas esses campos
passam de 10 MB, e webhook não é lugar de trafegar isso: o seu servidor teria
que aceitar e desserializar 10 MB dentro do timeout de entrega, para cada job.
Recebeu o aviso, busque o transcrito uma vez em GET /v1/transcripts/{id}.
A URL precisa ser pública e alcançável. Endereço interno ou host que não resolve
é recusado na criação com 400 validation e a chave webhook_url_invalid, em
vez de virar entrega que nunca chega.
Conferir a assinatura
Um endpoint público que recebe POST sem verificação aceita qualquer POST. Cada
entrega leva o header Transcrevo-Signature, no formato t=<unix>,v1=<hex>. O
v1 é o HMAC-SHA256 de <t>.<corpo cru>, e o segredo é o de webhook da conta
(whsec_...), mostrado no painel.
Ele é separado das chaves de API porque as duas rotações não têm nada a ver uma com a outra: a chave se troca quando vaza, o segredo quando o verificador muda. Ao rotacionar, o antigo continua verificando por 24 horas.
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("=")));
// Aviso velho é aviso repetido: fora de cinco minutos, recuse.
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));
}Dois detalhes que costumam quebrar isso na prática. O primeiro é o corpo cru: se o seu framework já converteu o JSON e você serializa de novo para conferir, a assinatura não bate, porque a ordem das chaves e o espaçamento mudam. Guarde os bytes originais. O segundo é a janela de tempo: sem ela, um aviso capturado hoje vale para sempre.
O que o seu endpoint deve responder
Responda 2xx para confirmar. Qualquer outra coisa, ou nenhuma resposta em 10
segundos, é nova tentativa, com espera crescente: 30 s, 2 min, 10 min, 30 min,
2 h e 6 h. Depois da sexta a API desiste do aviso, e a transcrição continua lá
para ser buscada.
Isso desenha o handler certo. Confirme rápido e trabalhe depois. Buscar o transcrito, gerar legenda e gravar no banco dentro do handler é o caminho mais curto para estourar os 10 segundos num áudio grande e receber o mesmo aviso seis vezes.
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 });
}E trate entrega repetida como normal. Uma resposta que demorou 11 segundos
foi processada por você e retentada pela API. O id da transcrição é a chave
natural de deduplicação: se você já gravou aquele id, ignore.
Quando o seu servidor estava fora do ar
É o caso que ninguém planeja e todo mundo vive. Seis tentativas cobrem cerca de nove horas de indisponibilidade, o que é bastante, mas não é infinito.
A rede de segurança é a listagem por data. GET /v1/transcripts aceita since
e until, e o recorte é feito no banco, então varrer a janela em que você
estava fora não exige paginar a conta inteira:
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"Os itens da lista trazem tudo exceto text e words, pelo mesmo motivo do
webhook. Compare os id com o que você tem gravado, e busque só os que faltam.
Uma varredura dessas rodando uma vez por hora é rede de segurança barata: ela não custa nada e transforma "perdi o aviso" em "atrasei o aviso". Vale a pena até em integração que nunca caiu.
O resumo prático
Polling para experimentar e para volume baixo. Webhook assim que houver fila, e
com as quatro coisas juntas: assinatura conferida sobre o corpo cru, resposta em
menos de 10 segundos, deduplicação por id, e uma varredura periódica por
since para o dia em que o seu servidor estiver fora.
Os campos do aviso e o formato da assinatura estão em transcrições; os códigos de erro, em erros.