XmlPeruDevDocs

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 (el 422);
  • 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.