Fac-360

Webhooks

Los ocho eventos, cómo se firma cada entrega y cómo verificarla.

En vez de preguntar por el estado de cada comprobante, deja que la plataforma te avise. Una suscripción es una URL tuya, una lista de eventos y un secreto.

Los ocho eventos

EventoCuándo se emite
document.validatedPasó las validaciones de negocio
document.submittedSUNAT recibió el envío
document.acceptedCDR de aceptación
document.observedCDR con observaciones
document.rejectedCDR de rechazo
document.failedFalló de forma definitiva
document.ambiguousNo se puede establecer si el envío ocurrió
document.pdf_readyUna representación PDF quedó lista

No hay más, y esa lista no es una selección: es el conjunto de transiciones que el sistema efectivamente produce hoy. Publicar un evento que no puede ocurrir sería anunciar una capacidad que no existe.

document.ambiguous tiene su propio tipo y no se mezcla con document.failed a propósito. Un receptor que trate los dos igual reintentará algo que quizá ya llegó a SUNAT. Ver Ciclo de vida.

Suscribirse

POST   /api/v2/webhooks/subscriptions                    # crear
GET    /api/v2/webhooks/subscriptions                    # listar
PATCH  /api/v2/webhooks/subscriptions/{id}               # url, eventos, o pausar
DELETE /api/v2/webhooks/subscriptions/{id}               # borrar
POST   /api/v2/webhooks/subscriptions/{id}/secret-rotations   # rotar el secreto

Todas piden webhooks:manage. Una suscripción está ACTIVE o PAUSED: pausar es la forma de dejar de recibir sin perder la configuración ni el secreto.

El secreto se devuelve una sola vez, al crear y al rotar. No hay forma de volver a leerlo después —ni para ti ni para nosotros: el rol de base de datos que atiende la API no tiene permiso de lectura sobre esa columna—. Si lo pierdes, rótalo.

La URL de destino se comprueba antes de admitirse: se rechazan direcciones IP literales, URLs con credenciales, puertos no estándar y sufijos internos. Un webhook no es un canal para que alguien haga que la plataforma llame a su red interna.

El cuerpo que recibes

{
  "schemaVersion": "1.0",
  "eventId": "…",
  "eventType": "document.accepted",
  "occurredAt": "2026-08-19T15:04:05.000Z",
  "subscriptionId": "…",
  "deliveryId": "…",
  "data": {}
}

schemaVersion está fijo en 1.0 para todo el catálogo. Si algún día ves 2.0, tu receptor debe poder rechazar el mensaje en vez de adivinar — y eso solo es posible porque el campo estuvo ahí desde la primera entrega. Los cambios aditivos mantienen 1.0; cualquier eliminación o reinterpretación de un campo acuña 2.0, y las dos versiones se entregarían en paralelo mientras las suscripciones migran.

El número de intento no viaja en el cuerpo: va en la cabecera Apifact-Delivery-Attempt. Así los bytes firmados de una entrega son idénticos en cada reintento, y puedes deduplicar por el hash del cuerpo además de por eventId.

La firma

Cada entrega lleva dos cabeceras:

Apifact-Signature: t=1755622245,v1=<64 caracteres hex>
Apifact-Timestamp: 1755622245

El material firmado es, literalmente:

apifact:webhook:v1\0<timestamp>\0<cuerpo crudo>

HMAC-SHA-256 con tu secreto, digest en hexadecimal. Tres detalles que importan:

  • El prefijo es separación de dominio. Un secreto usado para una cosa no debe producir una firma válida para otra.
  • El timestamp está dentro de la firma. Una firma que solo cubriera el cuerpo se podría repetir para siempre, porque el atacante sería libre de mover el timestamp.
  • Los separadores son NUL. La concatenación no es ambigua: ningún par timestamp/cuerpo se puede volver a partir de otra forma.

Verificarla

import { createHmac, timingSafeEqual } from "node:crypto";

export function verificar(secreto, cabecera, cuerpoCrudo, toleranciaSegundos = 300) {
  const m = /^t=(\d{1,15}),v1=([0-9a-f]{64})$/.exec(cabecera);
  if (!m) return false;

  const [, t, firmaRecibida] = m;
  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - Number(t)) > toleranciaSegundos) return false;

  const esperada = createHmac("sha256", secreto)
    .update(`apifact:webhook:v1\0${t}\0${cuerpoCrudo}`)
    .digest("hex");

  return timingSafeEqual(Buffer.from(esperada), Buffer.from(firmaRecibida));
}

Firma sobre el cuerpo crudo, no sobre el JSON reserializado. JSON.parse seguido de JSON.stringify reordena claves y cambia el espaciado, y entonces la firma no cuadra aunque el mensaje sea legítimo. En Express, express.raw(); en un Worker, await request.text() antes de parsear.

La tolerancia recomendada es de 300 segundos. Y compara en tiempo constante: una comparación normal de cadenas filtra, por lo que tarda, dónde empieza a diferir.

Reintentos

Tu endpoint debe contestar 2xx. Cualquier otra cosa —o no contestar en 10 segundos— cuenta como fallo.

Un fallo se reintenta con espera de 2^intento segundos, tope de una hora, y se agota a los 12 intentos. Es exactamente la misma curva que usa el publicador interno de la plataforma: un receptor caído toda una mañana y una cola atascada toda una mañana quieren la misma curva, y tener dos curvas distintas en un sistema es una cosa más sobre la que equivocarse.

Qué debe hacer tu receptor

  1. Contestar rápido y encolar. Contesta 200 en cuanto hayas guardado el mensaje; procesa después. Si procesas antes de contestar, un proceso lento se convierte en un reintento.
  2. Deduplicar por eventId. Los reintentos son parte del diseño, así que la misma entrega puede llegar más de una vez. eventId es estable entre intentos.
  3. No asumir orden. document.accepted puede llegar antes que document.submitted del mismo comprobante si el primero se reintentó. El estado autoritativo siempre es GET /api/v2/documents/{id}.
  4. Rechazar lo que no verifica. Firma mala, timestamp fuera de tolerancia o schemaVersion desconocida: descarta y registra. No proceses un mensaje que no puedas probar que es nuestro.

On this page