Cufy API
Webhooks

Verificar y recibir

Cómo comprobar que un aviso viene de Cufy, cómo no procesarlo dos veces y cómo responder sin perder ninguno.

Tu receptor es una URL pública. Cualquiera puede enviarle un POST diciendo que una factura fue validada. Por eso cada aviso va firmado: antes de creerle a un evento, comprueba la firma.

Lo que recibes

Un POST con el evento en JSON y cuatro cabeceras:

CabeceraContenido
X-Cufy-TimestampSegundos Unix de cuando se firmó.
X-Cufy-Signaturev1= seguido del HMAC-SHA256 en hexadecimal.
X-Cufy-Key-IdCuál de tus secretos se usó. Importa durante una rotación.
X-Cufy-Attempt-IdIdentifica este intento de entrega, no el evento.

El cuerpo es mínimo a propósito: dice qué documento cambió, no cómo quedó. Consulta GET /v1/documents/{id} para ver su estado actual. Los archivos (PDF, XML) pueden tardar un poco más que el aviso fiscal.

Verificar la firma

Cinco reglas, y las cinco importan:

  1. La clave es el secreto tal como lo recibiste, como texto. No lo decodifiques de base64url.
  2. El mensaje son los bytes UTF-8 de timestamp + "." seguidos de los bytes exactos del cuerpo.
  3. Verifica antes de parsear el JSON. Firma sobre los bytes crudos que llegaron. Si parseas y vuelves a serializar para firmar, cualquier diferencia de espaciado o de orden rompe la comparación.
  4. Compara en tiempo constante. Un === normal filtra información sobre la firma esperada.
  5. Acepta hasta 300 segundos de diferencia de reloj. Más viejo, recházalo.
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(secret: string, timestamp: string, signature: string, body: Buffer) {
  if (
    !/^\d{1,12}$/.test(timestamp) ||
    !/^v1=[0-9a-f]{64}$/.test(signature) ||
    Math.abs(Date.now() / 1000 - Number(timestamp)) > 300
  )
    return false;
  const expected =
    'v1=' + createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex');
  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

El cuerpo crudo, no el objeto

En Express, express.json() ya consumió el cuerpo y te dejó un objeto: la firma no se puede verificar desde ahí. Usa express.raw({ type: 'application/json' }) en esta ruta, verifica, y solo entonces parsea.

Después de parsear, comprueba que company_id y environment son los tuyos. Una firma válida de otro ambiente sigue siendo un evento que no te toca.

Durante una rotación del secreto acepta el nuevo y el anterior a la vez, eligiendo por X-Cufy-Key-Id.

Recibir sin perder ni duplicar

  1. Guarda el evento de forma durable antes de responder 2xx. Responder primero y guardar después es cómo se pierden eventos.
  2. Deduplica por event_id, en la misma transacción que aplica el efecto en tu negocio. Cufy puede repetir un evento: si tu respuesta se perdió por el camino, lo reintenta aunque tú ya lo hubieras procesado.
  3. Tolera el desorden. resource_version es la versión de la transición que originó el aviso, no necesariamente la última. Puede llegarte document.validated después de algo posterior. Consulta el documento antes de actuar sobre él.
  4. Separa recibir de procesar. Responder 2xx significa "lo guardé", no "lo apliqué". Procesa tu bandeja aparte, a tu ritmo.
Tu respuestaQué entiende Cufy
2xxRecibido. No lo reintenta.
Cualquier otra, o ningunaLo reintenta (ver Reintentos y replay).

Un receptor que se porta bien

Si quieres una lista contra la que comparar el tuyo, esto es lo que hace el receptor que usamos para probar:

  • responde 401 a una firma inválida o a un cuerpo alterado, y 403 si el evento es de otra empresa o de otro ambiente;
  • corta el cuerpo en 8 KiB y responde 413 si se pasa;
  • guarda el evento en una bandeja durable, con deduplicación que sobrevive a reinicios, y solo entonces responde 204;
  • responde 503 si no pudo guardarlo, para que Cufy lo reintente en vez de darlo por entregado.

Ese último punto es el que más se olvida: si tu base de datos está caída, falla ruidosamente. Un 200 que no guardó nada es un evento perdido para siempre.

En esta página