XmlPeruDevDocs

Emitir comprobante

Genera el comprobante desde el JSON, lo firma, lo valida y lo manda a SUNAT/OSE. Devuelve el XML firmado sin esperar el CDR.

Autenticación: token Bearer de empresa (cpe:sign). Soporta Idempotency-Key.

Consideraciones

  • Envía el header Idempotency-Key: un reintento con la misma clave devuelve la misma respuesta sin duplicar la emisión.
  • El entorno (demo/producción) lo define la empresa del token, no el request.
  • El emisor (company: RUC, razón social y dirección) va en el payload y debe corresponder a la empresa del token.
  • En demo no se consume cupo ni se inicia la vigencia del certificado.
  • Plan por firma (01): requiere cupo disponible en la cuenta; si no, responde 402.
  • El cupo se cuenta por firma, no por comprobante: reemitir la misma serie y número —por ejemplo, tras un rechazo de SUNAT— lo vuelve a firmar y consume otra unidad. POST /v1/cpe/{external_id}/resend no firma de nuevo y no consume cupo.
  • Plan por certificado (02): requiere certificado asignado y vigente; su vigencia arranca con la primera firma en producción. Sin certificado o vencido: 402.
  • El payload acepta también resúmenes (RC/RA/RR) y guías (GRE 09/31) según tipoDoc.
  • Si la empresa lleva el envío por su cuenta (modo manual), la respuesta trae estado: "por_enviar" y el comprobante espera a que lo mandes con POST /v1/cpe/{external_id}/enviar.
  • La respuesta es 202: el comprobante quedó firmado —que es lo que lo hace válido— y su envío, encolado. No trae el CDR. El resultado se consulta con GET /v1/cpe/{external_id}.
  • Un error acá significa que el comprobante no se firmó: falló la validación (422) o el plan (402). Nada salió hacia SUNAT.
  • Los errores de envío —SUNAT caída, timeout, rechazo— no llegan en esta respuesta. Se ven al consultar el comprobante, y lo que se puede reintentar se reintenta solo.
POSThttps://api.xmlperu.dev/v1/cpeCopiar

Firmar y enviar son dos momentos

Este endpoint firma. El envío a SUNAT lo hace después un proceso aparte.

La separación no es un detalle de implementación: cambia lo que tu integración debe esperar. Antes la respuesta traía el CDR y con eso terminaba todo. Ahora trae el XML firmado —que es lo que hace válido al comprobante, y lo que necesitas para imprimirlo— y el resultado del envío se consulta después.

A cambio, la respuesta ya no depende de cuánto tarde SUNAT. Un punto de venta no deja al cliente esperando en caja porque el servicio de SUNAT esté lento.

POST /v1/cpe            → 202, xml firmado          (inmediato)
GET  /v1/cpe/{id}       → estado, resuelto: true    (cuando quieras)

Para no consultar en bucle, conviene esperar unos segundos antes del primer GET — la mayoría de los envíos se resuelven en ese lapso. Y si prefieres no preguntar en absoluto, configura el webhook: te avisamos nosotros cuando haya desenlace.

Qué pasa si SUNAT falla

Nada que tengas que manejar tú. La cola reintenta los fallos de conexión con esperas crecientes; un rechazo de SUNAT no se reintenta porque es una respuesta, no un fallo; y un timeout tampoco, porque el comprobante pudo haber llegado y reenviarlo lo duplicaría — ese caso lo resuelve un proceso que consulta el estado real hasta que SUNAT se pronuncia.

Lo único importante de tu lado: no reemitas un comprobante que ya se firmó. Consulta su estado.

El catálogo completo de códigos y la clasificación origin/action está en Errores y respuestas.

Headers

NameTypeDescription
Acceptstringapplication/json
Content-Typestringapplication/json
AuthorizationstringBearer <token>. Genéralo desde tu panel, en Tokens de API.
Idempotency-KeystringOpcional. Un reintento con la misma clave devuelve la misma respuesta sin duplicar.

Body

NameTypeDescription
tipoDoc*stringTipo de comprobante: 01 factura · 03 boleta · 07 nota de crédito · 08 nota de débito · 09/31 GRE · rc/ra/rr resúmenes.
serie*stringSerie del comprobante: 4 caracteres — una letra que depende del tipo (F factura, B boleta) y 3 alfanuméricos más (A-Z, 0-9). No hace falta que sean dígitos: F001, FA01 y BOL1 son válidas. En las notas de crédito y débito la letra la manda el documento que corrigen, no la nota.
correlativo*stringNúmero correlativo.
fechaEmision*stringFecha de emisión YYYY-MM-DD.
tipoMoneda*stringPEN o USD.
emisor*objectEmisor: ruc, razonSocial y establecimiento (ubigeo, codLocal, departamento, provincia, distrito, direccion, codigoPais). También se acepta company / address.
cliente*objectReceptor: tipoDoc (6=RUC, 1=DNI), numDoc, nombre. Su dirección, si la envías, va en address (ubigeo, direccion, codigoPais). También se acepta client / rznSocial.
items*arrayLíneas: descripcion, cantidad, unidad, valorUnitario, afectacionIgv, porcentajeIgv. También se acepta details / mtoValorUnitario / tipAfeIgv.
leyendasarrayLeyendas: codigo y valor. Ejemplo: código 1000 con el monto en letras. También se acepta legends / code / value.

Ejemplo de solicitud

Copiar
curl -X POST https://api.xmlperu.dev/v1/cpe \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: fac-F001-123" \
  -H "Content-Type: application/json" \
  -d '{
  "tipoDoc": "01",
  "serie": "F001",
  "correlativo": "123",
  "fechaEmision": "2026-07-27",
  "horaEmision": "10:30:00",
  "tipoMoneda": "PEN",
  "formaPago": "Contado",
  "tipoOperacion": "0101",
  "emisor": {
    "ruc": "20123456789",
    "razonSocial": "MI EMPRESA SAC",
    "establecimiento": {
      "ubigeo": "150101",
      "codLocal": "0000",
      "departamento": "LIMA",
      "provincia": "LIMA",
      "distrito": "LIMA",
      "direccion": "AV. EJEMPLO 123",
      "codigoPais": "PE"
    }
  },
  "cliente": {
    "tipoDoc": "6",
    "numDoc": "20100070970",
    "nombre": "CLIENTE SAC"
  },
  "items": [
    {
      "descripcion": "Servicio de desarrollo",
      "cantidad": 1,
      "unidad": "NIU",
      "valorUnitario": 100,
      "afectacionIgv": "10",
      "porcentajeIgv": 18
    }
  ],
  "leyendas": [
    {
      "codigo": "1000",
      "valor": "SON CIENTO DIECIOCHO CON 00/100 SOLES"
    }
  ]
}'

Respuesta

202 Firmado y encoladoapplication/json
El comprobante se firmó y su envío quedó en cola. El XML firmado llega en xml (base64): con eso ya puedes imprimirlo.
{
  "success": true,
  "status": "queued",
  "message": "Comprobante firmado. El envío a SUNAT quedó encolado.",
  "external_id": "9c2f1b7e-…",
  "filename": "20123456789-01-F001-123",
  "hash": "a3f1…9e2c",
  "environment": "02",
  "environment_name": "production",
  "xml": "PD94bWwg…",
  "time": 0.84
}
202 Firmado con observacionesapplication/json
Igual que el anterior, más un warnings[] con lo que conviene revisar. No bloquea: el comprobante se firmó y va en camino.
{
  "success": true,
  "status": "queued",
  "message": "Comprobante firmado. El envío a SUNAT quedó encolado.",
  "external_id": "9c2f1b7e-…",
  "filename": "20123456789-01-F001-123",
  "hash": "a3f1…9e2c",
  "environment": "02",
  "environment_name": "production",
  "xml": "PD94bWwg…",
  "warnings": [
    "El código de unidad NIU no es el habitual para servicios."
  ],
  "time": 0.86
}
409 Ya emitidoapplication/json
Ya existe un comprobante aceptado con esa serie y número. No se vuelve a emitir.
{
  "success": false,
  "message": "El comprobante F001-123 ya fue aceptado por SUNAT."
}
402 Sin cupo o certificadoapplication/json
La empresa no puede emitir. Nada fue generado ni encolado. Ver detalle del 402.
{
  "success": false,
  "message": "Llegó al límite de firmas del plan por comprobante. Realice una recarga para continuar."
}
422 No pasó la validaciónapplication/json
El comprobante no cumple la estructura o las reglas SUNAT. No se firmó ni se encoló. Corrige y emite con una clave de idempotencia nueva. No lo confundas con un rechazo de SUNAT, que se ve al consultar el comprobante: aquí nunca salió de la API. Ver el error de integración más común.
{
  "success": false,
  "message": "El comprobante no pasó la validación. No fue enviado a SUNAT. [SUNAT 1001] ID - El dato SERIE-CORRELATIVO no cumple con el formato",
  "errors": [
    "[SUNAT 1001] ID - El dato SERIE-CORRELATIVO no cumple con el formato",
    "[BR_SERIES_PREFIX] Prefijo de serie inválido. Se espera que la serie comience con 'F'."
  ],
  "details": [
    {
      "code": "BR_SERIES_PREFIX",
      "message": "Prefijo de serie inválido. Se espera que la serie comience con 'F'.",
      "path": null,
      "line": null,
      "col": null,
      "snippet": null
    }
  ],
  "time": 0.12,
  "origin": "validation",
  "origin_description": "El comprobante no pasó la validación local (estructura / reglas SUNAT). NO fue enviado; corríjalo y vuelva a emitir.",
  "action": "correct",
  "reached_sunat": false
}