Integração

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.

Todos os posts