Errores y respuestas
Formato de todas las salidas del API, códigos HTTP y clasificación de rechazos.
Esta página cataloga todas las salidas posibles del API v1: el formato de las respuestas, los códigos HTTP y cómo interpretar un rechazo para decidir si reintentar, corregir o revisar.
Formatos de respuesta
Envolvente estándar (consultas y gestión)
Las operaciones de consulta (GET /v1/cpe/{external_id}) y de gestión de empresas
usan la envolvente estándar:
{
"success": true,
"message": "Comprobante encontrado.",
"data": { "document": { "…": "…" } }
}
En error, success es false y message describe el problema. Si hay errores de
validación de campos, llegan en errors (objeto campo → mensajes).
Respuesta de emisión (POST /v1/cpe)
La emisión firma y deja el envío encolado. Responde 202, y no trae el
CDR: el envío ocurre después.
{
"success": true,
"status": "queued",
"message": "Comprobante firmado. El envío a SUNAT quedó encolado.",
"external_id": "9c2f…",
"filename": "20123456789-01-F001-123",
"hash": "a3f1…9e2c",
"xml": "PD94bWwg…",
"time": 0.84
}
| Campo | Significado |
|---|---|
status |
queued: firmado y con el envío pendiente. to_send (envío manual) y to_summarize (boleta por resumen) son las variantes. |
xml |
El XML firmado (base64). Es lo que hace válido al comprobante; con esto ya puedes imprimirlo. |
external_id |
Identificador para consultar el resultado. Guárdalo. |
hash |
Hash de la firma digital del XML. |
warnings |
Solo si existe: observaciones no bloqueantes que conviene mirar. |
Un error acá significa que el comprobante no se firmó: 422 por validación o
402 por plan. Nada salió hacia SUNAT.
Resultado del envío (GET /v1/cpe/{external_id})
El veredicto de SUNAT se lee al consultar, en resolved y result:
{
"status_code": "09",
"status": "rejected",
"resolved": true,
"result": {
"code": "2335",
"message": "El XML no contiene información en el campo Total valor de venta",
"errors": ["2335: total del valor de venta descuadrado"],
"origin": "sunat",
"action": "correct",
"reached_sunat": true
}
}
resolved: false significa que sigue en camino — no es un error. result es
null mientras no haya habido ningún intento de envío.
Clasificación de rechazos
origin, origin_description, action y reached_sunat clasifican el fallo para
que tu integración decida automáticamente qué hacer. Aparecen en dos sitios:
- en la respuesta de emisión, solo para
validation(el422); - dentro de
result, al consultar el comprobante — ahí es donde llegan los veredictos de SUNAT y los fallos de envío.
origin |
Qué pasó | reached_sunat |
action |
|---|---|---|---|
validation |
El comprobante no pasó nuestra validación. No fue enviado. | false |
correct |
connection |
No se pudo comunicar con SUNAT/OSE. El comprobante no fue recibido. | false |
retry |
timeout |
Se envió, pero SUNAT/OSE no respondió a tiempo. No se sabe si llegó. | null |
review |
sunat |
SUNAT/OSE recibió el comprobante y lo rechazó. | true |
correct |
system |
Hubo comunicación pero falló el procesamiento de la respuesta. | true |
review |
Regla práctica: action es tu switch — retry (mismo payload, usa el mismo
Idempotency-Key), correct (arregla el payload y emite de nuevo con otra
clave), review (consulta el estado antes de actuar: puede haber quedado aceptado).
El error de integración más común
Cuando la validación previa frena un comprobante, la respuesta trae errors[] con
texto técnico (XSD, códigos SUNAT). Si tu integración muestra solo errors[0] y
descarta message, el usuario lee algo como [XSD] Element 'cbc:ID'… y concluye
que SUNAT rechazó el comprobante — cuando en realidad nunca salió de la API.
Los dos casos se distinguen por reached_sunat, nunca por el texto del error:
| Validación previa | Rechazo de SUNAT | |
|---|---|---|
| Dónde aparece | En la respuesta de POST /v1/cpe (422) |
En result, al consultar el comprobante |
origin |
validation |
sunat |
reached_sunat |
false |
true |
| ¿SUNAT lo vio? | No — no existe para SUNAT | Sí — quedó registrado el rechazo |
| Quién lo frenó | La validación de la API, antes de firmar | SUNAT/OSE |
En ambos casos action es correct, pero el mensaje al usuario es distinto: en el
primero el comprobante todavía no existe en ninguna parte; en el segundo SUNAT ya
evaluó y rechazó. Muestra siempre message (que ya trae ese contexto) y deja
errors[] como detalle secundario.
Regla de oro para no duplicar
Nunca reemitas un comprobante que ya se firmó. Consúltalo.
De los reintentos de envío nos encargamos nosotros, aplicando esta misma
regla: reintenta cuando reached_sunat es false, nunca cuando es null.
Lo que sigue en tus manos es no emitir de nuevo un comprobante que ya se
firmó. El Idempotency-Key te protege de reintentos del mismo request, no de
una segunda emisión con clave nueva.
Timeout: el caso donde no se sabe si llegó
Si SUNAT/OSE no responde a tiempo, el comprobante ya salió y no hay forma de
saber si fue recibido. Por eso el timeout no es un error de conexión, y por eso
no se reenvía solo: mandarlo otra vez podría duplicarlo. Así se ve dentro de
result al consultar:
{
"success": false,
"message": "Tiempo de espera agotado esperando la respuesta de SUNAT.",
"origin": "timeout",
"origin_description": "El comprobante se envió pero SUNAT/OSE no respondió a tiempo. NO se sabe si lo recibió: quedó Pendiente y el sistema consulta su estado automáticamente. Consulte el comprobante antes de reenviar — un reenvío podría duplicarlo.",
"action": "review",
"reached_sunat": null,
"external_id": "9c2f1b7e-…"
}
Qué hace la API por ti. El comprobante queda en estado Pendiente y un proceso automático consulta su estado real contra SUNAT cada 10 minutos, hasta resolverlo a Aceptado / Observado / Rechazado. Esa consulta es idempotente: no puede duplicar nada.
Qué debes hacer tú. Consulta GET /v1/cpe/{external_id} hasta que resolved
sea true. No reemitas mientras tanto.
Atención si tu empresa emite vía OSE. La conciliación automática usa el servicio de consulta de CDR, que es de SUNAT: no existe en el entorno de pruebas ni lo expone un OSE. Si emites a través de un OSE, un timeout no se resuelve solo — hay que consultar con el OSE antes de reintentar.
Árbol de decisión
La emisión y el resultado son dos momentos, y cada uno tiene su decisión.
Al emitir — solo puede fallar antes de la firma:
try {
const r = await POST('/v1/cpe', payload, { 'Idempotency-Key': clave })
// 202 = firmado y encolado. Imprime con r.xml y guarda r.external_id.
// Revisa r.warnings: observaciones NO bloqueantes.
guardar(r.external_id)
} catch (e) {
// Sin respuesta HTTP: el request nunca salió de tu equipo.
// Reintenta más tarde con la MISMA clave.
if (!e.response) return reintentarLuego(payload, clave)
// 422 validación · 402 plan · 401/403 token. En todos, el comprobante
// NO se firmó ni se encoló: nada llegó a SUNAT.
return mostrarAlUsuario(e.response.data?.message, e.response.data?.errors)
}
Al consultar — acá llega el veredicto:
const d = (await GET(`/v1/cpe/${externalId}`)).data.document
if (!d.resolved) return esperarYReconsultar() // sigue en camino
switch (d.result?.action) {
case 'correct': // SUNAT lo rechazó: arregla y emite con OTRA clave
return mostrarAlUsuario(d.result.message, d.result.errors)
case 'retry': // no llegó a salir — se reintenta solo
case 'review': // llegó, sin veredicto claro: NUNCA reenviar a ciegas
return revisarMasTarde(externalId)
}
// Sin `action`: aceptado u observado. El comprobante es válido.
El código HTTP no es el veredicto de SUNAT
Un 202 al emitir no significa que SUNAT lo aceptó — significa que el
comprobante se firmó y su envío quedó encolado. Y al consultar, un comprobante
rechazado llega con 200: el código HTTP dice si la API pudo hacer su trabajo,
no qué opinó SUNAT.
El veredicto está en resolved y, dentro de result, en estos campos:
resolved |
status |
Significado |
|---|---|---|
true |
accepted · observed |
Comprobante válido (observado también lo es). |
true |
rejected |
SUNAT lo rechazó. El motivo está en result. |
false |
registered · sent |
Todavía sin veredicto: sigue en camino. |
Por eso ningún código HTTP significa “aceptado”. Comprueba resolved y el estado
del comprobante antes de darlo por bueno.
Si lo que buscas es «¿qué hago con esta respuesta?», la tabla de combinaciones está en Cómo leer la respuesta.
Códigos HTTP
| Código | Cuándo ocurre | Ejemplos de message |
|---|---|---|
200 |
La consulta se completó. Incluye el comprobante rechazado — mira resolved y result, no el HTTP. También lo devuelve /api/cpe/enviar, que espera el veredicto de SUNAT. |
— |
202 |
El envío quedó encolado: en /v1 siempre, y en /api/cpe/enviar cuando SUNAT no contestó a tiempo. No es aceptación de SUNAT. |
Comprobante firmado. El envío a SUNAT quedó encolado. |
201 |
Empresa creada. | Empresa registrada. |
401 |
Token ausente, inválido o revocado; o token sin empresa asociada. | Token no asociado a una empresa. |
402 |
La empresa no puede emitir (plan). Ver detalle abajo. | Llegó al límite de firmas del plan por comprobante… |
403 |
El recurso no te pertenece o la cuenta/empresa está inactiva. | El comprobante no pertenece a esta empresa. · El RUC 20… no se encuentra activo. · El token no corresponde a una cuenta. |
404 |
Recurso inexistente. | El comprobante no existe. · Empresa no encontrada. |
409 |
Dos casos: el RUC ya está registrado en otra cuenta —reenvía con "confirm": true para reclamarlo—, o el entorno que declaraste en la ruta no es el de tu empresa. En el segundo no se firmó ni envió nada. |
No se emitió nada: la ruta declara entorno «demo» y el RUC 20… está configurado en «produccion». |
422 |
Validación: del comprobante (lista errors[], no se envió) o de campos del request (objeto errors{}). También reglas de negocio (p. ej. volver a demo una empresa que ya facturó). |
El comprobante no pasó la validación. No fue enviado a SUNAT. |
429 |
Límite de velocidad: 120 req/min por token. Espera y reintenta. | — |
Los antiguos 502 y 504 de emisión ya no existen: el envío ocurre fuera del
request, así que sus fallos se ven al consultar el comprobante, en resultado.
Detalle del 402 (bloqueos de emisión)
El guard de emisión corre antes de firmar — ninguno de estos casos genera ni envía nada:
| Situación | Mensaje |
|---|---|
| Plan por comprobante (01) sin cupo en la cuenta | Llegó al límite de firmas del plan por comprobante. Realice una recarga para continuar. |
| Plan por certificado (02) sin certificado asignado | La empresa está en plan por certificado pero no tiene un certificado asignado. |
| Certificado vencido | El certificado asignado venció el dd/mm/aaaa. Renueve para continuar. |
Notas: en demo nunca se bloquea. La vigencia del certificado arranca en la primera firma en producción (no en la compra ni en la asignación).
Ejemplo de 422 por validación del comprobante
{
"success": false,
"message": "El comprobante no pasó la validación. No fue enviado a SUNAT.",
"errors": [
"El campo client.numDoc es obligatorio para tipoDoc 01.",
"La suma de los items no coincide con mto_imp_venta."
],
"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
}
Idempotencia
En POST /v1/cpe y POST /v1/companies, envía el header
Idempotency-Key con un identificador propio de la operación (p. ej.
fac-F001-123). Si reintentas con la misma clave, recibes la misma
respuesta almacenada — nunca se emite dos veces. Usa una clave nueva cuando
corrijas el payload.