XmlPeruDevDocs

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 tu secret. Verifícala siempre — sin eso, cualquiera que conozca tu URL puede inyectarte aceptados falsos.
  • El secret se muestra una sola vez, al registrar la URL. Registrar una URL nueva genera un secret nuevo.
  • La URL debe ser https y 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": null desactiva el webhook.
PATCHhttps://api.xmlperu.dev/v1/companies/{ruc}/webhookCopiar

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-Entrega o el external_id para 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

NameTypeDescription
Acceptstringapplication/json
Content-Typestringapplication/json
AuthorizationstringBearer <token>. Genéralo desde tu panel, en Tokens de API.

Parámetros de URL

NameTypeDescription
ruc*stringRUC de la empresa (11 dígitos).

Body

NameTypeDescription
url*stringURL 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"

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."
}