Guía

Webhooks

En lugar de hacer polling a GET /batches/{batch_name} hasta que termine, registra una URL de callback una sola vez y recibe un HTTP POST en el momento en que el batch finaliza. Los tres encabezados de firma y la construcción de contenido firmado id.timestamp.body siguen la Standard Webhooks especificación abierta — 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 biblioteca verificadora estándar de Standard Webhooks/Svix 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 siguiente (o tu propio equivalente) en lugar de una biblioteca 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 envoltura de eventos

Cada entrega es un objeto JSON envuelto en una envoltura — nunca datos de batch sin procesar — para que la forma pueda crecer y cubrir futuros tipos de eventos sin romper integraciones existentes. Revisa type para distinguir eventos; una URL de callback registrada en un batch puede recibir ambos tipos siguientes, 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.

Disparar 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, por lo 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 abajo) sigue aplicándose además para el caso común de una entrega fallida que se reintenta.

Feed de eventos para disparadores de sondeo

Los webhooks de callback de batch están limitados a un batch_name conocido. Los disparadores de sondeo, como el "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, por lo 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 disparador necesita descubrir trabajo nuevo en toda la cuenta. Un disparador que pide al usuario ingresar manualmente un batch_name suele ser el modelo incorrecto.

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]