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
| Evento | Cuándo se emite |
|---|---|
document.validated | Pasó las validaciones de negocio |
document.submitted | SUNAT recibió el envío |
document.accepted | CDR de aceptación |
document.observed | CDR con observaciones |
document.rejected | CDR de rechazo |
document.failed | Falló de forma definitiva |
document.ambiguous | No se puede establecer si el envío ocurrió |
document.pdf_ready | Una 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 secretoTodas 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: 1755622245El 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
- Contestar rápido y encolar. Contesta
200en cuanto hayas guardado el mensaje; procesa después. Si procesas antes de contestar, un proceso lento se convierte en un reintento. - Deduplicar por
eventId. Los reintentos son parte del diseño, así que la misma entrega puede llegar más de una vez.eventIdes estable entre intentos. - No asumir orden.
document.acceptedpuede llegar antes quedocument.submitteddel mismo comprobante si el primero se reintentó. El estado autoritativo siempre esGET /api/v2/documents/{id}. - Rechazar lo que no verifica. Firma mala, timestamp fuera de tolerancia o
schemaVersiondesconocida: descarta y registra. No proceses un mensaje que no puedas probar que es nuestro.