Constaia

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

EventoCuándo se envíadata
analysis.completedTermina un análisis o clasificación que no es de un lote.Objeto analysis (o classification).
analysis.review_requiredUn análisis termina con veredicto review. Se envía además de analysis.completed (y también para documentos de lotes).Objeto analysis.
analysis.failedFalla un análisis, incluidos los de lotes. No se cobra.Objeto analysis con status: "failed" y error.
batch.completedTerminan todos los documentos de un lote.Objeto batch.
credits.lowEl 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

CabeceraValor
webhook-idId de la entrega, msg_…. Es el mismo en todos los reintentos: úsalo para deduplicar.
webhook-timestampMomento del envío, en segundos Unix. Cambia en cada reintento.
webhook-signaturev1,<firma en base64>. Puede traer varias firmas separadas por espacios; basta con que una coincida.
content-typeapplication/json
user-agentConstaia-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.

server.ts
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.

verify-constaia.ts
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:

FrameworkCómo leer el cuerpo crudo
Expressexpress.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 / HonoFastify: 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().
Djangorequest.body, con @csrf_exempt en la vista.
Flask / FastAPIrequest.get_data() / await request.body().
Railsrequest.raw_post, en un controlador sin protect_from_forgery.
Spring Boot@RequestBody byte[] body.
ASP.NET CoreCopia 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 2xx en menos de 15 segundos. Las redirecciones no se siguen y cuentan como fallo, igual que un 4xx, un 5xx o 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 204 y 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-id procesados 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 data como 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:

webhook.test.ts
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); // 204

Usa el secreto de un endpoint de test. Nunca pongas el secreto de producción en tests ni en repositorios.

Siguientes pasos

En esta página