Guía

Webhooks

En lugar de hacer polling a GET /batches/{batch_name} hasta que termine, registra una URL de callback una vez y recibe un HTTP POST en cuanto el batch finalice. Los tres encabezados de firma y la construcción de contenido firmado id.timestamp.body siguen la especificación abierta Standard Webhooks — el mismo enfoque que usan los webhooks de OpenAI. Una diferencia con la convención de la especificación: webhook_secret aquí es una cadena hexadecimal simple, no un valor base64 con prefijo whsec_ — una biblioteca verificadora Standard Webhooks/Svix estándar intentará decodificarlo en base64 y fallará. Usa el secreto exactamente como se devuelve, como bytes de clave HMAC sin procesar, con el código de verificación de abajo (o tu propio equivalente) en lugar de una biblioteca que entienda prefijos.

Registrar un webhook

Registra (o actualiza) el callback de un batch con PUT /api/v1/batches/{batch_name}/webhook:

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"}'

La respuesta incluye un webhook_secret por webhook, generado para ti — un secreto por registro, no una clave compartida de toda la cuenta, así que si se filtra solo afecta a ese callback:

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

Guarda webhook_secret — lo necesitarás para verificar las entregas entrantes. No hay un endpoint separado para "revelar el secreto", pero hacer un nuevo PUT con el mismo callback_url es una forma segura de obtenerlo de nuevo si lo pierdes (no rota el secreto ni reinicia nada).

Envoltura del evento

Cada entrega es un objeto JSON envuelto en una envoltura — nunca datos del batch en bruto — así que la estructura puede crecer para cubrir futuros tipos de evento sin romper integraciones existentes. Revisa type para diferenciar eventos; una URL de callback registrada en un batch puede recibir ambos tipos que se muestran abajo, mezclados.

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

data.status refleja el resultado consolidado del batch (succeeded o failed — consulta Async Task Model para el vocabulario completo de estados). El payload es un aviso de finalización, no los resultados en sí — llama a GET /batches/{batch_name}/results después de recibirlo para obtener los datos extraídos reales.

Activar un trabajo de anotación de bbox en un documento reutiliza el webhook ya registrado para el batch de ese documento y entrega un evento distinto en lugar de otro batch.completed:

{
  "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"
  }
}

Al igual que batch.completed, la entrega de este evento se reclama atómicamente, así que no recibirás dos entregas separadas de document.bbox_completed para un mismo trabajo de bbox aunque varios grupos de filas alcancen un estado terminal casi al mismo tiempo en hilos diferentes. La deduplicación habitual basada en webhook-id (consulta más abajo) sigue aplicándose además de esto para el caso normal de una entrega fallida que se reintenta.

Verificación de firmas

Cada entrega incluye tres encabezados, según la especificación de Standard Webhooks:

EncabezadoPropósito
webhook-idID único para este intento de entrega. Úsalo para deduplicar: una entrega reintentada reutiliza el mismo ID.
webhook-timestampMarca de tiempo Unix de cuándo se envió la entrega, para proteger contra ataques de repetición (rechaza entregas con una marca de tiempo demasiado antigua).
webhook-signatureLa firma en sí, formateada como v1,<firma base64>.

La firma es un HMAC-SHA256 sobre la concatenación de webhook-id, webhook-timestamp y el cuerpo de solicitud sin procesar — en ese orden, unidos con . — usando como clave tu webhook_secret:

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

Recalcula esto en tu lado y compáralo (usando una comparación en tiempo constante) contra el valor después de v1, en el encabezado webhook-signature. Siempre verifica contra los bytes del cuerpo de solicitud sin procesar, no contra una versión re-serializada del JSON analizado — re-serializar puede cambiar espacios en blanco/orden de las claves y producir un desajuste de firma incluso en una entrega genuina.

# La verificación de firmas no es una llamada HTTP, así que esto la recrea con
# openssl contra una entrega que ya hayas guardado en disco (headers.txt /
# body.json) — útil para confirmar tu comprensión del esquema a mano
# antes de implementar el código de verificación real en tu manejador 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
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())
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);

Si estás probando esto a mano — guardando el cuerpo de una entrega en body.json antes de ejecutar uno de los fragmentos de arriba — cuidado con un salto de línea final añadido por tu editor o método de captura. La versión con curl/openssl arriba es accidentalmente inmune a esto (el $(cat ...) de bash elimina saltos de línea finales), pero las versiones de Python y JavaScript leen los bytes exactos del archivo y producirán silenciosamente una firma que no coincide si se coló un \n extra — el síntoma clásico es "curl dice que es válido pero mi verificador en Python dice que no", lo cual es confuso precisamente porque el código en sí no tiene ningún error. Captura el cuerpo mediante el accesor de cuerpo de solicitud sin procesar de tu framework web (request.get_data() de Flask, middleware raw-body de Express) en tu manejador de webhook real en lugar de copiar/pegar o guardarlo manualmente, y esto no ocurre en producción — es solo una trampa de pruebas manuales.

Reintentos

El envío se intenta una vez inmediatamente cuando el batch se completa. Si ese intento falla (timeout, error de conexión o una respuesta que no sea 2xx), se reintenta con retroceso exponencial: 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 24 horas — hasta 7 intentos en total (el intento inicial más 6 reintentos). Si ninguno tiene éxito dentro de las 24 horas posteriores al primer intento, el envío se marca como fallido y no se realizan más reintentos. Haz que tu endpoint responda con un estado 2xx tan pronto como hayas registrado el evento de forma duradera — haz el procesamiento lento (como obtener y analizar los resultados del batch) después de responder, no antes, para que un paso downstream lento no provoque por sí mismo un reintento.

Reprocesar un batch: una notificación por oleada

Una notificación batch.completed determinada se dispara una vez, en el momento en que cada documento del batch ha alcanzado un estado terminal. Si luego agregas más documentos a ese mismo batch_name y llamas a process nuevamente — "una nueva oleada" — recibirás una notificación nuevamente cuando esa oleada también termine, automáticamente. process_batch rearma el webhook por sí mismo en el momento en que encuentra nuevos documentos elegibles en un batch cuya oleada anterior ya te notificó; no necesitas volver a hacer PUT del webhook ni hacer nada más entre oleadas.

Si dos oleadas se superponen — agregas y procesas más documentos mientras una oleada anterior aún se está ejecutando, en lugar de después de que haya terminado — recibirás exactamente una notificación, que cubre todos los documentos de ambas oleadas, entregada una vez que el último de ellos (de cualquier oleada) termine. Solo recibes dos notificaciones separadas cuando el batch realmente estuvo inactivo (cada documento alcanzó un estado terminal al menos una vez) entre las dos llamadas a process.

Versiones anteriores de este documento describían el disparo único como una limitación permanente que debías solucionar volviendo a hacer PUT del webhook antes de cada oleada. Ese consejo nunca funcionó realmente — el endpoint de registro era, y sigue siendo, deliberadamente no intervencionista con respecto a fired_at (consulta la referencia de Webhooks) — y ahora process se encarga automáticamente de rearmar. Si estás en una integración escrita siguiendo el consejo anterior, puedes dejar de volver a registrar antes de cada oleada de forma segura; nunca estuvo haciendo nada.

📮 contact email: [email protected]