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}/resendno 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 conPOST /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.
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
| 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. |
| Idempotency-Key | string | Opcional. Un reintento con la misma clave devuelve la misma respuesta sin duplicar. |
Body
| Name | Type | Description |
|---|---|---|
| tipoDoc* | string | Tipo 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* | string | Serie 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* | string | Número correlativo. |
| fechaEmision* | string | Fecha de emisión YYYY-MM-DD. |
| tipoMoneda* | string | PEN o USD. |
| emisor* | object | Emisor: ruc, razonSocial y establecimiento (ubigeo, codLocal, departamento, provincia, distrito, direccion, codigoPais). También se acepta company / address. |
| cliente* | object | Receptor: 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* | array | Líneas: descripcion, cantidad, unidad, valorUnitario, afectacionIgv, porcentajeIgv. También se acepta details / mtoValorUnitario / tipAfeIgv. |
| leyendas | array | Leyendas: codigo y valor. Ejemplo: código 1000 con el monto en letras. También se acepta legends / code / value. |
Ejemplo de solicitud
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"
}
]
}'<?php
$token_empresa = 'pega-aqui-tu-token';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.xmlperu.dev/v1/cpe');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token_empresa,
'Accept: application/json',
'Idempotency-Key: fac-F001-123',
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, <<<JSON
{
"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"
}
]
}
JSON);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);const TOKEN_EMPRESA = 'pega-aqui-tu-token';
const res = await fetch('https://api.xmlperu.dev/v1/cpe', {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN_EMPRESA}`,
Accept: 'application/json',
'Idempotency-Key': 'fac-F001-123',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"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"
}
]
}),
});
const data = await res.json();import requests
TOKEN_EMPRESA = "pega-aqui-tu-token"
headers = {
"Authorization": f"Bearer {TOKEN_EMPRESA}",
"Accept": "application/json",
"Idempotency-Key": "fac-F001-123",
}
payload = {
"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"
}
]
}
res = requests.post("https://api.xmlperu.dev/v1/cpe", json=payload, headers=headers)
data = res.json()Respuesta
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
}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
}{
"success": false,
"message": "El comprobante F001-123 ya fue aceptado por SUNAT."
}{
"success": false,
"message": "Llegó al límite de firmas del plan por comprobante. Realice una recarga para continuar."
}{
"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
}