# Guia de Webhooks — Callbacks de Conclusão de Batch

> Registre uma URL de callback para ser notificado quando um batch terminar, em vez de ficar consultando — verificação de assinatura Standard Webhooks, formato do payload do evento, comportamento de retry, o evento de conclusão de bbox e como o reprocessamento de um batch gera uma nova notificação automaticamente.

Em vez de ficar consultando `GET /batches/{batch_name}` até terminar, registre uma URL de callback uma vez e receba um HTTP POST no momento em que um batch for concluído. Os três cabeçalhos de assinatura e a construção do conteúdo assinado `id.timestamp.body` seguem a especificação aberta [Standard Webhooks](https://www.standardwebhooks.com/) — a mesma abordagem usada pelos webhooks da OpenAI. **Uma diferença da convenção da própria especificação:** `webhook_secret` aqui é uma string hexadecimal simples, não um valor base64 com prefixo `whsec_` — uma biblioteca verificadora Standard Webhooks/Svix pronta para uso tentará decodificá-lo como base64 e falhará. Use o segredo exatamente como retornado, como bytes brutos de chave HMAC, com o código de verificação abaixo (ou seu equivalente) em vez de uma biblioteca que espera o prefixo.

## Registrando um webhook

Registre (ou atualize) o callback de um batch com `PUT /api/v1/batches/{batch_name}/webhook`:

```bash
curl -X PUT https://imagetotable.ai/api/v1/batches/260716-4K9P/webhook \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"callback_url": "https://example.com/hooks/imagetotable"}'
```

A resposta inclui um `webhook_secret` por webhook, gerado para você — um segredo por registro, não uma chave compartilhada da conta, então um segredo vazado afeta apenas aquele callback:

```json
{
  "batch_name": "260716-4K9P",
  "callback_url": "https://example.com/hooks/imagetotable",
  "webhook_secret": "9f2a3b7c1d8e4f5061728394a5b6c7d81a2b3c4d5e6f7089",
  "created_at": "2026-07-16T09:12:03Z",
  "fired_at": null
}
```

Salve o `webhook_secret` — você vai precisar dele para verificar as entregas recebidas. Não existe um endpoint separado para "revelar o segredo", mas fazer um novo `PUT` com o mesmo `callback_url` é uma forma segura de obtê-lo novamente se você o perder (ele não rotaciona o segredo nem redefine nada).

## Envelope do evento

Toda entrega é um objeto JSON encapsulado em um envelope — nunca dados brutos do batch — para que a estrutura possa crescer e cobrir futuros tipos de evento sem quebrar integrações existentes. Verifique o campo `type` para diferenciar os eventos; uma URL de callback registrada em um batch pode receber ambos os tipos abaixo, misturados.

```json
{
  "type": "batch.completed",
  "created_at": "2026-07-16T09:14:31Z",
  "data": {
    "batch_name": "260716-4K9P",
    "status": "succeeded",
    "document_count": 3
  }
}
```

`data.status` reflete o resultado consolidado do batch (`succeeded` ou `failed` — veja [Async Task Model](/developers/guides/async-model) para o vocabulário completo de status). O payload é um aviso de conclusão, não os resultados em si — chame `GET /batches/{batch_name}/results` após recebê-lo para buscar os dados extraídos de fato.

Ao acionar um [trabalho de anotação de bbox](/developers/guides/bbox) em um documento, o webhook já registrado para o batch daquele documento é reutilizado, e entrega um evento distinto em vez de outro `batch.completed`:

```json
{
  "type": "document.bbox_completed",
  "created_at": "2026-07-16T09:20:07Z",
  "data": {
    "document_id": "3f9c9e2a-1b7d-4e2b-9a3e-2f5b6c7d8e9f",
    "group_batch_id": "bx_8a3c2e1f",
    "status": "succeeded"
  }
}
```

Assim como `batch.completed`, a entrega deste evento é reivindicada atomicamente, então você não receberá duas entregas separadas de `document.bbox_completed` para um único trabalho de bbox, mesmo que vários grupos de linhas atinjam um status terminal em momentos próximos em threads diferentes. A deduplicação usual baseada em `webhook-id` (veja [abaixo](#verifying-signatures)) ainda se aplica sobre isso para o caso comum de uma entrega com falha ser repetida.

## Verificando assinaturas

Cada entrega carrega três cabeçalhos, conforme a especificação Standard Webhooks:

| Cabeçalho | Finalidade |
| --- | --- |
| webhook-id | ID único para esta tentativa de entrega. Use-o para desduplicar — uma entrega repetida reutiliza o mesmo ID. |
| webhook-timestamp | Timestamp Unix do envio da entrega, para proteger contra ataques de repetição (rejeite entregas com timestamp muito antigo). |
| webhook-signature | A própria assinatura, formatada como v1,<assinatura base64> . |

A assinatura é um HMAC-SHA256 sobre a concatenação de `webhook-id`, `webhook-timestamp` e o corpo da requisição bruto — nessa ordem, unidos por `.` — usando como chave o `webhook_secret` do seu webhook:

```text
signed_content = "{webhook_id}.{webhook_timestamp}.{raw_request_body}"
signature      = base64(hmac_sha256(webhook_secret, signed_content))
```

Recompute isso no seu lado e compare (usando uma comparação em tempo constante) com o valor após `v1,` no cabeçalho `webhook-signature`. Sempre verifique contra os bytes do corpo da requisição *bruto*, não contra uma versão re-serializada do JSON analisado — re-serializar pode alterar espaços em branco/ordem das chaves e produzir uma incompatibilidade de assinatura mesmo para uma entrega genuína.

**cURL**

```bash
# A verificação de assinatura não é uma chamada HTTP, então recriamos ela com
# openssl contra uma entrega que você já salvou em disco (headers.txt /
# body.json) — útil para confirmar seu entendimento do esquema manualmente
# antes de implementar o código de verificação real no seu handler de webhook.
WEBHOOK_ID=$(grep -i '^webhook-id:' headers.txt | cut -d' ' -f2 | tr -d '\r')
WEBHOOK_TS=$(grep -i '^webhook-timestamp:' headers.txt | cut -d' ' -f2 | tr -d '\r')
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TS}.$(cat body.json)"

echo -n "$SIGNED_CONTENT" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
  | base64
```

**Python**

```python
import base64
import hashlib
import hmac
import os

headers = dict(line.split(": ", 1) for line in open("headers.txt") if ": " in line)
webhook_id = headers["webhook-id"].strip()
webhook_timestamp = headers["webhook-timestamp"].strip()
body = open("body.json", "rb").read()

signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + body
signature = base64.b64encode(
    hmac.new(os.environ["WEBHOOK_SECRET"].encode(), signed_content, hashlib.sha256).digest()
)
print(signature.decode())
```

**Javascript**

```javascript
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";

const headers = Object.fromEntries(
  readFileSync("headers.txt", "utf8")
    .split("\n")
    .filter((line) => line.includes(": "))
    .map((line) => line.split(": "))
);
const webhookId = headers["webhook-id"].trim();
const webhookTimestamp = headers["webhook-timestamp"].trim();
const body = readFileSync("body.json", "utf8");

const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
  .update(signedContent)
  .digest("base64");
console.log(signature);
```

Se você está testando manualmente — salvando o corpo de uma entrega em `body.json` antes de executar um dos trechos acima — **cuidado com uma quebra de linha extra adicionada pelo seu editor ou método de captura**. A versão com curl/openssl acima é acidentalmente imune a isso (o `$(cat ...)` do bash remove quebras de linha finais), mas as versões Python e JavaScript leem os bytes exatos do arquivo e vão silenciosamente produzir uma assinatura que não corresponde se um `\n` extra aparecer — o sintoma clássico é "curl diz que é válido, mas meu verificador Python diz que não é", o que é confuso justamente porque o código em si não tem bug. Capture o corpo através do acessor de corpo bruto da requisição do seu framework web (`request.get_data()` do Flask, middleware raw-body do Express) no seu handler de webhook real, em vez de copiar/colar ou salvar manualmente — isso não acontece em produção, é só uma armadilha de teste manual.

## Reintentos

A entrega é tentada uma vez imediatamente após o batch ser concluído. Se essa tentativa falhar (timeout, erro de conexão ou resposta diferente de 2xx), ela é repetida com backoff exponencial: **1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 24 horas** — até 7 tentativas no total (a tentativa inicial mais 6 reintentos). Se nenhuma delas for bem-sucedida dentro de 24 horas após a primeira tentativa, a entrega é marcada como falha e não ocorrem mais reintentos. Faça seu endpoint responder com um status 2xx assim que você tiver registrado o evento de forma durável — faça o processamento lento (como buscar e analisar os resultados do batch) após responder, não antes, para que uma etapa downstream lenta não cause por si só um reintento.

## Reprocessando um batch: uma notificação por onda

Uma notificação `batch.completed` é disparada uma vez, no momento em que todos os documentos do batch atingiram um status terminal. Se você adicionar mais documentos ao mesmo `batch_name` e chamar `process` novamente — "uma nova onda" — **você será notificado novamente assim que essa onda terminar também**, automaticamente. O `process_batch` rearma o próprio webhook no momento em que encontra novos documentos elegíveis em um batch cuja onda anterior já notificou você; você não precisa refazer o `PUT` do webhook ou fazer mais nada entre as ondas.

Se duas ondas se sobrepuserem — você adiciona e processa mais documentos enquanto uma onda anterior ainda está em execução, em vez de após ela terminar — você receberá exatamente **uma** notificação, cobrindo todos os documentos de ambas as ondas, entregue assim que o último deles (de qualquer uma das ondas) terminar. Você só recebe duas notificações separadas quando o batch realmente ficou ocioso (todos os documentos atingiram um status terminal pelo menos uma vez) entre as duas chamadas de `process`.

Versões anteriores deste documento descreviam o disparo único como uma limitação permanente que você precisava contornar refazendo o `PUT` do webhook antes de cada onda. Esse conselho nunca funcionou de fato — o endpoint de registro era, e ainda é, deliberadamente descomplicado em relação ao `fired_at` (veja a [referência de Webhooks](/developers/reference/webhooks)) — e o rearmamento agora é tratado automaticamente pelo `process`. Se você está em uma integração baseada no conselho antigo, pode parar de registrar novamente antes de cada onda com segurança; isso nunca estava fazendo nada.

---

Source: https://imagetotable.ai/pt/developers/guides/webhooks
