Configurar el webhook
Registra la URL donde avisamos cuando un comprobante de la empresa queda resuelto. Con esto, tu integración deja de consultar en bucle.
Autenticación: token Bearer de cuenta (empresas:manage).
Consideraciones
- Un solo evento:
cpe.resuelto— el comprobante cruzó a aceptado, observado o rechazado. El rechazo también avisa: es un desenlace, no un fallo. - El cuerpo del aviso es el mismo objeto que devuelve GET /v1/cpe/{external_id}: no hay un formato aparte que aprender.
- Cada entrega llega firmada: cabecera
X-Firma= HMAC-SHA256 del cuerpo con tusecret. Verifícala siempre — sin eso, cualquiera que conozca tu URL puede inyectarte aceptados falsos. - El
secretse muestra una sola vez, al registrar la URL. Registrar una URL nueva genera un secret nuevo. - La URL debe ser
httpsy pública: no se aceptan hosts locales ni rangos privados. - Si tu endpoint no responde 2xx, reintentamos con esperas crecientes (30 s → 30 min, 5 intentos). Responde rápido (2xx y procesa después): 10 s de timeout.
- El webhook es un aviso, no el contrato: puede perderse.
GET /v1/cpe/{external_id}sigue siendo la fuente de verdad. "url": nulldesactiva el webhook.
Qué recibe tu endpoint
Un POST con estas cabeceras y el mismo document de la consulta:
POST /webhooks/cpe HTTP/1.1
Content-Type: application/json
X-Evento: cpe.resuelto
X-Entrega: 9c2f1b7e-… ← id único de esta entrega
X-Firma: 3f2a9c… ← HMAC-SHA256(cuerpo, secret)
{
"event": "cpe.resolved",
"sent_at": "2026-08-22T15:04:05-05:00",
"document": {
"external_id": "9c2f1b7e-…",
"filename": "20123456789-01-F001-123",
"document_type_id": "01",
"series": "F001",
"number": 123,
"status_code": "09",
"status": "rejected",
"resolved": true,
"ticket": null,
"result": {
"code": "2335",
"message": "El XML no contiene información en el campo Total valor de venta",
"origin": "sunat",
"action": "correct",
"reached_sunat": true
}
}
}
Verificar la firma
Calcula el HMAC sobre el cuerpo crudo (antes de parsear el JSON) y compara en tiempo constante:
const crypto = require('crypto')
function esAutentico(cuerpoCrudo, firmaRecibida, secret) {
const esperada = crypto.createHmac('sha256', secret).update(cuerpoCrudo).digest('hex')
return crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(firmaRecibida))
}
$esperada = hash_hmac('sha256', $cuerpoCrudo, $secret);
$ok = hash_equals($esperada, $firmaRecibida);
Un aviso con firma inválida se descarta sin procesarlo.
Buenas prácticas del lado receptor
- Responde 2xx de inmediato y procesa después: el timeout es de 10 s, y un procesamiento lento de tu lado se convierte en reintentos nuestros.
- Sé idempotente. Un reintento puede llegar después de que ya procesaste la
entrega original — usa
X-Entregao elexternal_idpara deduplicar. - No dependas solo del webhook. Si tu endpoint estuvo caído más allá de los
reintentos, el aviso se pierde; el comprobante sigue consultable con
GET /v1/cpe/{external_id}.
Headers
| Name | Type | Description |
|---|---|---|
| Accept | string | application/json |
| Content-Type | string | application/json |
| Authorization | string | Bearer <token>. Genéralo desde tu panel, en Tokens de API. |
Parámetros de URL
| Name | Type | Description |
|---|---|---|
| ruc* | string | RUC de la empresa (11 dígitos). |
Body
| Name | Type | Description |
|---|---|---|
| url* | string | URL https pública que recibirá los avisos, o `null` para desactivar. |
Ejemplo de solicitud
Copiar
curl -X PATCH https://api.xmlperu.dev/v1/companies/20123456789/webhook \
-H "Authorization: Bearer $TOKEN_CUENTA" \
-H "Accept: application/json"Copiar
<?php
$token_cuenta = 'pega-aqui-tu-token';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.xmlperu.dev/v1/companies/20123456789/webhook');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token_cuenta,
'Accept: application/json',
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);Copiar
const TOKEN_CUENTA = 'pega-aqui-tu-token';
const res = await fetch('https://api.xmlperu.dev/v1/companies/20123456789/webhook', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TOKEN_CUENTA}`,
Accept: 'application/json',
},
});
const data = await res.json();Copiar
import requests
TOKEN_CUENTA = "pega-aqui-tu-token"
headers = {
"Authorization": f"Bearer {TOKEN_CUENTA}",
"Accept": "application/json",
}
res = requests.patch("https://api.xmlperu.dev/v1/companies/20123456789/webhook", headers=headers)
data = res.json()Respuesta
200 Configuradoapplication/json
Guarda el
secret ahora: no se vuelve a mostrar.{
"success": true,
"message": "Webhook configurado.",
"data": {
"webhook": {
"url": "https://mi-sistema.com/webhooks/cpe",
"event": "cpe.resolved",
"secret": "a3f19e2c…48 caracteres…",
"note": "Guarda el secret ahora: no se vuelve a mostrar. Cada entrega llega con la cabecera X-Firma = HMAC-SHA256(cuerpo, secret)."
}
}
}200 Desactivadoapplication/json
{
"success": true,
"message": "Webhook desactivado."
}422 URL no válidaapplication/json
{
"success": false,
"message": "La URL del webhook debe ser pública: no se aceptan hosts locales ni rangos privados."
}