Webhooks
Recibe eventos firmados de Constaia: cuerpos de cada evento, verificación de la firma Standard Webhooks en siete lenguajes, reintentos, idempotencia y pruebas.
Constaia te avisa con una petición POST a tu servidor cuando algo termina: un análisis asíncrono, un lote, un análisis que necesita revisión humana o un saldo que se agota. Los webhooks siguen la especificación Standard Webhooks: cabeceras webhook-id, webhook-timestamp y webhook-signature, firma HMAC-SHA256.
Los necesitas si usas async: true, lotes, o si un análisis síncrono pasa de 30 s y responde 202.
Crea un endpoint en el panel o con POST /v1/webhook-endpoints. Elige los eventos y guarda el secreto whsec_…, que solo se muestra una vez.
Recibe el POST en una ruta pública https de tu backend y lee el cuerpo en crudo, sin parsear.
Verifica la firma con el secreto. Si no es válida, responde 400 y descarta el evento.
Responde 2xx enseguida y procesa el evento en segundo plano. Deduplica por webhook-id.
Eventos
| Evento | Cuándo se envía | data |
|---|---|---|
analysis.completed | Termina un análisis o clasificación que no es de un lote. | Objeto analysis (o classification). |
analysis.review_required | Un análisis termina con veredicto review. Se envía además de analysis.completed (y también para documentos de lotes). | Objeto analysis. |
analysis.failed | Falla un análisis, incluidos los de lotes. No se cobra. | Objeto analysis con status: "failed" y error. |
batch.completed | Terminan todos los documentos de un lote. | Objeto batch. |
credits.low | El saldo baja del umbral configurado en el panel. Solo en modo live, una vez hasta que recargues. | object, credits_available (packs), free_tier_remaining, credits_spendable y threshold. |
Todos los cuerpos tienen la misma forma: type, created_at y data. Los documentos de un lote no emiten analysis.completed: espera a batch.completed.
Los mensajes de reasons y checks vienen en el language con el que se creó el análisis.
Cabeceras
| Cabecera | Valor |
|---|---|
webhook-id | Id de la entrega, msg_…. Es el mismo en todos los reintentos: úsalo para deduplicar. |
webhook-timestamp | Momento del envío, en segundos Unix. Cambia en cada reintento. |
webhook-signature | v1,<firma en base64>. Puede traer varias firmas separadas por espacios; basta con que una coincida. |
content-type | application/json |
user-agent | Constaia-Webhooks/1.0 (+https://constaia.com/docs/webhooks) |
Cómo se calcula la firma
Quita el prefijo whsec_ del secreto y decodifica el resto en base64. Esos bytes son la clave HMAC. No uses el texto del secreto tal cual.
Construye el contenido firmado: {webhook-id}.{webhook-timestamp}.{cuerpo}, con el cuerpo exactamente como llegó (bytes crudos, sin parsear ni volver a serializar).
Calcula HMAC-SHA256(clave, contenido) y codifica el resultado en base64 (no hexadecimal).
Separa webhook-signature por espacios. Para cada entrada v1,<firma>, compara <firma> con la tuya en tiempo constante. Si ninguna coincide, rechaza.
Rechaza también si webhook-timestamp se aleja más de 5 minutos (300 s) de tu reloj. Evita ataques de repetición.
Verificar con los SDK
Los SDK hacen los cinco pasos y devuelven el evento parseado, o lanzan un error si algo no cuadra.
import express from "express";
import { Constaia, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
const app = express();
const constaia = new Constaia();
const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
// express.raw: el cuerpo llega como Buffer, sin parsear
app.post("/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
let event: WebhookEvent<any>;
try {
event = await constaia.webhooks.verify(req.body, req.headers, secret);
} catch (err) {
const status = err instanceof WebhookVerificationError ? 400 : 500;
return res.sendStatus(status);
}
res.sendStatus(204);
void handleEvent(req.header("webhook-id")!, event).catch(console.error);
});
async function handleEvent(deliveryId: string, event: WebhookEvent<any>) {
// Deduplica por deliveryId en tu base de datos antes de procesar
switch (event.type) {
case "analysis.completed":
console.log(deliveryId, event.data.id, event.data.verdict?.status);
break;
case "analysis.review_required":
console.log("A revisión:", event.data.id);
break;
case "analysis.failed":
console.log("Falló:", event.data.id, event.data.error?.code);
break;
case "batch.completed":
console.log("Lote terminado:", event.data.id, event.data.counts);
break;
case "credits.low":
console.log("Saldo bajo:", event.data.credits_available);
break;
}
}
app.listen(3000);verifyWebhook(payload, headers, secret, { tolerance }) hace lo mismo sin instanciar el cliente: import { verifyWebhook } from "@constaia/sdk".
Verificar sin SDK
Implementaciones completas del algoritmo. Todas reciben el cuerpo crudo.
import { createHmac, timingSafeEqual } from "node:crypto";
type Headers = Record<string, string | string[] | undefined>;
export function verifyConstaiaWebhook(rawBody: Buffer | string, headers: Headers, secret: string, tolerance = 300) {
const get = (name: string) => {
const value = headers[name];
return Array.isArray(value) ? value[0] : value;
};
const id = get("webhook-id");
const timestamp = get("webhook-timestamp");
const signatures = get("webhook-signature");
if (!id || !timestamp || !signatures) throw new Error("Faltan cabeceras del webhook");
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > tolerance) {
throw new Error("webhook-timestamp fuera de tolerancia");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),
);
const valid = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature);
return received.length === expected.length && timingSafeEqual(received, expected);
});
if (!valid) throw new Error("Firma no válida");
return JSON.parse(rawBody.toString());
}Las cabeceras de IncomingMessage (Node, Express) ya vienen en minúsculas.
El cuerpo crudo, framework a framework
El error más habitual es verificar un cuerpo que tu framework ya ha parseado y vuelto a serializar: cambian espacios, orden o escapes y la firma deja de coincidir. Lee siempre los bytes originales:
| Framework | Cómo leer el cuerpo crudo |
|---|---|
| Express | express.raw({ type: "application/json" }) en la ruta del webhook. Si usas app.use(express.json()) global, registra la ruta del webhook antes. |
| Next.js (App Router) | const raw = await req.text() en route.ts. No llames antes a req.json(). |
| Next.js (Pages Router) | export const config = { api: { bodyParser: false } } y lee el stream de req. |
| Fastify / Hono | Fastify: addContentTypeParser con parseAs: "buffer" dentro de un plugin que solo contenga la ruta del webhook. Hono: await c.req.text(). |
| Laravel | $request->getContent(), nunca $request->all(). Pon la ruta en routes/api.php o exclúyela del CSRF. |
| Symfony | $request->getContent(). |
| Django | request.body, con @csrf_exempt en la vista. |
| Flask / FastAPI | request.get_data() / await request.body(). |
| Rails | request.raw_post, en un controlador sin protect_from_forgery. |
| Spring Boot | @RequestBody byte[] body. |
| ASP.NET Core | Copia Request.Body a un MemoryStream antes de cualquier binding. |
Guías con la ruta de webhook completa: Express, Next.js, Laravel, Django.
Entregas y reintentos
- Una entrega tiene éxito si tu servidor responde cualquier
2xxen menos de 15 segundos. Las redirecciones no se siguen y cuentan como fallo, igual que un4xx, un5xxo un timeout. - Si falla, se reintenta tras: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 12 h, 12 h, 10 h y 10 h. Son 12 intentos en unos 3 días. Después la entrega queda como
failed. - Si desactivas o borras el endpoint, sus entregas pendientes se marcan como fallidas.
- En el panel ves las últimas 100 entregas de cada endpoint con su código de respuesta.
Buenas prácticas
- Responde rápido. Verifica, guarda el evento o mételo en una cola, responde
204y procesa después. Si tu lógica tarda más de 15 s, Constaia lo contará como fallo y reintentará. - Sé idempotente. Un mismo evento puede llegar más de una vez (por ejemplo, si respondiste tarde). Guarda los
webhook-idprocesados y descarta los repetidos. - No dependas del orden. Con reintentos, un evento antiguo puede llegar después de uno nuevo. Si necesitas el estado actual, consulta
GET /v1/analyses/{id}. - Trata
datacomo la fuente. El evento trae el objeto completo; no necesitas otra llamada salvo para refrescarlo. - Protege las URLs de
exports. Caducan a las 24 h y no requieren clave: no las registres en logs públicos.
Probar tus webhooks
Modo test
Los endpoints creados con una clave ck_test_ reciben los eventos de los análisis hechos con claves test. Así pruebas el flujo completo gratis: analiza dni_valid.jpg con async: true y recibirás analysis.completed; blurry.jpg genera además analysis.review_required. Ver Modo test. Para recibirlos en tu máquina, expón tu servidor local con un túnel https.
Evento de prueba
Desde el panel puedes enviar un evento type: "test" a cualquier endpoint. Se envía firmado como los reales, una sola vez, y sirve para comprobar la URL y la verificación.
Firmar tus propios payloads
Para tests automáticos, los SDK generan cabeceras válidas para un payload y un secreto:
import { signWebhook } from "@constaia/sdk";
const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
const payload = JSON.stringify({
type: "analysis.completed",
created_at: new Date().toISOString(),
data: { id: "an_test", object: "analysis", status: "completed", verdict: null, metadata: {} },
});
const headers = await signWebhook(payload, secret, { id: "msg_test_1" });
const res = await fetch("http://localhost:3000/webhooks/constaia", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: payload,
});
console.log(res.status); // 204Usa el secreto de un endpoint de test. Nunca pongas el secreto de producción en tests ni en repositorios.
Siguientes pasos
Endpoints de webhook
Referencia de /v1/webhook-endpoints: crea, lista, consulta y borra las URLs que reciben los eventos de Constaia, con su secreto whsec_ y su modo.
Veredictos y motivos
Cómo se calcula el veredicto valid, invalid o review a partir de la severidad de los motivos, tabla completa de códigos estables y qué hacer en cada caso.