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:
| Cabecera | Contenido |
|---|---|
X-Cufy-Timestamp | Segundos Unix de cuando se firmó. |
X-Cufy-Signature | v1= seguido del HMAC-SHA256 en hexadecimal. |
X-Cufy-Key-Id | Cuál de tus secretos se usó. Importa durante una rotación. |
X-Cufy-Attempt-Id | Identifica 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:
- La clave es el secreto tal como lo recibiste, como texto. No lo decodifiques de base64url.
- El mensaje son los bytes UTF-8 de
timestamp + "."seguidos de los bytes exactos del cuerpo. - 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.
- Compara en tiempo constante. Un
===normal filtra información sobre la firma esperada. - 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
- Guarda el evento de forma durable antes de responder
2xx. Responder primero y guardar después es cómo se pierden eventos. - 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. - Tolera el desorden.
resource_versiones la versión de la transición que originó el aviso, no necesariamente la última. Puede llegartedocument.validateddespués de algo posterior. Consulta el documento antes de actuar sobre él. - Separa recibir de procesar. Responder
2xxsignifica "lo guardé", no "lo apliqué". Procesa tu bandeja aparte, a tu ritmo.
| Tu respuesta | Qué entiende Cufy |
|---|---|
2xx | Recibido. No lo reintenta. |
| Cualquier otra, o ninguna | Lo 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
401a una firma inválida o a un cuerpo alterado, y403si el evento es de otra empresa o de otro ambiente; - corta el cuerpo en 8 KiB y responde
413si se pasa; - guarda el evento en una bandeja durable, con deduplicación que sobrevive a reinicios, y solo entonces responde
204; - responde
503si 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.