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 el momento en que un batch finaliza. 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 propia especificación: webhook_secret aquí es una cadena hexadecimal simple, no un valor base64 con prefijo whsec_ — una librería 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 librería que reconozca 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 a nivel de cuenta, así que un secreto filtrado 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 volver a hacer PUT con la misma callback_url es una forma segura de obtenerlo de nuevo si lo pierdes (no rota el secreto ni reinicia nada).
Sobre de evento
Cada entrega es un objeto JSON envuelto en un sobre — nunca datos de batch sin procesar — para que la forma pueda crecer y cubrir futuros tipos de evento sin romper integraciones existentes. Revisa type para distinguir eventos; una URL de callback registrada en un batch puede recibir ambos tipos, 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 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 bbox, incluso si varios grupos de filas alcanzan un estado terminal en momentos cercanos en hilos distintos. La deduplicación habitual basada en webhook-id (consulta más abajo) sigue aplicándose además para el caso común de una entrega fallida que se reintenta.
Feed de eventos para triggers de polling
Los webhooks de callback de batch están limitados a un batch_name conocido. Los triggers de polling, como "New Completed Batch" o "Failed Extraction" de Zapier, necesitan un feed a nivel de cuenta: GET /api/v1/events. Devuelve eventos persistidos del más reciente al más antiguo, con un campo id estable que Zapier puede usar para deduplicación.
curl "https://imagetotable.ai/api/v1/events?type=batch.completed&limit=20" \
-H "Authorization: Bearer $API_KEY"{
"events": [
{
"id": "evt_3f9c9e2a1b7d4e2b9a3e2f5b6c7d8e9f",
"type": "batch.completed",
"created_at": "2026-08-04T08:31:22+00:00",
"batch_name": "260804-4K9P",
"document_id": null,
"status": "succeeded",
"document_count": 3,
"filename": null,
"error": null,
"batch_url": "https://imagetotable.ai/api/v1/batches/260804-4K9P",
"batch_results_url": "https://imagetotable.ai/api/v1/batches/260804-4K9P/results"
}
],
"has_more": false,
"next_page_token": null
}Los tipos de evento admitidos son batch.completed, document.completed y document.failed. Los eventos se escriben solo después de que el worker confirma el estado terminal del documento, así que un consumidor puede seguir document_url o batch_results_url de inmediato. Reprocesar un documento fallido o procesar una ola posterior en el mismo batch genera un nuevo id de evento; no dedupliques solo por batch_name o document_id.
Usa callbacks cuando ya conoces el batch y quieres reanudar una acción asíncrona en espera. Usa /events cuando un trigger necesita descubrir trabajo nuevo en toda la cuenta. Un trigger que pide al usuario ingresar manualmente un batch_name suele ser el modelo equivocado.
Verificación de firmas
Cada entrega incluye tres encabezados, según la especificación de Standard Webhooks:
| Encabezado | Propósito |
|---|---|
webhook-id | ID único para este intento de entrega. Úsalo para deduplicar: una entrega reintentada reutiliza el mismo ID. |
webhook-timestamp | Marca 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-signature | La 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 webhooks.
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 \
| base64import 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())Si estás probando esto a mano — guardando el cuerpo de una entrega en body.json tú mismo antes de
ejecutar uno de los fragmentos anteriores — cuidado con el salto de línea final que añade tu editor o método de captura.
La versión de curl/openssl de arriba es accidentalmente inmune a esto (el $(cat ...) de bash elimina los 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 cuela un \n extra — el síntoma clásico es "curl dice que es válido pero mi verificador de Python
dice que no lo es", 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 (el request.get_data() de Flask, el middleware de
cuerpo sin procesar de Express) en tu manejador de webhook real en lugar de copiar/pegar o guardarlo manualmente, y esto no
aparece en producción — es solo una trampa de las pruebas manuales.
Reintentos
La entrega se intenta una vez inmediatamente cuando el batch se completa. Si ese intento falla (timeout, error de conexión,
o una respuesta no-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 de esos tiene éxito dentro
de las 24 horas desde el primer intento, la entrega se marca como fallida y no ocurren 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 posterior lento no cause por sí
mismo un reintento.
Reprocesar un batch: una notificación por oleada
Una notificación batch.completed dada se dispara una vez, en el momento en que cada documento del batch ha alcanzado un
estado terminal. Si luego añades más documentos a ese mismo batch_name y llamas a
process de nuevo — "una nueva oleada" — serás notificado de nuevo una vez que 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 al webhook ni hacer nada más
entre oleadas.
Si dos oleadas se superponen — añades y procesas más documentos mientras una oleada anterior aún está en ejecución,
en lugar de después de que termine — recibirás exactamente una notificación, que cubre todos los documentos de
ambas oleadas, entregada una vez que el último de ellos (de cualquiera de las oleadas) termine. Solo recibes dos notificaciones separadas cuando
el batch realmente quedó inactivo (cada documento alcanzó un estado terminal al menos una vez) entre las dos
llamadas a process.
Las versiones anteriores de este documento describían el disparo único como una limitación permanente que tenías que solucionar
volviendo a hacer PUT al webhook antes de cada oleada. Ese consejo nunca funcionó realmente — el endpoint de registro
era, y sigue siendo, deliberadamente pasivo con respecto a fired_at (consulta la
referencia de Webhooks) — y el rearme
ahora se maneja automáticamente con process en su lugar. Si túre en una integración escrita según el
consejo anterior, puedes dejar de volver a registrarte antes de cada ola de forma segura; nunca estuvo haciendo nada.