# API de Facturación Electrónica FactuSmart (SUNAT, Perú) — documentación completa > API REST para emitir facturas, boletas, notas de crédito y débito, guías de remisión (GRE 2.0) y anulaciones ante SUNAT (Perú) desde cualquier sistema. Envías la venta en JSON; FactuSmart arma el XML UBL 2.1, lo firma, lo envía a SUNAT y te devuelve el estado, el PDF, el XML y el CDR. Este archivo trae TODO lo necesario para integrar sin leer nada más. Versión de la API: 1.0.0 · Generado el 2026-10-14 desde la especificación OpenAPI: https://factusmart.pe/api/openapi.yaml Guía para humanos: https://factusmart.pe/api-facturacion-electronica/docs · Inicio rápido: https://factusmart.pe/api-facturacion-electronica/docs/inicio-rapido · SDKs: https://factusmart.pe/api-facturacion-electronica/docs/sdks · Postman: https://factusmart.pe/factusmart-api.postman_collection.json ## Índice 1. Lo esencial (léelo primero) 2. Autenticación, llaves y URL base 3. Flujo de emisión completo (curl): /calcular → /documents 4. Regla de oro: HTTP 200 no es "aceptado por SUNAT" 5. Créditos y pruebas sin costo (modo prueba) 6. Idempotencia y límites 7. Anulaciones (facturas y boletas) 8. Guías de remisión 9. Webhooks y verificación de firma 10. SDKs oficiales (PHP, Node.js, Python, Java, C#) 11. Errores del API: qué hacer con cada `codigo` 12. Errores SUNAT comunes 13. Conceptos generales (texto de la especificación) 14. Referencia de endpoints (todos, con request y response) 15. Webhook: formato del aviso 16. Esquemas 17. Checklist para el agente que integra ## 1. Lo esencial (léelo primero) - URL base: `https://{servidor}.factusmart.pe/api/v1/issuer` (`servidor` = s1, s2, s3 o s4). La exacta está en el portal del integrador (Configuración → "URL base de la API"). Una API key solo vale en su servidor. - Autenticación: header `X-API-Key: TU_API_KEY` en todas las peticiones (alternativa: `Authorization: Bearer TU_API_KEY`). - Todo es JSON (`Content-Type: application/json`, envía también `Accept: application/json`), salvo las descargas de PDF/XML/CDR. - Para armar `items` y `totales` **usa `POST /calcular`** (gratis): devuelve el JSON exacto para `POST /documents`. No calcules el IGV a mano. - HTTP 200 al emitir **no** significa aceptado: revisa `aceptado_por_sunat` (true solo si `estado` = `05`). - Guarda el `external_id` de cada comprobante: es la clave para consultar, descargar, reenviar y anular. - Manda `Idempotency-Key` (el id de tu venta) en `POST /documents`: un reintento nunca emite dos veces. - Decide los errores por `codigo` y `reintentable`, nunca comparando `message`. - RUC ilimitados. Por seguridad la cuenta empieza con 50 y los amplías tú mismo, gratis, con un clic en tu portal (Empresas → Ampliar límite). - Cada RUC nace en **modo prueba** (SUNAT beta, sin validez fiscal): emitir, anular y enviar guías ahí **no cuesta créditos** (tope 200 operaciones por día y cuenta). - Códigos SUNAT (unidades, afectación IGV, motivos…): `GET /catalogos` y `GET /catalogos/{nombre}`. - PDF en ticket: `GET /documents/{external_id}/pdf?formato=ticket` (`a4`, `a5`, `ticket`/`ticket80`, `ticket58`, `ticket50`). Sin `formato`, el A4 de siempre; pedir otro formato no cambia el PDF que recibe el cliente final. - Varios locales (anexos SUNAT): `GET /establecimientos` y `POST /establecimientos`; para emitir desde un local manda `codigo_anexo` (ej. `"0001"`) o solo una serie de ese local en `serie_documento`. ## 2. Autenticación, llaves y URL base URL base: `https://{servidor}.factusmart.pe/api/v1/issuer`, con `servidor` en `s1`, `s2`, `s3`, `s4`. Usa la que muestra tu portal de integrador: se arma con el host del portal donde te registraste. ```bash curl -s https://s2.factusmart.pe/api/v1/issuer/ping -H "X-API-Key: TU_API_KEY" ``` Respuesta: `{"success": true, "message": "Autenticado correctamente", "cuenta": "...", "creditos": 50, "tipo_llave": "prueba"}` (más `ruc_emisor` y `razon_social` si se resolvió un RUC). Dos llaves por cuenta, misma URL base: - **Llave de producción** (64 letras y números, **sin prefijo**): opera en todos tus RUCs. Las llaves de producción que ya tenías antes de que existiera la llave de prueba son esto mismo: no llevan prefijo y siguen siendo válidas, no hay que cambiarlas. - **Llave de prueba** (empieza con `fs_test_`, opcional, se genera en Portal → Configuración): solo emite, anula, envía guías, reenvía y consulta SUNAT en RUCs en **modo prueba**. Si apunta a un RUC en producción responde `403 LLAVE_DE_PRUEBA_EN_PRODUCCION`. Las lecturas (consultar, listar, descargar) funcionan en todos los RUCs. Úsala en desarrollo y staging. ¿Cuál estoy usando? `GET /ping` lo dice en `tipo_llave`: `"produccion"` (cualquier llave sin el prefijo `fs_test_`, incluidas las antiguas) o `"prueba"`. RUC emisor: si la cuenta tiene un solo RUC se usa solo. Si tiene varios, indícalo con `ruc` (en el body o `?ruc=`), `emisor_ruc` o el header `X-RUC`. Los RUCs se crean con `POST /rucs` (nacen en modo prueba) y pasan a producción desde su panel web. RUC ilimitados. Por seguridad la cuenta empieza con 50 y los amplías tú mismo, gratis, con un clic en tu portal (Empresas → Ampliar límite). ## 3. Flujo de emisión completo (curl) Paso 1 — Calcula items y totales (no consume crédito, no necesita RUC): ```bash curl -s -X POST https://s2.factusmart.pe/api/v1/issuer/calcular \ -H "X-API-Key: TU_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"items":[{"descripcion":"Menu","cantidad":3,"precio_unitario":10,"afectacion":"10","unidad_de_medida":"ZZ","codigo_interno":"M1"}]}' ``` ```json {"success":true, "items":[{"codigo_interno":"M1","descripcion":"Menu","unidad_de_medida":"ZZ","cantidad":3,"valor_unitario":8.47,"codigo_tipo_precio":"01","precio_unitario":10,"codigo_tipo_afectacion_igv":"10","total_base_igv":25.42,"porcentaje_igv":18,"total_igv":4.58,"total_impuestos":4.58,"total_valor_item":25.42,"total_item":30}], "totales":{"total_operaciones_gravadas":25.42,"total_operaciones_exoneradas":0,"total_operaciones_inafectas":0,"total_exportacion":0,"total_igv":4.58,"total_impuestos":4.58,"total_valor":25.42,"total_venta":30}} ``` - `valor_unitario` = precio SIN IGV; `precio_unitario` = precio CON IGV (manda uno de los dos). - `afectacion`: `10` gravado, `20` exonerado, `30` inafecto, `40` exportación. - `porcentaje_igv` opcional: 18 (por defecto) o 10.5 (Ley 31556, solo si el RUC está acogido). - Descuentos todavía no: envía el precio ya descontado. - `unidad_de_medida` (catálogo SUNAT 03): `NIU` (unidad) para **bienes/productos**, `ZZ` para **servicios** (menú servido, delivery, consultoría, mantenimiento…). Si vendes por peso o volumen usa el código que corresponda (`KGM` kilos, `LTR` litros…). Si no la mandas, `/calcular` pone `NIU`. Lista completa: `GET /catalogos/03` (o `GET /catalogos/unidades`; acepta `?q=` para filtrar). Ejemplo con líneas **gravadas y exoneradas mezcladas** (cada ítem lleva su propia `afectacion`; `/calcular` reparte las bases en `total_operaciones_gravadas` y `total_operaciones_exoneradas`). Respuesta real del API: ```bash curl -s -X POST https://s2.factusmart.pe/api/v1/issuer/calcular \ -H "X-API-Key: TU_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"items":[ {"codigo_interno":"ARZ5","descripcion":"Arroz extra 5 kg","cantidad":2,"precio_unitario":22.5,"afectacion":"20","unidad_de_medida":"NIU"}, {"codigo_interno":"GAS15","descripcion":"Gaseosa 1.5 L","cantidad":3,"precio_unitario":7,"afectacion":"10","unidad_de_medida":"NIU"}, {"codigo_interno":"DEL","descripcion":"Delivery","cantidad":1,"precio_unitario":5,"afectacion":"10","unidad_de_medida":"ZZ"}]}' ``` ```json {"success":true, "items":[ {"codigo_interno":"ARZ5","descripcion":"Arroz extra 5 kg","unidad_de_medida":"NIU","cantidad":2,"valor_unitario":22.5,"codigo_tipo_precio":"01","precio_unitario":22.5,"codigo_tipo_afectacion_igv":"20","total_base_igv":45,"porcentaje_igv":0,"total_igv":0,"total_impuestos":0,"total_valor_item":45,"total_item":45}, {"codigo_interno":"GAS15","descripcion":"Gaseosa 1.5 L","unidad_de_medida":"NIU","cantidad":3,"valor_unitario":5.93,"codigo_tipo_precio":"01","precio_unitario":7,"codigo_tipo_afectacion_igv":"10","total_base_igv":17.8,"porcentaje_igv":18,"total_igv":3.2,"total_impuestos":3.2,"total_valor_item":17.8,"total_item":21}, {"codigo_interno":"DEL","descripcion":"Delivery","unidad_de_medida":"ZZ","cantidad":1,"valor_unitario":4.24,"codigo_tipo_precio":"01","precio_unitario":5,"codigo_tipo_afectacion_igv":"10","total_base_igv":4.24,"porcentaje_igv":18,"total_igv":0.76,"total_impuestos":0.76,"total_valor_item":4.24,"total_item":5}], "totales":{"total_operaciones_gravadas":22.04,"total_operaciones_exoneradas":45,"total_operaciones_inafectas":0,"total_exportacion":0,"total_igv":3.96,"total_impuestos":3.96,"total_valor":67.04,"total_venta":71}} ``` Se emite igual que cualquier comprobante (`codigo_tipo_operacion` `0101`, copiando `items` y `totales`). Usa `20` (exonerado) solo para bienes o servicios que la ley exonera del IGV; si tienes dudas sobre un producto, que lo confirme el contador del emisor. Paso 2 — Emite copiando `items` y `totales` tal cual (boleta a cliente con DNI): ```bash curl -s -X POST https://s2.factusmart.pe/api/v1/issuer/documents \ -H "X-API-Key: TU_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \ -H "Idempotency-Key: venta-000123" \ -d '{ "ruc": "20100070970", "numero_documento": "#", "fecha_de_emision": "2026-10-14", "hora_de_emision": "10:30:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "03", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": {"codigo_tipo_documento_identidad": "1", "numero_documento": "12345678"}, "items": [{"codigo_interno":"M1","descripcion":"Menu","unidad_de_medida":"ZZ","cantidad":3,"valor_unitario":8.47,"codigo_tipo_precio":"01","precio_unitario":10,"codigo_tipo_afectacion_igv":"10","total_base_igv":25.42,"porcentaje_igv":18,"total_igv":4.58,"total_impuestos":4.58,"total_valor_item":25.42,"total_item":30}], "totales": {"total_operaciones_gravadas":25.42,"total_operaciones_exoneradas":0,"total_operaciones_inafectas":0,"total_exportacion":0,"total_igv":4.58,"total_impuestos":4.58,"total_valor":25.42,"total_venta":30} }' ``` Con `jq` puedes encadenar los dos pasos sin copiar a mano: ```bash CALC=$(curl -s -X POST "$URL_BASE/calcular" -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"items":[{"descripcion":"Menu","cantidad":3,"precio_unitario":10,"afectacion":"10"}]}') echo "$CALC" | jq '{ruc:"20100070970", numero_documento:"#", fecha_de_emision:"2026-10-14", hora_de_emision:"10:30:00", codigo_tipo_operacion:"0101", codigo_tipo_documento:"03", codigo_tipo_moneda:"PEN", datos_del_cliente_o_receptor:{codigo_tipo_documento_identidad:"1", numero_documento:"12345678"}} + {items, totales}' \ | curl -s -X POST "$URL_BASE/documents" -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: venta-000123" -d @- ``` Reglas de la cabecera: - `codigo_tipo_documento`: `01` factura, `03` boleta, `07` nota de crédito, `08` nota de débito. - `numero_documento: "#"` → FactuSmart asigna el correlativo. `serie_documento` es opcional si el establecimiento tiene una sola serie de ese tipo (consulta `GET /series`; los nombres reales son tipo FA01/BA01, no F001/B001). - `fecha_de_emision` (YYYY-MM-DD) y `hora_de_emision` (HH:MM:SS, 24 h) van **en hora de Perú** (zona `America/Lima`, UTC-5, sin horario de verano) y deben ser la fecha y hora reales. Son **obligatorias: el API no las completa** si faltan (la emisión se rechaza con `422` y no se cobra). Ver "Fecha y hora en hora de Perú" más abajo. - Factura con operación `0101` exige cliente con RUC (`codigo_tipo_documento_identidad: "6"`). Boleta mayor a S/ 700 exige cliente identificado. Con RUC o DNI basta el número: nombre y dirección se autocompletan. - Exportación: factura con `codigo_tipo_operacion` `0200` y afectación `40`. Paso 3 — Lee la respuesta: ```json {"success": true, "estado": "05", "estado_descripcion": "Aceptado", "serie_numero": "BA01-35", "external_id": "ad4d29e9-7510-4efd-963e-3f8b6fa2facd", "sunat": {"codigo": "0", "descripcion": "La Boleta numero BA01-35, ha sido aceptada"}, "creditos_restantes": 50, "credito_cobrado": false, "modo_demo": true, "pruebas_restantes_hoy": 199, "aceptado_por_sunat": true, "entorno": "prueba", "tiene_validez_fiscal": false} ``` Paso 4 — Si `aceptado_por_sunat` es false, mira `estado`, `sunat.codigo` y `accion_requerida` (sección 4). Para enterarte de cambios posteriores usa webhooks (sección 9) o `GET /documents/{external_id}`. Descarga archivos con `GET /documents/{external_id}/pdf` (o `xml`, `cdr`): si tu cuenta tiene **varios RUC**, agrega `?ruc=20100070970` (o el header `X-RUC`), si no responde `422 RUC_REQUERIDO`. Basta el header `X-API-Key`: **no hace falta un header `Accept` especial**. La respuesta `200` es el archivo (PDF, XML o ZIP del CDR); si el archivo no existe responde `404` en JSON, así que revisa el código HTTP antes de guardarlo. ```bash curl -s -o BA01-35.pdf -w "%{http_code}\n" \ "https://s2.factusmart.pe/api/v1/issuer/documents/ad4d29e9-7510-4efd-963e-3f8b6fa2facd/pdf?ruc=20100070970" \ -H "X-API-Key: TU_API_KEY" ``` ### Fecha y hora en hora de Perú `fecha_de_emision` y `hora_de_emision` se interpretan en hora de Perú (`America/Lima`, UTC-5). No uses la fecha local del servidor: un servidor en UTC (lo normal en la nube) **después de las 19:00 de Perú ya está en el día siguiente** y mandaría la fecha de mañana. Calcúlalas siempre con la zona explícita. En Node: ```js function ahoraEnPeru(d = new Date()) { const p = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Lima', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }).formatToParts(d).map(({ type, value }) => [type, value]), ); return { fecha: `${p.year}-${p.month}-${p.day}`, hora: `${p.hour}:${p.minute}:${p.second}` }; } // ahoraEnPeru() → { fecha: '2026-10-14', hora: '10:30:00' } ``` El API **no** pone la fecha ni la hora por ti: si las omites, la emisión se rechaza con `422` (no se cobra). ### Ejemplo completo en Node.js con fetch (sin SDK): calcular → emitir → PDF Node 18 o superior (`fetch` nativo). La `Idempotency-Key` es **el id de la venta en tu sistema**, no un UUID nuevo en cada intento: si la petición se corta (timeout, caída de red) y reintentas, con la misma key el API te devuelve el comprobante que ya emitió, sin emitir otro ni cobrar dos veces. Con un UUID nuevo por intento, cada reintento sería una emisión distinta: comprobante duplicado ante SUNAT y un correlativo quemado. La key vale por cuenta (no por RUC): si emites para varios RUC desde sistemas con ids que pueden repetirse, inclúyelo en la key (`20100070970-venta-123`). ```js import { writeFile } from 'node:fs/promises'; const BASE = process.env.FACTUSMART_URL_BASE; // ej. https://s2.factusmart.pe/api/v1/issuer const KEY = process.env.FACTUSMART_API_KEY; const RUC = '20100070970'; // ahoraEnPeru(): ver "Fecha y hora en hora de Perú" function ahoraEnPeru(d = new Date()) { const p = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Lima', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }).formatToParts(d).map(({ type, value }) => [type, value]), ); return { fecha: `${p.year}-${p.month}-${p.day}`, hora: `${p.hour}:${p.minute}:${p.second}` }; } async function api(path, body, headers = {}) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json', Accept: 'application/json', ...headers }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { // Decide por data.codigo y data.reintentable, nunca por el texto de message. throw Object.assign(new Error(`${res.status} ${data.codigo}: ${data.message}`), { status: res.status, data }); } return data; } // venta = registro de TU sistema: { id, dni, lineas: [{ sku, nombre, cantidad, precio }] } export async function emitirBoleta(venta) { // 1) items y totales exactos (gratis, no necesita RUC) const { items, totales } = await api('/calcular', { items: venta.lineas.map((l) => ({ codigo_interno: l.sku, descripcion: l.nombre, cantidad: l.cantidad, precio_unitario: l.precio, afectacion: '10', unidad_de_medida: 'NIU', })), }); // 2) emitir. Idempotency-Key = id de la venta: el MISMO valor en cada reintento. const { fecha, hora } = ahoraEnPeru(); const body = { ruc: RUC, numero_documento: '#', fecha_de_emision: fecha, hora_de_emision: hora, codigo_tipo_operacion: '0101', codigo_tipo_documento: '03', codigo_tipo_moneda: 'PEN', datos_del_cliente_o_receptor: { codigo_tipo_documento_identidad: '1', numero_documento: venta.dni }, items, totales, }; let doc; for (let intento = 1; ; intento++) { try { doc = await api('/documents', body, { 'Idempotency-Key': `venta-${venta.id}` }); break; } catch (e) { const reintentable = !e.status || e.data?.reintentable; // sin status = error de red/timeout if (!reintentable || intento === 3) throw e; await new Promise((r) => setTimeout(r, 3000 * intento)); // 409 EMISION_EN_CURSO, 429, caída } } // Guarda esto en tu venta. HTTP 200 NO es "aceptado": mira aceptado_por_sunat. // { external_id, serie_numero, estado, aceptado_por_sunat } if (!doc.aceptado_por_sunat) console.warn(doc.serie_numero, doc.estado, doc.sunat, doc.accion_requerida); // 3) PDF (con ?ruc= si la cuenta tiene varios RUC; sin Accept especial) const pdf = await fetch(`${BASE}/documents/${doc.external_id}/pdf?ruc=${RUC}`, { headers: { 'X-API-Key': KEY } }); if (!pdf.ok) throw new Error(`PDF ${pdf.status}: ${(await pdf.json()).message}`); await writeFile(`${doc.serie_numero}.pdf`, Buffer.from(await pdf.arrayBuffer())); return doc; } ``` ## 4. Regla de oro: HTTP 200 no es "aceptado por SUNAT" Un `200` en `POST /documents` significa que el comprobante se generó. SUNAT puede estar caída, tardar o rechazarlo. Revisa siempre `aceptado_por_sunat` y `estado`: | `estado` | Significado | Qué hacer | |---|---|---| | `05` | Aceptado | Listo. `aceptado_por_sunat: true`. | | `07` | Observado | Válido, con observaciones de SUNAT. Revisa `sunat.descripcion`. | | `01` | Registrado, SUNAT no lo confirmó | **No reemitas** (duplicarías y quemarías un correlativo). Lee `sunat.codigo`: si es caída/timeout, `POST /documents/{external_id}/reenviar` más tarde (FactuSmart también reintenta solo 3 veces al día); si dudas si llegó, `POST /documents/{external_id}/consultar-sunat`. | | `09` | Rechazado | Corrige los datos y emite un comprobante nuevo. | | `03` | Enviado, esperando respuesta | Consulta más tarde. | | `11` | Anulado | — | | `13` | Por anular | Anulación enviada, esperando a SUNAT. | Una vez al día corre `GET /documents?estado=01` para encontrar comprobantes atascados. ## 5. Créditos y pruebas sin costo (modo prueba) - 1 crédito = 1 comprobante (factura, boleta, nota de crédito, nota de débito, guía) o 1 anulación. - Planes: todo prepago, sin cobro automático ni tarjeta, precios sin IGV. Cada plan (mensual o anual) da un **cupo mensual** de comprobantes, **no acumulable**: lo que no se usa en el mes se pierde. - Al registrarse la cuenta recibe **50 créditos de regalo**, por única vez, para empezar a emitir en producción (con validez fiscal). Al contratar el primer plan, los que queden se reemplazan por el cupo del plan (no se suman). - No se venden recargas sueltas: quien ya tiene plan puede pedir **créditos extra** para su plan. Al emitir se consume primero el cupo del plan y luego los créditos extra. Los créditos extra y los de regalo vencen al año; los avisos por correo de saldo bajo (20% y 10%) aplican solo a esos créditos, no al cupo del plan. - En un RUC en **producción** cuesta 1 crédito cada comprobante emitido, cada guía enviada y cada anulación enviada. Si la operación falla con `422` el crédito se devuelve. Si el comprobante se genera pero SUNAT no lo acepta (estado `01`) la respuesta es `200` y sí se cobra; el reintento con `/reenviar` es gratis. Sin saldo → `402 SIN_CREDITOS` (sube de plan o pide créditos extra para tu plan). - En un RUC en **modo prueba** (SUNAT beta) emitir, enviar guías y anular **no cuesta créditos**, con cualquiera de las dos llaves. Tope: **200 operaciones de prueba por cuenta y por día** (todas las empresas juntas), se reinicia a medianoche hora de Perú; una operación que falla (`422`) no cuenta. Al pasarlo: `429` con `codigo: LIMITE_PRUEBAS_DIARIO` y `reintentable: false` (no reintentes hoy). - Las respuestas de `POST /documents`, `/guias` y `/anulaciones` traen `creditos_restantes` (saldo real) y `credito_cobrado` (bool); en modo prueba además `modo_demo: true` y `pruebas_restantes_hoy`. - Gratis siempre: consultar, listar, descargar, historial, series, saldo, `/calcular` y `/catalogos`. ## 6. Idempotencia y límites - `Idempotency-Key` (solo en `POST /documents`): usa un valor único por venta (el id de la venta en tu sistema). Reintentar con la misma key devuelve la misma respuesta sin reemitir ni recobrar. Si la primera petición sigue en curso → `409 EMISION_EN_CURSO` (reintentable, espera unos segundos). Si la emisión falló (`402`/`422`), la key se libera. **No generes un UUID nuevo en cada intento**: la key tiene que salir de tu venta (mismo valor en todos los reintentos), si no la protección no sirve y un reintento tras un timeout emite un segundo comprobante. La key es única por cuenta (no por RUC). Ejemplo completo en la sección 3 ("Ejemplo completo en Node.js"). - Límites: 120 peticiones/minuto por API key y 60/minuto por IP. Al superarlos: `429 LIMITE_PETICIONES` con header `Retry-After` (segundos); espera y reintenta. - Hay dos `429` distintos: `LIMITE_PETICIONES` (reintentable) y `LIMITE_PRUEBAS_DIARIO` (no reintentable). Decide por `codigo`. ## 7. Anulaciones (facturas y boletas) `POST /anulaciones` anula facturas **y** boletas. Es asíncrono: devuelve un `ticket`; el comprobante pasa a `13` y queda en `11` cuando SUNAT acepta. | Mandas | SUNAT lo anula con | `tipo` | `identificador` | |---|---|---|---| | Facturas y sus notas | Comunicación de baja | `baja` | `RA-AAAAMMDD-N` | | Boletas y sus notas | Resumen diario de anulación | `resumen` | `RC-AAAAMMDD-N` | | Facturas y boletas juntas | — | `422 ANULACION_MIXTA` | — | ```bash curl -s -X POST https://s2.factusmart.pe/api/v1/issuer/anulaciones \ -H "X-API-Key: TU_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"ruc":"20100070970","fecha_de_emision_de_documentos":"14-10-2026", "documentos":[{"external_id":"a3af5a50-dfe9-41e9-a4be-6de52c0b5c61","motivo_anulacion":"Error en el comprobante"}]}' ``` - `fecha_de_emision_de_documentos` va en **DD-MM-AAAA** y debe ser exactamente la fecha de emisión de todos los comprobantes listados. - Boletas: solo las aceptadas por SUNAT (`05`). Plazo: el configurado por la empresa (por defecto 7 días desde la emisión). - Una petición por tipo: nunca mezcles facturas y boletas. - Resultado: `GET /anulaciones/{ticket}/status` → `estado` de la anulación (`05` aceptada, `09` rechazada, `03` enviada) y `documentos[].estado` de cada comprobante (`11` anulado, `13` por anular). Si SUNAT rechaza un resumen de boletas, las boletas vuelven a `01`. - Para corregir un comprobante aceptado también puedes emitir una nota de crédito (`codigo_tipo_documento: "07"`) con `documento_afectado`. ## 8. Guías de remisión `POST /guias` genera, firma y envía la guía GRE 2.0 (asíncrono). Envía siempre `serie_documento` (ej. `TA01` remitente, `VA01` transportista); el establecimiento va en `datos_del_emisor.codigo_del_domicilio_fiscal`. Revisa `enviado` en la respuesta: si es `false` la guía quedó creada pero no se envió (faltan credenciales GRE del RUC) y no se cobró. Consulta el resultado con `GET /guias/{external_id}/status`. Motivos de traslado y modalidades: `GET /catalogos/motivos-traslado` y `GET /catalogos/modalidad-transporte`. Ejemplo completo en la referencia (sección 14). ## 9. Webhooks y verificación de firma Configura una URL `https://` por cuenta en el portal del integrador (pestaña Webhooks; no hay endpoint del API para esto). Recibirás un POST cuando un comprobante emitido por el API pase a aceptado (`05`), observado (`07`), rechazado (`09`) o anulado (`11`). Header `X-FactuSmart-Firma` = HMAC-SHA256 en hex del **cuerpo crudo** con tu secreto `whsec_…`. Responde `2xx` en menos de 8 s; si no, hay 6 intentos (inmediato, +1 min, +5 min, +30 min, +2 h, +6 h). Deduplica por `id`. Verificación sin SDK: ```js // Node.js import crypto from 'node:crypto' const esperada = crypto.createHmac('sha256', secreto).update(cuerpoCrudo).digest('hex') const valida = esperada.length === firma.length && crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(firma)) ``` ```php // PHP $esperada = hash_hmac('sha256', $cuerpoCrudo, $secreto); $valida = hash_equals($esperada, $firma); ``` ```python # Python import hmac, hashlib esperada = hmac.new(secreto.encode(), cuerpo_crudo, hashlib.sha256).hexdigest() valida = hmac.compare_digest(esperada, firma) ``` Con Express usa `express.raw({ type: 'application/json' })` para tener el cuerpo crudo (no el JSON ya parseado). Si la firma no coincide, responde `401`. Si el proceso es largo, responde `200` de inmediato y procesa después. Formato completo del aviso en la sección 15. ## 10. SDKs oficiales (PHP, Node.js, Python, Java, C#) ```bash npm install factusmart # Node.js composer require factusmart/sdk # PHP pip install factusmart # Python ``` PHP 0.2.0 (Packagist), Node.js 1.1.0 (npm) y Python 0.2.0 (PyPI) ya están publicados. Código fuente: https://github.com/factusmart/factusmart-php · https://github.com/factusmart/factusmart-node · https://github.com/factusmart/factusmart-python Java y C# se instalan desde su release en GitHub (pronto en Maven Central / NuGet): - **Java 11+** (sin dependencias): descarga `factusmart-0.1.0.jar` de https://github.com/factusmart/factusmart-java/releases/tag/v0.1.0 y ponlo en `libs/`. Gradle: `implementation files('libs/factusmart-0.1.0.jar')`. Maven: `mvn install:install-file -Dfile=libs/factusmart-0.1.0.jar -DgroupId=pe.factusmart -DartifactId=factusmart -Dversion=0.1.0 -Dpackaging=jar` y luego la dependencia `pe.factusmart:factusmart:0.1.0` (o `system` con ``). - **C#** (`netstandard2.0` y `net8.0`): descarga `FactuSmart.0.1.0.nupkg` de https://github.com/factusmart/factusmart-dotnet/releases/tag/v0.1.0 a una carpeta (ej. `./paquetes`) y ejecuta `dotnet add package FactuSmart --source ./paquetes`. Node.js: ```js import { FactuSmart } from 'factusmart' const fs = new FactuSmart({ apiKey: 'TU_API_KEY', baseUrl: 'https://TU_URL_BASE/api/v1/issuer', ruc: '20100070970' }) const { items, totales } = FactuSmart.calcularComprobante([ { descripcion: 'Servicio de consultoría', cantidad: 1, valor_unitario: 100, afectacion: '10' }, ]) const r = await fs.comprobantes.emitir({ codigo_tipo_documento: '01', datos_del_cliente_o_receptor: { codigo_tipo_documento_identidad: '6', numero_documento: '20131312955' }, items, totales, }, { idempotencyKey: 'venta-000123' }) console.log(r.aceptado_por_sunat, r.serie_numero) ``` PHP: ```php 'TU_API_KEY', 'baseUrl' => 'https://TU_URL_BASE/api/v1/issuer', 'ruc' => '20100070970']); ['items' => $items, 'totales' => $totales] = FactuSmart\Cliente::calcularComprobante([ ['descripcion' => 'Servicio de consultoría', 'cantidad' => 1, 'valor_unitario' => 100, 'afectacion' => '10'], ]); $r = $fs->comprobantes->emitir([ 'codigo_tipo_documento' => '01', 'datos_del_cliente_o_receptor' => ['codigo_tipo_documento_identidad' => '6', 'numero_documento' => '20131312955'], 'items' => $items, 'totales' => $totales, ], ['idempotencyKey' => 'venta-000123']); var_dump($r['aceptado_por_sunat'], $r['serie_numero']); ``` Python: ```python import factusmart fs = factusmart.Cliente(api_key="TU_API_KEY", base_url="https://TU_URL_BASE/api/v1/issuer", ruc="20100070970") calc = factusmart.calcular_comprobante([ {"descripcion": "Servicio de consultoría", "cantidad": 1, "valor_unitario": 100, "afectacion": "10"}, ]) r = fs.comprobantes.emitir({ "codigo_tipo_documento": "01", "datos_del_cliente_o_receptor": {"codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955"}, "items": calc["items"], "totales": calc["totales"], }, idempotency_key="venta-000123") print(r["aceptado_por_sunat"], r["serie_numero"]) ``` Java: ```java import pe.factusmart.FactuSmart; import pe.factusmart.Json; import java.util.Map; FactuSmart fs = FactuSmart.builder().apiKey("TU_API_KEY").baseUrl("https://TU_URL_BASE/api/v1/issuer").ruc("20100070970").build(); Map calc = FactuSmart.calcularComprobante(Json.lista( Json.objeto("descripcion", "Servicio de consultoría", "cantidad", 1, "valor_unitario", 100, "afectacion", "10"))); Map r = fs.comprobantes.emitir(Json.objeto( "codigo_tipo_documento", "01", "datos_del_cliente_o_receptor", Json.objeto("codigo_tipo_documento_identidad", "6", "numero_documento", "20131312955"), "items", calc.get("items"), "totales", calc.get("totales"))); System.out.println(r.get("aceptado_por_sunat") + " " + r.get("serie_numero")); ``` C#: ```csharp using FactuSmart; var fs = new FactuSmartClient(new FactuSmartOptions { ApiKey = "TU_API_KEY", BaseUrl = "https://TU_URL_BASE/api/v1/issuer", Ruc = "20100070970" }); var calc = FactuSmartClient.CalcularComprobante(new[] { new { descripcion = "Servicio de consultoría", cantidad = 1, valor_unitario = 100m, afectacion = "10" }, }); var r = await fs.Comprobantes.EmitirAsync(new { codigo_tipo_documento = "01", datos_del_cliente_o_receptor = new { codigo_tipo_documento_identidad = "6", numero_documento = "20131312955" }, items = calc.Items, totales = calc.Totales, }); Console.WriteLine($"{r.GetProperty("aceptado_por_sunat")} {r.GetProperty("serie_numero")}"); ``` - `calcularComprobante` / `calcular_comprobante` del SDK calcula en local y da el mismo resultado que `POST /calcular`. - Métodos (Node/PHP/Java; en Python igual con snake_case; en C# PascalCase + `Async`, ej. `fs.Comprobantes.EmitirAsync`): `ping()`, `cuenta.saldo()`, `series.listar()`, `establecimientos.listar()`, `establecimientos.crear(datos)`, `comprobantes.emitir/listar/consultar/descargar/reenviar/consultarSunat/historial`, `anulaciones.crear/estado`, `guias.emitir/estado`, `rucs.listar/crear/activar`. En PHP con flecha: `$fs->comprobantes->emitir(...)`. - Locales (anexos): Node/PHP/Java `establecimientos.listar()`, `establecimientos.crear(datos)`; Python igual; C# `fs.Establecimientos.ListarAsync()`, `fs.Establecimientos.CrearAsync(datos)`. - PDF en ticket: Node/PHP/Java `comprobantes.descargar(id, 'pdf', { formato: 'ticket' })`; Python `comprobantes.descargar(id, 'pdf', formato='ticket')`; C# `fs.Comprobantes.DescargarAsync(id, "pdf", formato: "ticket")`. - Excepciones: `ErrorDeAutenticacion` (401), `SinCreditos` (402), `RucNoAutorizado` (403), `NoEncontrado` (404), `ErrorDeValidacion` (422), `LimiteDePeticiones` (429), base `FactuSmartError` (`FactuSmartException` en C#). - `comprobantes.emitir` siempre manda `Idempotency-Key` y la reusa en sus reintentos. No se reintentan solos: anulaciones, guías, reenviar, consultarSunat y activar RUC. - Firma de webhooks: `FactuSmart.verificarFirmaWebhook(cuerpoCrudo, firma, secreto)` (Node), `\FactuSmart\Cliente::verificarFirmaWebhook($cuerpoCrudo, $firma, $secreto)` (PHP), `factusmart.verificar_firma_webhook(cuerpo_crudo, firma, secreto)` (Python), `FactuSmart.verificarFirmaWebhook(cuerpoCrudo, firma, secreto)` (Java), `FactuSmartClient.VerificarFirmaWebhook(cuerpoCrudo, firma, secreto)` (C#). - Si el SDK que usas no tiene todavía un método para un endpoint nuevo (`/calcular`, `/catalogos`), llámalo por HTTP con la misma API key. ## 11. Errores del API: qué hacer con cada `codigo` Todo error tiene la forma `{"success": false, "message": "...", "codigo": "NO_ENCONTRADO", "reintentable": false}`. `message` es para mostrar; tu lógica va por `codigo` y `reintentable`. Un `codigo` desconocido: trátalo según `reintentable`. La tabla completa (HTTP, reintentable, significado) está en la sección 13, apartado "Errores". Qué hacer, en resumen: - `reintentable: true` (`EMISION_EN_CURSO`, `SUNAT_NO_DISPONIBLE`, `LIMITE_PETICIONES`, `ERROR_INTERNO`, `PDF_NO_GENERADO`): reintenta la misma petición más tarde (con la misma `Idempotency-Key`), con espera creciente. - `DATOS_INVALIDOS`: corrige el payload. Si viene `campos`, ahí está el detalle por campo (ej. `items.0.cantidad`). - `RUC_REQUERIDO`: manda `ruc` (la cuenta tiene varios). `RUC_NO_AUTORIZADO` / `RUC_DESACTIVADO`: revisa el RUC o reactívalo con `PATCH /rucs/{ruc}/activation`. - `LLAVE_DE_PRUEBA_EN_PRODUCCION`: usa la llave de producción para ese RUC. - `SIN_CREDITOS` / `PLAN_VENCIDO`: el usuario debe renovar o subir de plan en el portal (o pedir créditos extra para su plan por WhatsApp); no reintentes. - `LIMITE_PRUEBAS_DIARIO`: no reintentes hasta mañana (medianoche de Perú). - `ANULACION_MIXTA`: separa facturas y boletas en dos peticiones. - `LIMITE_ESTABLECIMIENTOS` (en `POST /establecimientos`): el plan del RUC no permite más locales; se amplía el plan desde el sistema. - `CERTIFICADO_NO_CONFIGURADO`: el RUC necesita certificado digital (o PSE) en su panel. - `RECHAZADO_SUNAT` (en /reenviar): emite un comprobante nuevo corregido. ## 12. Errores SUNAT comunes Vienen en `sunat.codigo` / `sunat.descripcion` del comprobante (no en `codigo` del API). | Código SUNAT | Qué pasa | Qué hacer | |---|---|---| | `0111` | El usuario SOL no tiene el perfil de facturación electrónica. | Detén la emisión de ese RUC: todo fallará igual. Dar el perfil en el portal SUNAT con la Clave SOL principal. Reenviar no sirve hasta arreglarlo. | | `0102` | Usuario o contraseña SOL incorrectos. | Detén la emisión. Corregir credenciales en el panel del RUC. | | `1033` | SUNAT ya tiene ese serie-número con otros datos (típico al migrar de otro sistema o emitir la misma serie desde dos sistemas). | No reenvíes: ese número está quemado. Continúa desde el número real de SUNAT. | | `0306`, `1001`–`1077` | Error en el contenido del comprobante. | Corrige los datos y emite uno nuevo. | | Sin código / timeout / red | SUNAT no respondió. | Reenvía más tarde con `/reenviar` (FactuSmart reintenta solo 3 veces al día). No reemitas. | ## 13. Conceptos generales (texto de la especificación) API REST para que sistemas de terceros emitan **facturas, boletas, notas de crédito/débito, guías de remisión (GRE 2.0) y anulaciones** (comunicación de baja para facturas, resumen diario para boletas) ante SUNAT, y descarguen el PDF, el XML firmado y el CDR. Tú envías la venta en JSON; FactuSmart arma el XML UBL 2.1, lo firma y lo envía. **¿No quieres calcular el IGV a mano?** `POST /calcular` te devuelve `items` y `totales` listos para copiar en `POST /documents`. Los códigos SUNAT (unidades, afectación, motivos…) están en `GET /catalogos`. Ninguno de los dos consume crédito. **¿Integras con un agente de IA (Claude, ChatGPT, Cursor)?** Dale este enlace, que trae toda la documentación en un solo archivo Markdown: https://factusmart.pe/api-facturacion-electronica/llms-full.txt Todas las respuestas son **JSON** (`Content-Type: application/json`), salvo las descargas de archivos (`/documents/{external_id}/{type}`), que devuelven el archivo (y un error JSON `404` si el comprobante o el archivo no existen). ### Autenticación Toda petición lleva tu API key en el header: ``` X-API-Key: TU_API_KEY ``` Como alternativa, el middleware también acepta `Authorization: Bearer TU_API_KEY`. Sin key → `401`. Key inválida o cuenta desactivada → `401`. #### Llave de prueba y llave de producción Cada cuenta puede tener **dos llaves**, que se envían igual y usan la **misma URL base**: | | Llave de producción | Llave de prueba | |---|---|---| | Formato | 64 letras y números (la de siempre) | Empieza con `fs_test_` | | Dónde se obtiene | Portal → Configuración | Portal → Configuración → "Generar llave de prueba" | | Emitir, anular, guías, `/reenviar`, `/consultar-sunat` | En todos tus RUCs | **Solo en RUCs en modo demo** | | Consultar, listar, descargar, historial, series, cuenta, ping | Todos tus RUCs | Todos tus RUCs | | Crear y listar RUCs (`/rucs`) | Sí | Sí | | Activar/desactivar un RUC | Sí | Solo si el RUC está en modo demo | | Créditos | 1 por operación en RUCs en producción; **gratis en RUCs demo** (tope 200/día) | Gratis (solo opera en RUCs demo; tope 200/día) | | Límite de peticiones | 120/min | 120/min (contador propio) | La llave de prueba es **opcional**: si ya integraste con tu llave de siempre, esa es tu llave de producción y sigue funcionando igual. Úsala en desarrollo y staging para que tu sistema de pruebas **nunca pueda emitir por error un comprobante real**: si apunta a un RUC en producción, la API responde `403` con `codigo: LLAVE_DE_PRUEBA_EN_PRODUCCION` en vez de enviarlo a SUNAT. El control es inmediato: si un RUC pasa de demo a producción, la llave de prueba deja de poder emitir en él al instante. `GET /ping` te dice qué llave usas (`tipo_llave`: `prueba`/`produccion`). Cada llave se regenera por separado en el portal y la anterior deja de funcionar al momento. El campo `entorno` de los comprobantes no depende de la llave: indica en qué entorno de SUNAT salió **ese** comprobante. ### Cuenta y RUCs Una **cuenta API** tiene una bolsa de créditos compartida y puede emitir para uno o varios RUCs. El RUC emisor se elige con (en este orden de prioridad): 1. el campo `ruc` (en el body JSON o como query `?ruc=`), 2. el campo `emisor_ruc` (alias), 3. el header `X-RUC`. Si no indicas RUC y la cuenta tiene **un solo** RUC, se usa ese automáticamente. Si tiene varios y no indicas cuál, los endpoints que operan sobre un RUC responden `422`. Un RUC que no pertenece a la cuenta → `403`. Un RUC desactivado → `403`. Los endpoints `/rucs*` nunca resuelven RUC (gestionan la cuenta). Cada cuenta empieza con un tope de **50 RUCs** (freno de seguridad, no límite comercial). Lo amplías tú mismo de 50 en 50 desde tu portal de integrador: **Empresas → Ampliar límite (+50)**, siempre que tengas un **plan activo** o que al menos **la mitad de tus RUCs ya estén en producción**. Si no cumples ninguna de las dos, pasa a producción tus empresas o escríbenos por WhatsApp +51 914 266 974. Ver `POST /rucs`. Solo se aceptan **RUC válidos**: 11 dígitos, prefijo `10`, `15`, `17` o `20` y dígito verificador (módulo 11 de SUNAT) correcto. Un RUC inventado responde `422` (`DATOS_INVALIDOS`). ### Varios locales (anexos) Un RUC puede emitir desde varios locales registrados en SUNAT. Cada local (establecimiento) tiene un **código de anexo de 4 dígitos** (`0000` = domicilio fiscal) y **sus propias series**; su dirección sale en el XML y en el PDF del comprobante. - `GET /establecimientos` lista los locales del RUC con sus series. `predeterminado: true` marca el local desde donde se emite si no indicas otro. - `POST /establecimientos` crea un local (y sus series). Antes regístralo en SUNAT (Clave SOL): el API no lo da de alta en SUNAT. Editar o eliminar un local se hace desde el sistema web (Empresa → Establecimientos). - Para emitir desde un local, en `POST /documents` envía `codigo_anexo` (ej. `"0001"`; también se acepta el nombre antiguo `codigo_establecimiento`), **o** solo una serie de ese local en `serie_documento` (ej. `"BA02"`): el sistema usa el local dueño de esa serie. Sin ninguno de los dos se emite desde el local predeterminado, como siempre. Ninguno de estos endpoints consume crédito. ### Créditos Todo es prepago. Cuesta **1 crédito**: cada comprobante emitido (`POST /documents`), cada guía **enviada** a SUNAT (`POST /guias`) y cada anulación **enviada** (`POST /anulaciones`), **en un RUC en producción**. En un RUC en **modo demo** no se cobra nada (ver "Pruebas sin costo"). El crédito se reserva de forma atómica antes de emitir/enviar y se **devuelve** si la operación falla con error (respuesta `422`). Ojo: si el comprobante se genera pero SUNAT no lo acepta (queda en estado `01`), la respuesta es `200` y el crédito **sí** se cobra; se reintenta gratis con `/reenviar`. Consultar, listar, descargar, ver saldo, `/calcular` y `/catalogos` es gratis. Sin saldo → `402` (`SIN_CREDITOS`): sube de plan o pide créditos extra para tu plan. Si tu **plan vence**, la emisión se pausa (`402` `PLAN_VENCIDO`) hasta que lo renueves; tus créditos extra se conservan (cada paquete mantiene su vigencia de 12 meses) y vuelves a usarlos al renovar. Las respuestas de `POST /documents`, `POST /guias` y `POST /anulaciones` traen `creditos_restantes` (tu saldo real tras la operación) y `credito_cobrado` (`true` si esta operación descontó 1 crédito; en demo siempre `false`; en `/guias`, `false` también si la guía no se pudo enviar y se devolvió). #### Pruebas sin costo (RUC en modo demo) En un RUC en modo demo (SUNAT beta) emitir, enviar guías y anular **no gasta créditos**, con cualquiera de tus dos llaves: lo que decide es el modo del RUC, no la llave. La respuesta lo dice: ```json { "creditos_restantes": 14945, "credito_cobrado": false, "modo_demo": true, "pruebas_restantes_hoy": 198 } ``` - `creditos_restantes` es tu saldo real (no cambia en demo). - `modo_demo` y `pruebas_restantes_hoy` solo aparecen cuando la operación fue en un RUC demo. - Tope: **200 operaciones en demo por cuenta y por día** (emisiones + guías + anulaciones, todas tus empresas juntas). Al pasarlo la API responde `429` con `codigo: LIMITE_PRUEBAS_DIARIO` y `reintentable: false`: reintentar hoy no sirve, el cupo se reinicia a medianoche (hora de Perú). En producción no hay este tope. - Una operación que falla (`422`) no cuenta para el tope. ### Modo prueba vs producción (por RUC) Cada RUC **nace en modo prueba** (SUNAT beta, sin validez fiscal): se puede integrar sin certificado ni clave SOL. El paso a producción se hace por RUC desde su panel (`https://{RUC}.{servidor}.factusmart.pe`). La misma API key y los mismos endpoints sirven en ambos modos. Cada comprobante informa en qué entorno salió (`entorno`: `prueba`/`produccion`, `tiene_validez_fiscal`). **Ojo con OSE:** si el RUC envía sus comprobantes por un **OSE** (operador de servicios electrónicos) en vez de directo a SUNAT, el envío va siempre con el certificado y el usuario SOL reales y tiene validez fiscal, aunque el RUC figure en "prueba". Por eso la API lo trata como **producción**: cobra crédito, la llave de prueba no emite en él, y `entorno` es `produccion` (`GET /rucs` también lo muestra en `produccion`). ### Idempotencia `POST /documents` acepta el header `Idempotency-Key` (único por venta). Si reintentas con la misma key y la emisión ya terminó, recibes la **misma respuesta** sin reemitir ni recobrar. Si la primera petición aún se procesa → `409`. Si la emisión falló (`402`/`422`), la key se libera y puedes reintentar con ella. Solo existe en `POST /documents`. ### Webhooks (avisos de cambio de estado) En vez de consultar `GET /documents/{external_id}` cada cierto tiempo, puedes recibir un **POST firmado** en tu servidor cuando un comprobante **emitido por el API** pasa a aceptado (`05`), observado (`07`), rechazado (`09`) o anulado (`11`). Se configura **una URL por cuenta** (recibes los avisos de todos tus RUCs) en tu portal de integrador, pestaña **Webhooks**; ahí obtienes el secreto `whsec_…` y puedes enviar un aviso de prueba. No hay endpoint del API para configurarlo. - Firma: header `X-FactuSmart-Firma` = HMAC-SHA256 (hex) del **cuerpo crudo** con tu secreto. Los SDK traen `verificarFirmaWebhook` / `verificar_firma_webhook`. - Responde `2xx` en menos de **5 segundos**. Si no, se reintenta: 6 intentos en total (inmediato, +1 min, +5 min, +30 min, +2 h, +6 h). No se siguen redirecciones. - Si tu servidor falla **10 envíos seguidos**, los avisos de tu cuenta se **pausan** (los pendientes quedan como Error, sin enviarse) hasta que guardes de nuevo la URL en el portal o un "Enviar prueba" salga bien. - El `id` del aviso es el mismo en todos los reintentos: úsalo para no procesarlo dos veces. El detalle del payload y los headers está en la sección `webhooks` (`comprobanteEstado`). ### HTTP 200 no significa "aceptado por SUNAT" En emisión y consultas revisa siempre `aceptado_por_sunat` y `estado` (`01` Registrado, `03` Enviado, `05` Aceptado, `07` Observado, `09` Rechazado, `11` Anulado, `13` Por anular). ### Límites de uso - `120` peticiones/minuto **por API key** (limitador `issuer`). - Además, el grupo de rutas `api` aplica `60` peticiones/minuto **por IP** antes de autenticar. Al superar cualquiera de los dos límites la respuesta es **HTTP 429** con el header `Retry-After` (segundos) y `{"success": false, "message": "Demasiadas peticiones. Espera N segundos e intenta de nuevo.", "retry_after": N, "codigo": "LIMITE_PETICIONES", "reintentable": true}`. Espera esos segundos y reintenta. Hay **otro** `429` que **no** se reintenta: `LIMITE_PRUEBAS_DIARIO` (tope de 200 operaciones diarias en RUCs demo). Decide siempre por `codigo`/`reintentable`, no solo por el HTTP. ### Errores Toda respuesta de error (HTTP `4xx`/`5xx`) tiene la misma forma base (schema `Error`): ```json {"success": false, "message": "Comprobante no encontrado.", "codigo": "NO_ENCONTRADO", "reintentable": false} ``` - `message`: texto en español para mostrar o registrar. **No lo compares**: puede cambiar de redacción. - `codigo`: identificador estable en MAYÚSCULAS. Decide tu lógica con este campo. - `reintentable`: `true` si repetir **la misma petición** más tarde puede funcionar (límite de peticiones, emisión en curso, SUNAT caída, error interno). `false` = hay que corregir algo antes. - Algunos errores agregan campos propios (`creditos_restantes`, `retry_after`, `campos`, o el estado del comprobante en `/reenviar`). | `codigo` | HTTP | `reintentable` | Significado | |---|---|---|---| | `NO_AUTENTICADO` | 401 | no | Falta la API key, es inválida o la cuenta está desactivada. | | `PLAN_VENCIDO` | 402 | no | El plan de la cuenta venció. Solo quedan abiertos `/ping`, `/account` y `/rucs*`. | | `SIN_CREDITOS` | 402 | no | Sin créditos. No se emitió ni envió nada. Trae `creditos_restantes: 0`. | | `RUC_NO_AUTORIZADO` | 403 | no | El RUC indicado no pertenece a la cuenta o no existe. | | `RUC_DESACTIVADO` | 403 | no | El RUC está desactivado (`PATCH /rucs/{ruc}/activation`). | | `LLAVE_DE_PRUEBA_EN_PRODUCCION` | 403 | no | Usaste la llave de prueba (`fs_test_…`) para emitir, anular, enviar una guía, reenviar, consultar-sunat o activar/desactivar un RUC que está en **producción**. Usa tu llave de producción. | | `SIN_PERMISO` | 403 | no | Acción no permitida. | | `NO_ENCONTRADO` | 404 | no | No existe el comprobante/guía/anulación, o el archivo a descargar (PDF/XML/CDR). | | `RUTA_NO_ENCONTRADA` | 404 | no | La URL no existe (revisa la ruta y la URL base). | | `METODO_NO_PERMITIDO` | 405 | no | Método HTTP incorrecto para esa ruta. | | `EMISION_EN_CURSO` | 409 | **sí** | Ya hay una emisión en curso con esa `Idempotency-Key`. Reintenta en unos segundos. | | `CONFLICTO` | 409 | no | Conflicto con el estado actual (ej. el RUC ya está registrado con otra cuenta). | | `DATOS_INVALIDOS` | 422 | no | El payload o los parámetros no pasan la validación. Si hay detalle por campo, viene en `campos`. | | `RUC_REQUERIDO` | 422 | no | La cuenta tiene varios RUCs y no indicaste cuál (`ruc`). | | `RUC_SIN_TENANT` | 422 | no | El RUC no tiene una empresa (tenant) asociada. Contacta soporte. | | `CERTIFICADO_NO_CONFIGURADO` | 422 | no | El RUC no tiene certificado digital (ni envío por PSE). | | `ERROR_EMISION` | 422 | no | El motor no pudo generar el comprobante/guía/anulación. Revisa `message`. Crédito devuelto. | | `RECHAZADO_SUNAT` | 422 | no | `/reenviar`: SUNAT rechazó el comprobante (estado `09`). Corrige y emite uno nuevo. | | `REENVIO_FALLIDO` | 422 | no | `/reenviar`: el reintento no se completó por otro motivo. Revisa `message`. | | `SUNAT_NO_DISPONIBLE` | 422 / 5xx | **sí** | SUNAT (o la red hacia SUNAT/PSE) no respondió. Reintenta más tarde. | | `LIMITE_PETICIONES` | 429 | **sí** | Demasiadas peticiones. Espera `Retry-After` segundos. | | `LIMITE_PRUEBAS_DIARIO` | 429 | **no** | Llegaste a las 200 operaciones en modo demo de hoy. Se reinicia a medianoche (hora de Perú). Trae `pruebas_restantes_hoy: 0`, `limite_pruebas_diario` y `creditos_restantes`. | | `ANULACION_MIXTA` | 422 | no | `POST /anulaciones` con facturas y boletas juntas. Haz una petición para cada tipo. | | `ERROR_INTERNO` | 5xx | **sí** | Error interno del servidor. Reintenta; si persiste, contacta soporte. | | `PDF_NO_GENERADO` | 500 | **sí** | `GET /documents/{external_id}/pdf?formato=…`: el generador de PDF falló dos veces seguidas. Reintenta en unos segundos. Nunca se entrega un PDF de otro tamaño. | | `LIMITE_ESTABLECIMIENTOS` | 403 | no | `POST /establecimientos`: el plan del RUC no permite más locales. Se amplía el plan desde el sistema. | | `CATALOGO_NO_DISPONIBLE` | 503 | **no** | El catálogo pedido todavía no está cargado en el servidor. Reintentar no lo arregla. | La lista puede crecer: trata un `codigo` desconocido como no reintentable salvo que `reintentable` diga lo contrario. #### Errores de validación - Los errores del payload de emisión (`/documents`, `/anulaciones`, `/guias`) devuelven `422` con `codigo: DATOS_INVALIDOS` y el detalle en `message`. - `POST /calcular`, `GET /catalogos/{nombre}` y `GET /catalogos/productos-sunat` devuelven `422 DATOS_INVALIDOS` con el detalle por campo en `campos` (ej. `{"items.0.cantidad": ["cantidad debe ser un número mayor que 0."]}`). - Los endpoints con validación por campo (`GET /documents`, `POST /rucs`, `PATCH /rucs/{ruc}/activation`) devuelven `422` con `codigo: DATOS_INVALIDOS` y el mapa `{"campo": ["mensaje"]}` en `campos`. Por compatibilidad con integraciones existentes, ese mismo mapa sigue apareciendo donde aparecía antes: en la raíz del JSON si envías `Accept: application/json`, o dentro de `message` si no lo envías. Usa `campos`, que es igual en ambos casos. Nunca se exponen rutas de archivos ni líneas de código del servidor. - La API **ignora en silencio** los campos con nombre mal escrito. ## 14. Referencia de endpoints Todos los endpoints, agrupados por tema. URL completa = URL base + ruta. Todos llevan `X-API-Key: TU_API_KEY`. ### Utilidades Pruebas de conexión. #### GET / — Raíz del API (sin llave) - operationId: `apiRoot` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/` - Grupo: Utilidades Confirma que el servicio responde y dónde está la documentación. **No requiere API key** ni consume crédito. Para comprobar tu llave usa `GET /ping`. **Respuestas** - `200`: El API responde. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. Campos de la respuesta `200`: | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean | sí | | | `api` | string | sí | | | `docs` | string (uri) | sí | | | `estado` | string (`ok`) | sí | | Ejemplo `200`: ```json { "success": true, "api": "FactuSmart API de facturación", "docs": "https://factusmart.pe/api-facturacion-electronica/docs", "estado": "ok" } ``` #### GET /ping — Probar la API key - operationId: `ping` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/ping` - Grupo: Utilidades Confirma que la API key es válida y devuelve el saldo. No consume crédito. Si se resolvió un RUC (indicado o único en la cuenta) agrega `ruc_emisor` y `razon_social`. `tipo_llave` indica si autenticaste con la llave de `prueba` (`fs_test_…`) o la de `produccion`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Autenticado. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `422`: El RUC resuelto no tiene tenant asociado. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`PingResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `message` | string | sí | | | `cuenta` | string/null | sí | Nombre de la cuenta API. | | `creditos` | integer | sí | Créditos disponibles (cupo del plan + bolsa). | | `tipo_llave` | string (`prueba`, `produccion`) | sí | Llave usada: `prueba` (empieza con `fs_test_`, solo escribe en RUCs en modo demo) o `produccion`. | | `ruc_emisor` | string/null | no | Solo si se resolvió un RUC. | | `razon_social` | string/null | no | Solo si se resolvió un RUC. Es el nombre comercial (`trade_name`) de la empresa. | Ejemplo `200`: ```json { "success": true, "message": "Autenticado correctamente", "cuenta": "Mi Integración", "creditos": 4830, "tipo_llave": "produccion", "ruc_emisor": "20100070970", "razon_social": "MI EMPRESA" } ``` ### Cuenta Saldo de créditos, plan y series. #### GET /account — Estado de la cuenta - operationId: `getAccount` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/account` - Grupo: Cuenta Saldo disponible, total recargado, consumido, plan y RUCs de la cuenta. No requiere RUC. No consume crédito. **Respuestas** - `200`: Estado de la cuenta. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `422`: El RUC resuelto no tiene tenant asociado. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`AccountResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `cuenta` | string | sí | | | `creditos_disponibles` | integer | sí | Lo que puede emitir ahora (cupo del plan restante este mes + bolsa). | | `creditos_recargados_total` | integer | sí | | | `creditos_consumidos` | integer | sí | | | `plan` | object | sí | | | `plan.plan` | string/null | no | Nombre del plan de suscripción, o null. | | `plan.plan_activo` | boolean | no | | | `plan.plan_vencido` | boolean | no | true si la cuenta tuvo plan y ya venció (no se renovó). | | `plan.periodo` | string/null (`mensual`, `anual`, null) | no | | | `plan.plan_vence` | string/null (date) | no | | | `plan.cupo_mensual` | integer | no | | | `plan.cupo_usado_mes` | integer | no | | | `plan.cupo_restante_mes` | integer | no | | | `plan.cupo_se_renueva` | string/null (date) | no | | | `plan.bolsa` | integer | no | Créditos extra otorgados para tu plan (no vencen con el mes; vigencia 12 meses). | | `plan.bolsa_vence` | object/null | no | Próximo vencimiento de la bolsa. | | `plan.bolsa_vence.fecha` | string (date) | no | | | `plan.bolsa_vence.cantidad` | integer | no | | | `plan.disponibles` | integer | no | | | `rucs` | array de objetos | sí | | | `rucs[].ruc` | string | no | | | `rucs[].nombre` | string | no | | #### GET /series — Series del RUC - operationId: `listSeries` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/series` - Grupo: Cuenta Series configuradas en el establecimiento principal del RUC (el del usuario admin), con el siguiente número estimado. FactuSmart no usa F001/B001: cada RUC nace con series propias (FA01, BA01, FN01, TA01, ...). `siguiente_numero` es el último número emitido en esa serie + 1, contado sobre lo que corresponde a cada tipo (comprobantes 01/03/07/08, guías 09/31, etc.). Es una estimación: si otro proceso emite entre la consulta y tu envío, el número real será mayor (usa `numero_documento: "#"` para que el sistema asigne el correlativo). Requiere RUC. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Series del RUC. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `422`: No se pudo determinar el RUC emisor (cuenta con varios RUCs y no se indicó), o el RUC no tiene tenant. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`SeriesResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `series` | array de objetos | sí | | | `series[].serie` | string | no | | | `series[].codigo_tipo_documento` | string | no | Tipo de comprobante de la serie (01, 03, 07, 08, 09, 31...). | | `series[].siguiente_numero` | integer | no | | Ejemplo `200`: ```json { "success": true, "series": [ { "serie": "FA01", "codigo_tipo_documento": "01", "siguiente_numero": 15 }, { "serie": "BA01", "codigo_tipo_documento": "03", "siguiente_numero": 230 }, { "serie": "FN01", "codigo_tipo_documento": "07", "siguiente_numero": 1 } ] } ``` ### RUCs Alta, listado y activación/desactivación de los RUCs de la cuenta. #### GET /rucs — Listar RUCs de la cuenta - operationId: `listRucs` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/rucs` - Grupo: RUCs RUCs enlazados a la cuenta con su entorno (`demo`/`produccion`) y si están activos. No consume crédito. **Respuestas** - `200`: Lista de RUCs. - `401`: API key ausente, inválida o cuenta desactivada. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`RucListResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `rucs` | array de objetos | sí | | | `rucs[].ruc` | string | no | | | `rucs[].nombre` | string | no | | | `rucs[].correo` | string/null | no | | | `rucs[].entorno` | string/null (`demo`, `produccion`, null) | no | `demo` = modo prueba (equivale a `entorno: prueba` en los comprobantes). Un RUC que envía por OSE es `produccion` aunque esté en prueba. null si el RUC no tiene tenant. | | `rucs[].activo` | boolean | no | | | `rucs[].creado` | string/null | no | YYYY-MM-DD HH:MM | #### POST /rucs — Crear un RUC - operationId: `createRuc` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/rucs` - Grupo: RUCs Da de alta un RUC nuevo enlazado a la cuenta (nace en modo prueba/demo). Idempotente: si el RUC ya pertenece a esta cuenta devuelve `200` con `ya_existia: true`. Si pertenece a otra cuenta → `409`. No consume crédito. **RUC válido.** `ruc` debe tener 11 dígitos, empezar con `10`, `15`, `17` o `20` y tener el dígito verificador correcto (módulo 11 de SUNAT). Si no → `422` (`DATOS_INVALIDOS`, con `campos.ruc`). La idempotencia va primero: reintentar con un RUC que ya es de tu cuenta sigue devolviendo `200`. **Tope de RUCs (ampliable).** Cada cuenta empieza con un tope de **50 RUCs**. Es solo un freno de seguridad (que un error en tu sistema no cree empresas en masa), no un límite comercial. Al alcanzarlo, esta llamada responde `422` (`codigo: DATOS_INVALIDOS`) con el mensaje "Alcanzaste el máximo de N RUCs…". Lo amplías tú mismo, sin costo, de 50 en 50 desde tu portal de integrador (**Empresas → Ampliar límite (+50)**) si tienes un **plan activo** o al menos **la mitad de tus RUCs están en producción**. Si no, pasa a producción tus empresas o escríbenos por WhatsApp +51 914 266 974. **Cuerpo (JSON)** — esquema `CreateRucRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | sí | RUC nuevo (11 dígitos). Formato `^\d{11}$`. | | `empresa` | string | sí | Razón social. Máx. 255 caracteres. | | `correo` | string (email) | sí | Correo del usuario administrador del panel del RUC. | | `clave` | string | sí | Contraseña de acceso al panel del RUC. | Ejemplo de petición: ```json { "ruc": "20609999994", "empresa": "Mi Cliente SAC", "correo": "contacto@micliente.pe", "clave": "una-clave-de-al-menos-6-caracteres" } ``` **Respuestas** - `200`: El RUC ya existía en esta cuenta. - `201`: RUC creado en modo demo. - `401`: API key ausente, inválida o cuenta desactivada. - `409`: El RUC ya está registrado con otra cuenta. - `422`: Validación fallida, tope de RUCs alcanzado o error al crear el tenant. - `500`: Plan de integradores no configurado en el servidor o error interno. Campos de la respuesta `200` (`CreateRucResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `ya_existia` | boolean | sí | | | `ruc` | string | sí | | | `nombre` | string | sí | | | `activo` | boolean | sí | | | `tenant_url` | string/null (uri) | sí | URL del panel del RUC. | | `mensaje` | string | no | Solo cuando se crea (201). | Ejemplo `409`: ```json { "success": false, "message": "Ese RUC ya está registrado en el sistema (con otra cuenta).", "codigo": "CONFLICTO", "reintentable": false } ``` Ejemplo `422` — Tope de RUCs alcanzado (amplíalo desde el portal): ```json { "success": false, "message": "Alcanzaste el máximo de 50 RUCs. Puedes ampliarlo desde tu portal de integrador (Empresas → Ampliar límite) si tienes un plan activo o al menos la mitad de tus empresas ya están en producción. Si no, escríbenos por WhatsApp +51 914 266 974.", "codigo": "DATOS_INVALIDOS", "reintentable": false } ``` Ejemplo `422` — RUC con dígito verificador incorrecto: ```json { "success": false, "message": "El RUC no es válido: debe tener 11 dígitos, empezar con 10, 15, 17 o 20 y su dígito verificador debe ser correcto.", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "ruc": [ "El RUC no es válido: debe tener 11 dígitos, empezar con 10, 15, 17 o 20 y su dígito verificador debe ser correcto." ] } } ``` #### PATCH /rucs/{ruc}/activation — Activar o desactivar un RUC - operationId: `setRucActivation` - URL: `PATCH https://s2.factusmart.pe/api/v1/issuer/rucs/{ruc}/activation` - Grupo: RUCs Desactiva (`activo: false`) o reactiva (`activo: true`) un RUC de la cuenta. Un RUC desactivado no puede emitir, consultar ni descargar nada por la API ni por su panel. Reversible; no borra datos. No existe endpoint para eliminar un RUC. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | path | sí | RUC de 11 dígitos enlazado a la cuenta. | **Cuerpo (JSON)** — esquema `RucActivationRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `activo` | boolean | sí | false desactiva, true reactiva. | Ejemplo de petición: ```json { "activo": false } ``` **Respuestas** - `200`: Estado actualizado. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: Usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción. - `404`: El RUC no pertenece a la cuenta o no existe. - `422`: Validación por campo fallida (`codigo: DATOS_INVALIDOS`). El detalle está en `campos`. Por compatibilidad, el mismo mapa también va en la raíz (con `Accept: application/json`) o dentro de `message` (sin ese header). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`RucActivationResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `ruc` | string | sí | | | `activo` | boolean | sí | | | `mensaje` | string | sí | | Ejemplo `404`: ```json { "success": false, "message": "Ese RUC no pertenece a esta cuenta o no existe.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` ### Anexos Locales (establecimientos / anexos SUNAT) de un RUC, cada uno con sus series. No consumen crédito. #### GET /establecimientos — Listar locales (anexos) del RUC - operationId: `listEstablishments` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/establecimientos` - Grupo: Anexos Locales del RUC registrados en FactuSmart, con su código de anexo SUNAT y sus series de comprobantes (01, 03, 07, 08, 09, 31, 20, 40, 04). `predeterminado: true` = el local desde donde emite `POST /documents` si no envías `codigo_anexo`. Requiere RUC. No consume crédito. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Locales del RUC. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `422`: No se pudo determinar el RUC emisor (cuenta con varios RUCs y no se indicó), o el RUC no tiene tenant. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`EstablishmentListResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `establecimientos` | array de objetos | sí | | | `establecimientos[].id` | integer | sí | | | `establecimientos[].codigo_anexo` | string | sí | Código de anexo SUNAT del local (`0000` = domicilio fiscal). Es el que envías en `codigo_anexo` al emitir. Formato `^\d{4}$`. | | `establecimientos[].nombre` | string | sí | | | `establecimientos[].direccion` | string/null | no | | | `establecimientos[].ubigeo` | string/null | no | Código INEI del distrito (6 dígitos). | | `establecimientos[].departamento` | string/null | no | | | `establecimientos[].provincia` | string/null | no | | | `establecimientos[].distrito` | string/null | no | | | `establecimientos[].telefono` | string/null | no | | | `establecimientos[].email` | string/null | no | | | `establecimientos[].predeterminado` | boolean | sí | true = el local desde donde emite `POST /documents` si no envías `codigo_anexo`. | | `establecimientos[].series` | array de objetos | sí | Series de comprobantes SUNAT del local (01, 03, 07, 08, 09, 31, 20, 40, 04). | | `establecimientos[].series[].serie` | string | no | | | `establecimientos[].series[].codigo_tipo_documento` | string | no | | | `establecimientos[].series[].tipo` | string/null | no | | Ejemplo `200`: ```json { "success": true, "establecimientos": [ { "id": 1, "codigo_anexo": "0000", "nombre": "Oficina Principal", "direccion": "Av. Arequipa 1234", "ubigeo": "150101", "departamento": "LIMA", "provincia": "Lima", "distrito": "Lima", "telefono": null, "email": "ventas@miempresa.pe", "predeterminado": true, "series": [ { "serie": "FA01", "codigo_tipo_documento": "01", "tipo": "Factura" }, { "serie": "BA01", "codigo_tipo_documento": "03", "tipo": "Boleta de venta" } ] }, { "id": 2, "codigo_anexo": "0001", "nombre": "Tienda Miraflores", "direccion": "Av. Larco 345", "ubigeo": "150122", "departamento": "LIMA", "provincia": "Lima", "distrito": "Miraflores", "telefono": "014445566", "email": null, "predeterminado": false, "series": [ { "serie": "FA02", "codigo_tipo_documento": "01", "tipo": "Factura" }, { "serie": "B002", "codigo_tipo_documento": "03", "tipo": "Boleta de venta" }, { "serie": "BA02", "codigo_tipo_documento": "03", "tipo": "Boleta de venta" } ] } ] } ``` #### POST /establecimientos — Crear un local (anexo) - operationId: `createEstablishment` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/establecimientos` - Grupo: Anexos Crea un local del RUC con su almacén y sus series, igual que el sistema web (Empresa → Establecimientos) y con sus mismas reglas. No consume crédito. - Registra antes el anexo en SUNAT (Clave SOL): el API no lo da de alta en SUNAT. - `codigo_anexo` (4 dígitos) y `nombre` deben ser únicos dentro del RUC. - `crear_series_por_defecto` (por defecto `true`) crea las series del local como la web (`FA0N`, `BA0N`, `FN0N`, `BN0N`, `FD0N`, `BD0N`, guías…). Con `false` solo se crean el almacén y las `series` que envíes. - `series` agrega series propias (máx. 20; tipos `01`, `03`, `07`, `08`; 4 caracteres; factura `F` + 3, boleta `B` + 3; las notas empiezan con `F` o `B`). Una serie ya usada en el RUC → `422`. - El plan del RUC limita cuántos locales puede tener → `403 LIMITE_ESTABLECIMIENTOS`. - Con la llave de prueba (`fs_test_…`) solo en RUCs en modo demo; en producción → `403 LLAVE_DE_PRUEBA_EN_PRODUCCION`. - Editar o eliminar un local se hace desde el sistema web. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `CreateEstablishmentRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | RUC emisor. Obligatorio si la cuenta tiene varios RUCs. Formato `^\d{11}$`. | | `codigo_anexo` | string | sí | Código de 4 dígitos que SUNAT asignó al local. Único en el RUC. Formato `^\d{4}$`. | | `nombre` | string | sí | Nombre del local. Único en el RUC. | | `direccion` | string | sí | | | `ubigeo` | string | sí | Código INEI del distrito, 6 dígitos (ej. `150122` = Miraflores, Lima). Formato `^\d{6}$`. | | `telefono` | string | no | | | `email` | string (email) | no | | | `crear_series_por_defecto` | boolean | no | Crea las series del local igual que el sistema web (`FA0N`, `BA0N`, `FN0N`, `BN0N`, `FD0N`, `BD0N`, guías…). Por defecto `true`. | | `series` | array de objetos | no | Series extra del local. 0 a 20 elementos. | | `series[].codigo_tipo_documento` | string (`01`, `03`, `07`, `08`) | sí | | | `series[].serie` | string | sí | Factura `F` + 3, boleta `B` + 3; notas empiezan con `F` o `B`. Formato `^[A-Za-z0-9]{4}$`. | Ejemplo de petición: ```json { "ruc": "20100070970", "codigo_anexo": "0001", "nombre": "Tienda Miraflores", "direccion": "Av. Larco 345", "ubigeo": "150122", "telefono": "014445566", "series": [ { "codigo_tipo_documento": "03", "serie": "B002" } ] } ``` **Respuestas** - `201`: Local creado. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC no pertenece a la cuenta o está desactivado, llave de prueba en un RUC en producción, o el plan del RUC no permite más locales (`LIMITE_ESTABLECIMIENTOS`). - `422`: Falta el RUC, o los datos no pasan la validación (detalle por campo en `campos`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Ejemplo `201`: ```json { "success": true, "message": "Establecimiento creado. Para emitir desde él envía \"codigo_anexo\": \"0001\" en POST /documents.", "establecimiento": { "id": 2, "codigo_anexo": "0001", "nombre": "Tienda Miraflores", "direccion": "Av. Larco 345", "ubigeo": "150122", "departamento": "LIMA", "provincia": "Lima", "distrito": "Miraflores", "telefono": "014445566", "email": null, "predeterminado": false, "series": [ { "serie": "FA02", "codigo_tipo_documento": "01", "tipo": "Factura" }, { "serie": "B002", "codigo_tipo_documento": "03", "tipo": "Boleta de venta" }, { "serie": "BA02", "codigo_tipo_documento": "03", "tipo": "Boleta de venta" }, { "serie": "BN02", "codigo_tipo_documento": "07", "tipo": "Nota de crédito" }, { "serie": "FN02", "codigo_tipo_documento": "07", "tipo": "Nota de crédito" } ] } } ``` Ejemplo `403` — Límite de locales del plan: ```json { "success": false, "message": "Ha alcanzado el límite de 1 sucursal(es) permitidas por su plan. Elimine una sucursal o amplíe su plan.", "codigo": "LIMITE_ESTABLECIMIENTOS", "reintentable": false } ``` Ejemplo `403` — llavePrueba: ```json { "success": false, "message": "Estás usando tu llave de PRUEBA y este RUC está en producción. La llave de prueba solo emite, anula y reenvía en RUCs en modo demo. Usa tu llave de producción.", "codigo": "LLAVE_DE_PRUEBA_EN_PRODUCCION", "reintentable": false } ``` Ejemplo `403` — ajeno: ```json { "success": false, "message": "El RUC indicado no pertenece a esta cuenta o no existe.", "codigo": "RUC_NO_AUTORIZADO", "reintentable": false } ``` Ejemplo `422` — validacion: ```json { "success": false, "message": "Datos inválidos: …", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "nombre": [ "Ya existe un establecimiento con ese nombre." ], "ubigeo": [ "ubigeo inválido: envía el código de distrito INEI de 6 dígitos (ej. 150122 = Miraflores, Lima)." ], "direccion": [ "El campo direccion es obligatorio." ], "codigo_anexo": [ "codigo_anexo debe ser el código de 4 dígitos que SUNAT asignó al local (ej. \"0001\"; \"0000\" es el domicilio fiscal)." ], "series.0.serie": [ "El formato de la serie es inválido - Formato del campo: [B][A-Z0-9]{3}, Ejemplo: B001" ], "series.1.serie": [ "La serie FA01 ya ha sido registrada en este RUC." ], "series.2.codigo_tipo_documento": [ "Debe ser uno de: 01, 03, 07, 08." ] } } ``` Ejemplo `422` — rucRequerido: ```json { "success": false, "message": "Indica el RUC emisor en el campo \"ruc\" (tu cuenta tiene varios RUCs enlazados).", "codigo": "RUC_REQUERIDO", "reintentable": false } ``` ### Comprobantes Facturas (01), boletas (03), notas de crédito (07) y débito (08). #### GET /documents — Listar comprobantes - operationId: `listDocuments` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/documents` - Grupo: Comprobantes Lista comprobantes (01/03/07/08) del RUC, del más reciente al más antiguo, con el diagnóstico SUNAT de cada uno. Paginación por cursor: pasa `siguiente_cursor` como `antes_de`. No consume crédito. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | | `estado` | query | no | Filtra por código de estado (ej. `01` para los no aceptados todavía). | | `desde` | query | no | Fecha de emisión mínima (inclusive). | | `hasta` | query | no | Fecha de emisión máxima (inclusive). | | `limite` | query | no | Cantidad máxima de resultados. | | `antes_de` | query | no | Cursor. Devuelve comprobantes con id interno menor a este valor. | **Respuestas** - `200`: Página de comprobantes. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `422`: Falta el RUC o los filtros no pasan la validación. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`DocumentListResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `comprobantes` | array de objetos | sí | | | `comprobantes[].external_id` | string (uuid) | no | | | `comprobantes[].serie_numero` | string | no | | | `comprobantes[].codigo_tipo_documento` | string (`01`, `03`, `07`, `08`) | no | 01 Factura, 03 Boleta de venta, 07 Nota de crédito, 08 Nota de débito. | | `comprobantes[].estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | no | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `comprobantes[].estado_descripcion` | string/null | no | | | `comprobantes[].fecha_emision` | string/null (date) | no | | | `comprobantes[].total` | number | no | | | `comprobantes[].sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `comprobantes[].sunat.codigo` | string/null | no | | | `comprobantes[].sunat.descripcion` | string/null | no | | | `comprobantes[].aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `comprobantes[].entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `comprobantes[].tiene_validez_fiscal` | boolean | sí | | | `comprobantes[].accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | | `siguiente_cursor` | integer/null | sí | Pásalo como `antes_de` para la siguiente página. `null` = no hay más. | #### POST /documents — Emitir comprobante - operationId: `emitDocument` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/documents` - Grupo: Comprobantes Emite una factura (01), boleta (03), nota de crédito (07) o nota de débito (08): genera el XML UBL 2.1, lo firma, crea el PDF, lo envía a SUNAT y responde con el resultado. Cuesta 1 crédito (gratis en RUCs demo, con tope de 200 pruebas por día; ver "Pruebas sin costo"). - **Lo más fácil:** arma `items` y `totales` con `POST /calcular` y cópialos tal cual aquí. - `serie_documento` es opcional si el establecimiento tiene una sola serie para ese tipo (caso normal en facturas y boletas). En notas normalmente hay dos (de factura y de boleta): envíala. Consulta `GET /series`. - `numero_documento`: envía `"#"` (o no lo envíes) para que FactuSmart asigne el correlativo. Si mandas un número que ya existe → `422`. - Cliente con RUC (`6`) o DNI (`1`): basta el número; nombre y dirección se autocompletan. Otros tipos exigen `apellidos_y_nombres_o_razon_social`. - Factura con `codigo_tipo_operacion` `0101` exige cliente con RUC. Boleta mayor a S/ 700 exige cliente identificado. - Si hay `detraccion`, la leyenda 2006 se agrega sola. Con operación `1004`, `ubigeo_origen` y `ubigeo_destino` son obligatorios. - HTTP `200` = comprobante generado. Revisa `aceptado_por_sunat`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `Idempotency-Key` | header | no | Valor único por venta (ej. el id de la venta en tu sistema). Ver sección Idempotencia. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `EmitDocumentRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | RUC emisor. Obligatorio si la cuenta tiene varios RUCs. Formato `^\d{11}$`. | | `codigo_anexo` | string | no | Código de anexo del local desde el que se emite (ej. `0001`), el de `GET /establecimientos`. Si no lo envías: el local dueño de la `serie_documento` enviada o, sin serie, el local predeterminado (`0000`). Un código que no existe en el RUC → `422 DATOS_INVALIDOS`. Formato `^\d{4}$`. | | `codigo_establecimiento` | string | no | Nombre antiguo de `codigo_anexo` (mismo dato; se aceptan los dos). Si envías ambos, gana este. | | `serie_documento` | string | no | Serie (ej. FA01, BA01, FN01). Opcional si el establecimiento tiene una sola serie para el tipo. | | `numero_documento` | string | no | `#` para correlativo automático. Un número explícito ya usado da `422`. Por defecto `#`. | | `fecha_de_emision` | string (date) | sí | Fecha de emisión (YYYY-MM-DD) **en hora de Perú** (zona `America/Lima`, UTC-5, sin horario de verano). Debe ser la fecha real de hoy en Perú. Ojo: un servidor en UTC que use su fecha local después de las 19:00 de Perú mandaría la fecha de mañana. Es obligatoria: el API no la completa si falta (la emisión se rechaza con `422` y no se cobra). | | `hora_de_emision` | string | sí | Hora de emisión (HH:MM:SS, 24 h) en hora de Perú (`America/Lima`). Obligatoria; el API no la completa. Formato `^\d{2}:\d{2}:\d{2}$`. | | `fecha_de_vencimiento` | string (date) | no | Solo facturas/boletas. Si no se envía, se usa la fecha de emisión. | | `codigo_tipo_documento` | string (`01`, `03`, `07`, `08`) | sí | 01 Factura, 03 Boleta de venta, 07 Nota de crédito, 08 Nota de débito. | | `codigo_tipo_operacion` | string | no | Catálogo 51 SUNAT (0101 venta interna, 0200 exportación, 1001 detracción, 1004 detracción transporte de carga...). Obligatorio en la práctica para 01/03; no aplica a notas. | | `codigo_tipo_moneda` | string (`PEN`, `USD`) | sí | Código de moneda (catálogo del RUC; se crea con PEN y USD). | | `factor_tipo_de_cambio` | number | no | Tipo de cambio de venta. Por defecto `1`. | | `numero_orden_de_compra` | string | no | Referencia de orden de compra (aparece en PDF y XML). | | `numero_de_placa` | string | no | Placa vehicular (ventas de combustible, etc.). | | `informacion_adicional` | string | no | Texto libre que aparece en el PDF y viaja en el XML como nota adicional. | | `codigo_condicion_de_pago` | string (`01`, `02`) | no | `01` Contado, `02` Crédito (requiere `cuotas`). Un texto como "Credito" se ignora y se emite al contado. Por defecto `01`. | | `cuotas` | array de objetos | no | Cuotas del crédito. La suma debe coincidir con `total_venta`. | | `cuotas[].fecha` | string (date) | sí | Fecha de vencimiento de la cuota. | | `cuotas[].codigo_tipo_moneda` | string (`PEN`, `USD`) | sí | Código de moneda (catálogo del RUC; se crea con PEN y USD). | | `cuotas[].monto` | number | sí | | | `cuotas[].codigo_metodo_de_pago` | string | no | | | `pagos` | array de objetos | no | Pagos recibidos (solo 01/03). | | `pagos[].codigo_metodo_pago` | string | sí | Código de método de pago del RUC. | | `pagos[].codigo_destino_pago` | string | no | | | `pagos[].referencia` | string | no | | | `pagos[].monto` | number | no | Por defecto `0`. | | `pagos[].pago_recibido` | number | no | | | `datos_del_cliente_o_receptor` | objeto `Cliente` (ver Esquemas) | sí | | | `totales` | objeto `Totales` (ver Esquemas) | sí | Importes del comprobante. Si un total viene en 0 o vacío, el sistema lo recalcula desde los ítems. | | `items` | array de `ItemComprobante` (ver Esquemas) | sí | 1 a ∞ elementos. | | `detraccion` | object | no | Datos de detracción. La leyenda 2006 se agrega automáticamente. | | `detraccion.codigo_tipo_detraccion` | string | sí | Código de bien/servicio (catálogo 54), ej. `022`. | | `detraccion.porcentaje` | number | sí | | | `detraccion.monto` | number | sí | Porcentaje aplicado sobre el total con IGV, a 2 decimales. | | `detraccion.codigo_metodo_pago` | string | sí | Catálogo 59, ej. `001` depósito en cuenta. | | `detraccion.cuenta_bancaria` | string | sí | Cuenta de detracciones del Banco de la Nación. | | `detraccion.ubigeo_origen` | string | no | Obligatorio con operación 1004. Formato `^\d{6}$`. | | `detraccion.ubigeo_destino` | string | no | Obligatorio con operación 1004. Formato `^\d{6}$`. | | `detraccion.direccion_origen` | string | no | | | `detraccion.direccion_destino` | string | no | | | `detraccion.detalle_viaje` | string | no | | | `detraccion.valor_referencial_carga_util` | number | no | | | `detraccion.valor_referencial_servicio_transporte` | number | no | | | `detraccion.valor_referencia_carga_efectiva` | number | no | | | `percepcion` | object | no | Percepción o retención. | | `percepcion.codigo` | string | sí | | | `percepcion.porcentaje` | number | sí | | | `percepcion.monto` | number | sí | | | `percepcion.base` | number | sí | | | `retencion` | object | no | Percepción o retención. | | `retencion.codigo` | string | sí | | | `retencion.porcentaje` | number | sí | | | `retencion.monto` | number | sí | | | `retencion.base` | number | sí | | | `cargos` | array de objetos | no | | | `cargos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `cargos[].descripcion` | string | sí | | | `cargos[].factor` | number | sí | | | `cargos[].monto` | number | sí | | | `cargos[].base` | number | sí | | | `descuentos` | array de objetos | no | | | `descuentos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `descuentos[].descripcion` | string | sí | | | `descuentos[].factor` | number | sí | | | `descuentos[].monto` | number | sí | | | `descuentos[].base` | number | sí | | | `anticipos` | array de objetos | no | | | `anticipos[].numero` | string | sí | | | `anticipos[].codigo_tipo_documento` | string | sí | | | `anticipos[].monto` | number | sí | | | `anticipos[].total` | number | sí | | | `guias` | array de objetos | no | Guías de remisión relacionadas. | | `guias[].numero` | string | sí | | | `guias[].codigo_tipo_documento` | string | sí | | | `relacionados` | array de objetos | no | Otros documentos relacionados. | | `relacionados[].numero` | string | sí | | | `relacionados[].codigo_tipo_documento` | string | sí | | | `relacionados[].monto` | number | sí | | | `leyendas` | array de objetos | no | | | `leyendas[].codigo` | string | sí | Catálogo 52 SUNAT. | | `leyendas[].valor` | string | sí | | | `acciones` | object | no | | | `acciones.enviar_email` | boolean | no | Enviar el comprobante por correo al cliente. | | `acciones.enviar_xml_firmado` | boolean | no | | | `acciones.formato_pdf` | string | no | Formato del PDF (ej. a4, ticket). | | `codigo_tipo_nota` | string | no | Obligatorio en 07/08. Crédito (cat. 09): 01 Anulación de la operación, 07 Devolución por ítem, etc.; débito (cat. 10): 01 Intereses por mora, 02 Aumento en el valor, 03 Penalidades. | | `motivo_o_sustento_de_nota` | string | no | Solo 07/08. Motivo de la nota (si se omite queda vacío). | | `documento_afectado` | object | no | Obligatorio en 07/08 (sin este bloque la emisión de la nota falla con 422). | | `documento_afectado.external_id` | string (uuid) | no | | | `documento_afectado.serie_documento` | string | no | | | `documento_afectado.numero_documento` | string | no | | | `documento_afectado.codigo_tipo_documento` | string (`01`, `03`) | no | | Ejemplo de petición — Factura mínima (sin serie, cliente solo con RUC): ```json { "ruc": "20100070970", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:30:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "01", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955" }, "totales": { "total_operaciones_gravadas": 100, "total_igv": 18, "total_impuestos": 18, "total_valor": 100, "total_venta": 118 }, "items": [ { "codigo_interno": "PROD-001", "descripcion": "Servicio de consultoría", "unidad_de_medida": "NIU", "cantidad": 1, "valor_unitario": 100, "codigo_tipo_precio": "01", "precio_unitario": 118, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 100, "porcentaje_igv": 18, "total_igv": 18, "total_impuestos": 18, "total_valor_item": 100, "total_item": 118 } ] } ``` Ejemplo de petición — Boleta a cliente con DNI: ```json { "ruc": "20100070970", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:30:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "03", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "1", "numero_documento": "12345678" }, "totales": { "total_operaciones_gravadas": 50, "total_igv": 9, "total_impuestos": 9, "total_valor": 50, "total_venta": 59 }, "items": [ { "codigo_interno": "PROD-002", "descripcion": "Producto de prueba", "unidad_de_medida": "NIU", "cantidad": 1, "valor_unitario": 50, "codigo_tipo_precio": "01", "precio_unitario": 59, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 50, "porcentaje_igv": 18, "total_igv": 9, "total_impuestos": 9, "total_valor_item": 50, "total_item": 59 } ] } ``` Ejemplo de petición — Nota de crédito (anulación de la operación) referida por serie-número: ```json { "ruc": "20100070970", "serie_documento": "FN01", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:45:00", "codigo_tipo_documento": "07", "codigo_tipo_nota": "01", "motivo_o_sustento_de_nota": "Anulación de la operación", "documento_afectado": { "serie_documento": "FA01", "numero_documento": "1", "codigo_tipo_documento": "01" }, "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955" }, "totales": { "total_operaciones_gravadas": 100, "total_igv": 18, "total_impuestos": 18, "total_valor": 100, "total_venta": 118 }, "items": [ { "codigo_interno": "PROD-001", "descripcion": "Servicio de consultoría", "unidad_de_medida": "NIU", "cantidad": 1, "valor_unitario": 100, "codigo_tipo_precio": "01", "precio_unitario": 118, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 100, "porcentaje_igv": 18, "total_igv": 18, "total_impuestos": 18, "total_valor_item": 100, "total_item": 118 } ] } ``` Ejemplo de petición — Factura sujeta a detracción (operación 1001): ```json { "ruc": "20100070970", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:30:00", "codigo_tipo_operacion": "1001", "codigo_tipo_documento": "01", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955" }, "totales": { "total_operaciones_gravadas": 1000, "total_igv": 180, "total_impuestos": 180, "total_valor": 1000, "total_venta": 1180 }, "detraccion": { "codigo_tipo_detraccion": "022", "porcentaje": 12, "monto": 141.6, "codigo_metodo_pago": "001", "cuenta_bancaria": "00-000-000000" }, "items": [ { "codigo_interno": "SERV-001", "descripcion": "Servicio empresarial sujeto a detracción", "unidad_de_medida": "ZZ", "cantidad": 1, "valor_unitario": 1000, "codigo_tipo_precio": "01", "precio_unitario": 1180, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 1000, "porcentaje_igv": 18, "total_igv": 180, "total_impuestos": 180, "total_valor_item": 1000, "total_item": 1180 } ] } ``` Ejemplo de petición — Factura al crédito con cuotas, orden de compra e información adicional: ```json { "ruc": "20100070970", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:30:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "01", "codigo_tipo_moneda": "PEN", "numero_orden_de_compra": "OC-2026-4521", "codigo_condicion_de_pago": "02", "cuotas": [ { "fecha": "2026-08-08", "codigo_tipo_moneda": "PEN", "monto": 118 }, { "fecha": "2026-09-08", "codigo_tipo_moneda": "PEN", "monto": 118 } ], "informacion_adicional": "Entrega en almacén central", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955" }, "totales": { "total_operaciones_gravadas": 200, "total_igv": 36, "total_impuestos": 36, "total_valor": 200, "total_venta": 236 }, "items": [ { "descripcion": "Servicio de consultoría", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 100, "codigo_tipo_precio": "01", "precio_unitario": 118, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 200, "porcentaje_igv": 18, "total_igv": 36, "total_impuestos": 36, "total_valor_item": 200, "total_item": 236 } ] } ``` **Respuestas** - `200`: Comprobante generado (o respuesta repetida por `Idempotency-Key`). No implica aceptación de SUNAT: revisa `aceptado_por_sunat`. - `401`: API key ausente, inválida o cuenta desactivada. - `402`: Sin créditos disponibles. No se emitió ni envió nada. - `403`: El RUC no pertenece a la cuenta, está desactivado, o usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción (`LLAVE_DE_PRUEBA_EN_PRODUCCION`). - `409`: Ya hay una emisión en curso con esa `Idempotency-Key`. - `422`: Falta el RUC (cuenta con varios), el RUC no tiene tenant, payload inválido (`Datos del comprobante inválidos o incompletos...`) o error del motor de emisión (`Ocurrió un error al emitir el comprobante: ...`). El crédito no se cobra. - `429`: Dos casos con el mismo HTTP 429, distínguelos por `codigo`: - `LIMITE_PETICIONES` (reintentable): superaste 120/min por API key o 60/min por IP. Espera `Retry-After` segundos. - `LIMITE_PRUEBAS_DIARIO` (**no** reintentable): llegaste a las 200 operaciones en modo demo de hoy. El cupo se reinicia a medianoche (hora de Perú). En producción no existe este tope. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`EmitDocumentResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `estado_descripcion` | string/null | no | | | `serie_numero` | string | sí | | | `external_id` | string (uuid) | sí | | | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `creditos_restantes` | integer | sí | Tu saldo real de créditos después de la operación (en demo no cambia). | | `credito_cobrado` | boolean | sí | `true` si esta operación descontó 1 crédito. En demo siempre `false`; en `/guias` también `false` si la guía no se pudo enviar (se devolvió). | | `modo_demo` | boolean = `true` | no | Solo en RUCs demo. | | `pruebas_restantes_hoy` | integer | no | Solo en RUCs demo. Operaciones demo que le quedan hoy a la cuenta (tope 200 por día, se reinicia a medianoche hora de Perú). | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | Ejemplo `200`: ```json { "success": true, "estado": "05", "estado_descripcion": "Aceptado", "serie_numero": "FA01-1234", "external_id": "9822f52c-108e-47ff-9d2e-5f0c3c1a7b11", "sunat": { "codigo": "0", "descripcion": "La Factura numero FA01-1234, ha sido aceptada" }, "creditos_restantes": 4830, "credito_cobrado": true, "aceptado_por_sunat": true, "entorno": "prueba", "tiene_validez_fiscal": false } ``` Ejemplo `409`: ```json { "success": false, "message": "Ya hay una emisión en curso con esa Idempotency-Key. Reintenta en unos segundos.", "codigo": "EMISION_EN_CURSO", "reintentable": true } ``` Ejemplo `422` — payloadInvalido: ```json { "success": false, "message": "Datos del comprobante inválidos o incompletos. Revisa el formato del JSON. (La serie ingresada FA09, es incorrecta.)", "codigo": "DATOS_INVALIDOS", "reintentable": false } ``` Ejemplo `422` — rucRequerido: ```json { "success": false, "message": "Indica el RUC emisor en el campo \"ruc\" (tu cuenta tiene varios RUCs enlazados).", "codigo": "RUC_REQUERIDO", "reintentable": false } ``` #### GET /documents/{external_id} — Consultar un comprobante - operationId: `getDocument` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/documents/{external_id}` - Grupo: Comprobantes Estado guardado del comprobante (no consulta a SUNAT en vivo; para eso usa `POST /documents/{external_id}/consultar-sunat`). Incluye links de descarga. No consume crédito. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Datos del comprobante. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: No existe un comprobante con ese `external_id` en el RUC. - `422`: No se pudo determinar el RUC emisor (cuenta con varios RUCs y no se indicó), o el RUC no tiene tenant. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`DocumentDetailResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `estado_descripcion` | string/null | no | | | `serie_numero` | string | sí | | | `external_id` | string (uuid) | sí | | | `fecha_emision` | string/null (date) | no | | | `total` | number | no | | | `links` | object | sí | URLs absolutas de descarga (incluyen `?ruc=` si lo enviaste). | | `links.pdf` | string (uri) | no | | | `links.pdf_ticket` | string (uri) | no | El mismo PDF en ticket de 80 mm (`?formato=ticket`). | | `links.xml` | string (uri) | no | | | `links.cdr` | string (uri) | no | | | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | #### GET /documents/{external_id}/{type} — Descargar PDF, XML o CDR - operationId: `downloadDocumentFile` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/documents/{external_id}/{type}` - Grupo: Comprobantes Descarga el PDF (formato A4, se regenera en cada descarga), el XML firmado o el CDR (ZIP de SUNAT) del comprobante. No consume crédito. El PDF y el XML existen desde que el comprobante se genera (incluso en estado `01`); el CDR solo cuando SUNAT respondió. Si el comprobante o el archivo (PDF, XML o CDR) no existen, responde `404` JSON con `codigo: NO_ENCONTRADO` (si falta el CDR, consulta a SUNAT con `/consultar-sunat` y reintenta). Si tu cuenta tiene varios RUC, indica el emisor con `?ruc=20100070970` (o el header `X-RUC`); sin él responde `422 RUC_REQUERIDO`. Basta el header `X-API-Key`: no hace falta un header `Accept` especial (la respuesta trae el `Content-Type` del archivo; los errores siempre en JSON). **PDF en ticket u otro tamaño:** `GET /documents/{external_id}/pdf?formato=ticket`. | `formato` | Tamaño | Ancho de página | |---|---|---| | *(sin formato)* | A4, igual que siempre | 210 mm | | `a4` | A4 | 210 mm | | `a5` | A5 apaisado | 210 × 148 mm | | `ticket` o `ticket80` | Ticket de 80 mm | 78 mm | | `ticket58` | Ticket de 58 mm | 56 mm | | `ticket50` | Ticket de 50 mm | 45 mm | - Es el mismo PDF que imprime el sistema web con el botón "Imprimir" en ese tamaño (mismo diseño y logo del RUC). Funciona en cualquier estado, también en `01`. - Pedir otro formato **no cambia** el PDF que recibe tu cliente final (correo, consulta pública) ni el que descargas sin `formato`: ese sigue siendo el A4. - Solo aplica al PDF: `/xml?formato=…` o `/cdr?formato=…` → `422 DATOS_INVALIDOS`. Un formato inválido también → `422`, con la lista en `formatos_validos`. - Si el generador falla dos veces seguidas → `500 PDF_NO_GENERADO` (reintentable). Nunca se entrega un PDF de otro tamaño "disfrazado". - `GET /documents/{external_id}` trae también `links.pdf_ticket` (el de 80 mm). **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `type` | path | sí | Archivo a descargar. | | `formato` | query | no | Solo para `pdf`. Tamaño del PDF. Sin él, el A4 de siempre. `ticket` y `ticket80` son el mismo (80 mm). | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: El archivo, como adjunto. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: Comprobante no encontrado, o PDF/XML/CDR inexistente en el servidor. - `422`: Falta el RUC, `formato` inválido, o `formato` en `xml`/`cdr`. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno, o el PDF en el `formato` pedido no se pudo generar (`PDF_NO_GENERADO`, reintentable). Ejemplo `404` — comprobante: ```json { "success": false, "message": "Comprobante no encontrado.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` Ejemplo `404` — cdr: ```json { "success": false, "message": "El CDR de este comprobante no está disponible todavía. Consulta a SUNAT con POST /documents/{external_id}/consultar-sunat y vuelve a intentar.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` Ejemplo `422` — rucRequerido: ```json { "success": false, "message": "Indica el RUC emisor en el campo \"ruc\".", "codigo": "RUC_REQUERIDO", "reintentable": false } ``` Ejemplo `422` — Formato de PDF inválido: ```json { "success": false, "message": "Formato de PDF inválido: \"carta\". Válidos: a4, a5, ticket, ticket80, ticket58, ticket50.", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "formato": [ "Debe ser uno de: a4, a5, ticket, ticket80, ticket58, ticket50." ] }, "formatos_validos": [ "a4", "a5", "ticket", "ticket80", "ticket58", "ticket50" ] } ``` Ejemplo `422` — formato en xml o cdr: ```json { "success": false, "message": "El parámetro \"formato\" solo aplica al PDF (GET /documents/{external_id}/pdf).", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "formato": [ "Solo aplica al PDF." ] } } ``` Ejemplo `500` — pdfNoGenerado: ```json { "success": false, "message": "No se pudo generar el PDF en formato ticket58. Vuelve a intentar en unos segundos.", "codigo": "PDF_NO_GENERADO", "reintentable": true } ``` Ejemplo `500` — interno: ```json { "success": false, "message": "Ocurrió un error interno. Intenta de nuevo en unos segundos.", "codigo": "ERROR_INTERNO", "reintentable": true } ``` #### POST /documents/{external_id}/reenviar — Reenviar a SUNAT - operationId: `resendDocument` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/documents/{external_id}/reenviar` - Grupo: Comprobantes Reintenta el envío del mismo comprobante (sin crear uno nuevo ni gastar correlativo). Pensado para estados `01` y `09`. No consume crédito. `success: true` significa que el reintento se ejecutó; revisa `aceptado_por_sunat`. Si el reintento falla responde `422` **con el mismo cuerpo** (`success: false`, `mensaje`, `estado`...) más los campos de error comunes `message`, `codigo` (`RECHAZADO_SUNAT`, `SUNAT_NO_DISPONIBLE` o `REENVIO_FALLIDO`) y `reintentable`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `RucOnlyBody` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | RUC emisor (obligatorio si la cuenta tiene varios). Formato `^\d{11}$`. | Ejemplo de petición: ```json { "ruc": "20100070970" } ``` **Respuestas** - `200`: Reintento ejecutado. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC no pertenece a la cuenta, está desactivado, o usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción (`LLAVE_DE_PRUEBA_EN_PRODUCCION`). - `404`: No existe un comprobante con ese `external_id` en el RUC. - `422`: Falta el RUC (formato `Error`) o el reintento falló (formato `ResendResponse` con `success:false`, más `message`, `codigo` y `reintentable`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`ResendResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean | sí | Si el reintento se ejecutó sin error de sistema (no implica aceptación). | | `mensaje` | string/null | no | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `serie_numero` | string | sí | | | `external_id` | string (uuid) | sí | | | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | #### POST /documents/{external_id}/consultar-sunat — Consultar estado real en SUNAT - operationId: `querySunatStatus` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/documents/{external_id}/consultar-sunat` - Grupo: Comprobantes Pregunta a SUNAT en vivo por el comprobante (consulta de CDR), sincroniza el estado y lo devuelve. Útil cuando el envío quedó en el aire. No consume crédito. **Solo funciona con RUC en producción.** SUNAT no ofrece este servicio de consulta en su entorno de pruebas (beta): con un RUC en modo prueba la respuesta es `422` con "No se pudo consultar a SUNAT: ... Unauthorized". En pruebas usa `POST /documents/{external_id}/reenviar`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `RucOnlyBody` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | RUC emisor (obligatorio si la cuenta tiene varios). Formato `^\d{11}$`. | Ejemplo de petición: ```json { "ruc": "20100070970" } ``` **Respuestas** - `200`: Consulta realizada. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC no pertenece a la cuenta, está desactivado, o usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción (`LLAVE_DE_PRUEBA_EN_PRODUCCION`). - `404`: No existe un comprobante con ese `external_id` en el RUC. - `422`: Falta el RUC o la consulta a SUNAT falló (`No se pudo consultar a SUNAT: ...`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`QuerySunatResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `serie_numero` | string | sí | | | `external_id` | string (uuid) | sí | | | `consulta` | object/null | no | Respuesta cruda de la consulta de CDR a SUNAT. | | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | #### GET /documents/{external_id}/historial — Historial del comprobante - operationId: `getDocumentHistory` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/documents/{external_id}/historial` - Grupo: Comprobantes Línea de tiempo del comprobante (creación, cambios de estado, respuestas de SUNAT, reintentos, errores). No consume crédito. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Historial. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: No existe un comprobante con ese `external_id` en el RUC. - `422`: No se pudo determinar el RUC emisor (cuenta con varios RUCs y no se indicó), o el RUC no tiene tenant. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`HistoryResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `external_id` | string (uuid) | sí | | | `serie_numero` | string | sí | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `historial` | array de objetos | sí | | | `historial[].fecha` | string/null | no | YYYY-MM-DD HH:MM:SS | | `historial[].evento` | string (`creado`, `registrado`, `enviado`, `aceptado`, `observado`, `rechazado`, `anulado`, `por_anular`, `anulacion_enviada`, `error`, `reintento`) | no | `anulacion_enviada` trae en `detalle` el tipo (`resumen`/`baja`), el `ticket` y el `identificador` (`RC-…`/`RA-…`). | | `historial[].mensaje` | string/null | no | | | `historial[].codigo_sunat` | string/null | no | | | `historial[].detalle` | object/array/null | no | | | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | ### Anulaciones Anulación de facturas (comunicación de baja) y boletas (resumen diario). Asíncrona, con ticket. #### POST /anulaciones — Anular comprobantes (facturas o boletas) - operationId: `voidDocument` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/anulaciones` - Grupo: Anulaciones Anula comprobantes ante SUNAT y devuelve un `ticket` para consultar el resultado. Es asíncrono: el comprobante pasa a `13` (por anular) y queda en `11` (anulado) cuando SUNAT acepta. Cuesta 1 crédito (se devuelve si el envío falla; gratis en RUCs demo). La API elige el camino según lo que mandes: | Mandas | SUNAT lo anula con | `tipo` en la respuesta | `identificador` | |---|---|---|---| | Facturas y sus notas | Comunicación de baja | `baja` | `RA-AAAAMMDD-N` | | Boletas y sus notas | Resumen diario de anulación | `resumen` | `RC-AAAAMMDD-N` | | Las dos cosas juntas | — | `422 ANULACION_MIXTA` | — | - Boletas: solo se anulan las **aceptadas** por SUNAT (estado `05`). Otra → `422 DATOS_INVALIDOS`. - No mezcles facturas y boletas en la misma petición: haz una petición para cada tipo. - `fecha_de_emision_de_documentos` en formato **DD-MM-AAAA** y debe ser **exactamente** la fecha de emisión de cada comprobante listado (todos deben compartirla). - Plazo: el que tenga configurado la empresa para anular (por defecto **7 días** desde la emisión, el plazo de SUNAT). Fuera de plazo → `422` (`El documento excede los 7 días válidos para ser anulado.`). - Requiere certificado digital configurado en el RUC (o envío por PSE). - Cada comprobante deja en su historial el evento `anulacion_enviada` (con tipo, ticket e identificador). **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `VoidRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | Formato `^\d{11}$`. | | `fecha_de_emision_de_documentos` | string | sí | Fecha de emisión de los comprobantes a anular, formato DD-MM-AAAA. Debe coincidir con la de cada uno. Formato `^\d{2}-\d{2}-\d{4}$`. | | `documentos` | array de objetos | sí | 1 a ∞ elementos. | | `documentos[].external_id` | string (uuid) | sí | | | `documentos[].motivo_anulacion` | string | sí | | Ejemplo de petición: ```json { "ruc": "20100070970", "fecha_de_emision_de_documentos": "08-07-2026", "documentos": [ { "external_id": "9822f52c-108e-47ff-9d2e-5f0c3c1a7b11", "motivo_anulacion": "Error en el comprobante" } ] } ``` **Respuestas** - `200`: Anulación enviada a SUNAT (comunicación de baja o resumen de boletas). - `401`: API key ausente, inválida o cuenta desactivada. - `402`: Sin créditos disponibles. No se emitió ni envió nada. - `403`: El RUC no pertenece a la cuenta, está desactivado, o usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción (`LLAVE_DE_PRUEBA_EN_PRODUCCION`). - `422`: Falta el RUC, facturas y boletas mezcladas (`ANULACION_MIXTA`), payload inválido (p. ej. `El código externo ... no fue encontrado o la fecha indica no corresponde al documento.`), boleta no aceptada, fuera de plazo, certificado no encontrado, o error al generar/enviar la anulación. El crédito no se cobra. - `429`: Dos casos con el mismo HTTP 429, distínguelos por `codigo`: - `LIMITE_PETICIONES` (reintentable): superaste 120/min por API key o 60/min por IP. Espera `Retry-After` segundos. - `LIMITE_PRUEBAS_DIARIO` (**no** reintentable): llegaste a las 200 operaciones en modo demo de hoy. El cupo se reinicia a medianoche (hora de Perú). En producción no existe este tope. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`VoidResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `message` | string | sí | | | `tipo` | string (`baja`, `resumen`) | sí | `baja` = comunicación de baja (facturas y sus notas, RA-…); `resumen` = resumen diario de anulación (boletas y sus notas, RC-…). | | `ticket` | string/null | sí | Ticket de SUNAT para `GET /anulaciones/{ticket}/status`. | | `external_id` | string | sí | Identificador de la anulación (comunicación de baja o resumen). | | `identificador` | string/null | sí | Número SUNAT de la anulación: `RA-AAAAMMDD-N` (baja) o `RC-AAAAMMDD-N` (resumen). | | `creditos_restantes` | integer | sí | Tu saldo real de créditos después de la operación (en demo no cambia). | | `credito_cobrado` | boolean | sí | `true` si esta operación descontó 1 crédito. En demo siempre `false`; en `/guias` también `false` si la guía no se pudo enviar (se devolvió). | | `modo_demo` | boolean = `true` | no | Solo en RUCs demo. | | `pruebas_restantes_hoy` | integer | no | Solo en RUCs demo. Operaciones demo que le quedan hoy a la cuenta (tope 200 por día, se reinicia a medianoche hora de Perú). | Ejemplo `200` — Factura en un RUC en producción (comunicación de baja): ```json { "success": true, "message": "Anulación enviada a SUNAT. Consulta el resultado con el ticket.", "tipo": "baja", "ticket": "1792012278123", "external_id": "5b1d0e7a-3c2f-4a51-9d1e-2f6a7c8b9d10", "identificador": "RA-20261014-1", "creditos_restantes": 4829, "credito_cobrado": true } ``` Ejemplo `200` — Boleta en un RUC demo (resumen diario, sin costo): ```json { "success": true, "message": "Anulación enviada a SUNAT. Consulta el resultado con el ticket.", "tipo": "resumen", "ticket": "1792012278669", "external_id": "87c0049e-1225-43af-8a0a-7d2a7d8d32ab", "identificador": "RC-20261014-1", "creditos_restantes": 14945, "credito_cobrado": false, "modo_demo": true, "pruebas_restantes_hoy": 197 } ``` Ejemplo `422` — Facturas y boletas en la misma petición: ```json { "success": false, "message": "No se pueden anular facturas y boletas en la misma petición: SUNAT las anula por caminos distintos (comunicación de baja para facturas, resumen diario para boletas). Envía una petición para las facturas y otra para las boletas.", "codigo": "ANULACION_MIXTA", "reintentable": false } ``` Ejemplo `422` — Boleta que no está aceptada por SUNAT: ```json { "success": false, "message": "La boleta BA01-72 no se puede anular: su estado es \"Anulado\". Solo se anulan boletas aceptadas por SUNAT (estado 05).", "codigo": "DATOS_INVALIDOS", "reintentable": false } ``` Ejemplo `422` — Fuera del plazo de anulación: ```json { "success": false, "message": "El documento excede los 7 días válidos para ser anulado.", "codigo": "DATOS_INVALIDOS", "reintentable": false } ``` #### GET /anulaciones/{ticket}/status — Estado de una anulación - operationId: `getVoidStatus` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/anulaciones/{ticket}/status` - Grupo: Anulaciones Consulta el resultado de la anulación (comunicación de baja o resumen de boletas) asociada al ticket. Si SUNAT ya respondió, devuelve esa respuesta guardada sin volver a preguntar. No consume crédito. - `estado` es el de la anulación: `05` aceptada por SUNAT, `09` rechazada, `03` enviada. - `documentos[].estado` es el de cada comprobante: `11` anulado, `13` todavía por anular. - Si SUNAT rechaza un resumen de boletas, las boletas vuelven a `01`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ticket` | path | sí | Ticket devuelto por `POST /anulaciones`. | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Resultado de la consulta. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: No existe una anulación con ese ticket. - `422`: Falta el RUC o la consulta a SUNAT falló. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`VoidStatusResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `tipo` | string (`baja`, `resumen`) | sí | `baja` = comunicación de baja (facturas y sus notas, RA-…); `resumen` = resumen diario de anulación (boletas y sus notas, RC-…). | | `ticket` | string | sí | | | `estado` | object | sí | Estado de la anulación: `05` aceptada por SUNAT, `09` rechazada, `03` enviada. | | `external_id` | string | sí | | | `documentos` | array de objetos | sí | Cada comprobante incluido en la anulación y su estado actual. | | `documentos[].external_id` | string/null (uuid) | no | | | `documentos[].serie_numero` | string/null | no | | | `documentos[].estado` | string/null | no | `11` anulado (SUNAT aceptó), `13` todavía por anular; `01` si SUNAT rechazó un resumen de boletas. | | `sunat` | object/array/null | no | Respuesta de SUNAT a la consulta del ticket (la guardada, si SUNAT ya respondió). | Ejemplo `200`: ```json { "success": true, "tipo": "resumen", "ticket": "1792012278669", "estado": "05", "external_id": "87c0049e-1225-43af-8a0a-7d2a7d8d32ab", "documentos": [ { "external_id": "a3af5a50-dfe9-41e9-a4be-6de52c0b5c61", "serie_numero": "BA01-72", "estado": "11" } ], "sunat": { "sent": true, "code": "0", "description": "El Resumen diario RC-20261014-1, ha sido aceptado", "notes": [], "is_accepted": true, "status_code": 0 } } ``` Ejemplo `404`: ```json { "success": false, "message": "Anulación no encontrada para ese ticket.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` ### Guías de remisión Guías de remisión electrónica GRE 2.0 (asíncronas). #### POST /guias — Emitir guía de remisión (GRE 2.0) - operationId: `emitDispatchGuide` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/guias` - Grupo: Guías de remisión Genera, firma y crea el PDF de la guía; luego la envía a SUNAT (proceso asíncrono). Cuesta 1 crédito solo si el envío a SUNAT tiene éxito; si no se pudo enviar, la guía queda creada, `enviado` es `false` y el crédito se devuelve (respuesta `200`). - Requiere certificado digital y credenciales GRE de SUNAT configurados en el RUC. - Envía siempre `serie_documento` (ej. `TA01` remitente, `VA01` transportista). - El establecimiento se indica con `datos_del_emisor.codigo_del_domicilio_fiscal` (no con `codigo_establecimiento`). - Para `09` (remitente) el bloque `datos_del_cliente_o_receptor` es obligatorio. - No usa `Idempotency-Key`. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Cuerpo (JSON)** — esquema `DispatchGuideRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | Formato `^\d{11}$`. | | `serie_documento` | string | sí | Serie de guía (ej. TA01 remitente, VA01 transportista). | | `numero_documento` | string | no | Por defecto `#`. | | `fecha_de_emision` | string (date) | sí | | | `hora_de_emision` | string | sí | Formato `^\d{2}:\d{2}:\d{2}$`. | | `codigo_tipo_documento` | string (`09`, `31`) | sí | 09 guía del remitente, 31 guía del transportista. | | `datos_del_emisor` | object | sí | | | `datos_del_emisor.codigo_del_domicilio_fiscal` | string | sí | Código del establecimiento emisor (`0000` el principal). | | `datos_del_cliente_o_receptor` | objeto `Cliente` (ver Esquemas) | no | | | `observaciones` | string | no | | | `codigo_modo_transporte` | string (`01`, `02`) | no | 01 público (transportista), 02 privado. | | `codigo_motivo_traslado` | string | no | Catálogo 20 (01 venta, 02 compra, 04 traslado entre establecimientos...). | | `descripcion_motivo_traslado` | string | no | | | `fecha_de_traslado` | string (date) | no | | | `indicador_de_transbordo` | boolean | no | Por defecto `false`. | | `codigo_de_puerto` | string | no | | | `unidad_peso_total` | string | no | | | `peso_total` | number | no | | | `numero_de_bultos` | integer | no | | | `numero_de_contenedor` | string | no | | | `es_traslado_m1l` | boolean | no | Vehículo M1 o L (sin conductor ni vehículo). Por defecto `false`. | | `numero_de_placa_m1l` | string | no | | | `direccion_partida` | object | no | | | `direccion_partida.ubigeo` | string | sí | Formato `^\d{6}$`. | | `direccion_partida.direccion` | string | sí | | | `direccion_partida.codigo_del_domicilio_fiscal` | string | sí | Código de establecimiento SUNAT del punto (`-` si no aplica). | | `direccion_llegada` | object | no | | | `direccion_llegada.ubigeo` | string | sí | Formato `^\d{6}$`. | | `direccion_llegada.direccion` | string | sí | | | `direccion_llegada.codigo_del_domicilio_fiscal` | string | sí | Código de establecimiento SUNAT del punto (`-` si no aplica). | | `transportista` | object | no | Transporte público (modo 01). | | `transportista.codigo_tipo_documento_identidad` | string | sí | | | `transportista.numero_documento` | string | sí | | | `transportista.apellidos_y_nombres_o_razon_social` | string | sí | | | `transportista.numero_mtc` | string | sí | | | `chofer` | object | no | | | `chofer.codigo_tipo_documento_identidad` | string | sí | | | `chofer.numero_documento` | string | sí | | | `chofer.nombres` | string | no | | | `chofer.numero_licencia` | string | no | | | `chofer.telefono` | string | no | | | `chofer_secundario` | array de objetos | no | 0 a 2 elementos. | | `chofer_secundario[].codigo_tipo_documento_identidad` | string | sí | | | `chofer_secundario[].numero_documento` | string | sí | | | `chofer_secundario[].nombres` | string | no | | | `chofer_secundario[].numero_licencia` | string | no | | | `chofer_secundario[].telefono` | string | no | | | `vehiculo` | object | no | | | `vehiculo.numero_de_placa` | string | no | | | `vehiculo.modelo` | string | no | | | `vehiculo.marca` | string | no | | | `vehiculo.certificado_habilitacion_vehicular` | string | no | | | `vehiculo_secundario` | array de objetos | no | 0 a 2 elementos. | | `vehiculo_secundario[].numero_de_placa` | string | no | | | `vehiculo_secundario[].modelo` | string | no | | | `vehiculo_secundario[].marca` | string | no | | | `vehiculo_secundario[].certificado_habilitacion_vehicular` | string | no | | | `documento_afectado` | object | no | Comprobante que corrige la nota. Con `external_id` se enlaza al comprobante emitido en FactuSmart (si no existe → `422`). Sin `external_id`, se usan `serie_documento` + `numero_documento` + `codigo_tipo_documento` como referencia. | | `documento_afectado.external_id` | string (uuid) | no | | | `documento_afectado.serie_documento` | string | no | | | `documento_afectado.numero_documento` | string | no | | | `documento_afectado.codigo_tipo_documento` | string (`01`, `03`) | no | | | `documento_relacionado` | array de objetos | no | | | `documento_relacionado[].numero` | string | no | | | `documento_relacionado[].empresa` | string | no | | | `documento_relacionado[].ruc` | string | no | | | `documento_relacionado[].documento` | object | no | | | `documento_relacionado[].documento.id` | string | no | | | `documento_relacionado[].documento.descripcion` | string | no | | | `datos_remitente` | object | no | | | `datos_remitente.codigo_tipo_documento_identidad` | string | sí | | | `datos_remitente.descripcion_tipo_documento_identidad` | string | sí | | | `datos_remitente.numero_documento` | string | sí | | | `datos_remitente.apellidos_y_nombres_o_razon_social` | string | sí | | | `datos_destinatario` | object | no | | | `datos_destinatario.codigo_tipo_documento_identidad` | string | sí | | | `datos_destinatario.descripcion_tipo_documento_identidad` | string | sí | | | `datos_destinatario.numero_documento` | string | sí | | | `datos_destinatario.apellidos_y_nombres_o_razon_social` | string | sí | | | `pagador_flete` | object | no | | | `pagador_flete.indicador_pagador_flete` | string | no | | | `pagador_flete.codigo_tipo_documento_identidad` | string | no | | | `pagador_flete.descripcion_tipo_documento_identidad` | string | no | | | `pagador_flete.numero` | string | no | | | `pagador_flete.nombres` | string | no | | | `leyendas` | array de objetos | no | | | `leyendas[].codigo` | string | sí | Catálogo 52 SUNAT. | | `leyendas[].valor` | string | sí | | | `acciones` | object | no | | | `acciones.enviar_email` | boolean | no | Enviar el comprobante por correo al cliente. | | `acciones.enviar_xml_firmado` | boolean | no | | | `acciones.formato_pdf` | string | no | Formato del PDF (ej. a4, ticket). | | `items` | array de objetos | sí | 1 a ∞ elementos. | | `items[].codigo_interno` | string | no | Código del producto; se crea en el catálogo si no existe. | | `items[].descripcion` | string | sí | | | `items[].nombre` | string | no | | | `items[].codigo_tipo_item` | string | no | Por defecto `01`. | | `items[].codigo_producto_sunat` | string | no | Código de producto SUNAT (catálogo 25, UNSPSC, 8 dígitos). Búscalo con [`GET /catalogos/productos-sunat`](#operation/searchSunatProductCodes). Opcional: SUNAT no lo exige en factura ni boleta. | | `items[].unidad_de_medida` | string | sí | | | `items[].cantidad` | number | sí | | | `items[].informacion_adicional` | string | no | | | `items[].dato_adicional` | string | no | | Ejemplo de petición — Guía del remitente, transporte privado: ```json { "ruc": "20100070970", "serie_documento": "TA01", "numero_documento": "#", "fecha_de_emision": "2026-07-08", "hora_de_emision": "10:30:00", "codigo_tipo_documento": "09", "datos_del_emisor": { "codigo_del_domicilio_fiscal": "0000" }, "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20131312955", "apellidos_y_nombres_o_razon_social": "EMPRESA DESTINO S.A.C." }, "codigo_motivo_traslado": "01", "descripcion_motivo_traslado": "Venta", "fecha_de_traslado": "2026-07-08", "peso_total": 50, "unidad_peso_total": "KGM", "numero_de_bultos": 2, "codigo_modo_transporte": "02", "direccion_partida": { "ubigeo": "150101", "direccion": "Av. Partida 123 - Lima", "codigo_del_domicilio_fiscal": "0000" }, "direccion_llegada": { "ubigeo": "150203", "direccion": "Av. Llegada 456 - Barranca", "codigo_del_domicilio_fiscal": "-" }, "vehiculo": { "numero_de_placa": "ABC123" }, "chofer": { "codigo_tipo_documento_identidad": "1", "numero_documento": "12345678", "nombres": "Juan Pérez", "numero_licencia": "Q12345678" }, "items": [ { "codigo_interno": "PROD-001", "descripcion": "Producto trasladado", "unidad_de_medida": "NIU", "cantidad": 10 } ] } ``` **Respuestas** - `200`: Guía creada (revisa `enviado`). - `401`: API key ausente, inválida o cuenta desactivada. - `402`: Sin créditos disponibles. No se emitió ni envió nada. - `403`: El RUC no pertenece a la cuenta, está desactivado, o usaste la llave de prueba (`fs_test_…`) en un RUC que está en producción (`LLAVE_DE_PRUEBA_EN_PRODUCCION`). - `422`: Falta el RUC, payload inválido, certificado no encontrado o error al generar la guía. - `429`: Dos casos con el mismo HTTP 429, distínguelos por `codigo`: - `LIMITE_PETICIONES` (reintentable): superaste 120/min por API key o 60/min por IP. Espera `Retry-After` segundos. - `LIMITE_PRUEBAS_DIARIO` (**no** reintentable): llegaste a las 200 operaciones en modo demo de hoy. El cupo se reinicia a medianoche (hora de Perú). En producción no existe este tope. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`DispatchResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `serie_numero` | string | sí | | | `external_id` | string (uuid) | sí | | | `enviado` | boolean | sí | true si SUNAT aceptó el envío (solo entonces se cobra el crédito, y solo en un RUC en producción). | | `mensaje` | string | sí | | | `envio` | object/null | no | Respuesta cruda del envío a SUNAT (`success`, `message`, ticket...). | | `creditos_restantes` | integer | sí | Tu saldo real de créditos después de la operación (en demo no cambia). | | `credito_cobrado` | boolean | sí | `true` si esta operación descontó 1 crédito. En demo siempre `false`; en `/guias` también `false` si la guía no se pudo enviar (se devolvió). | | `modo_demo` | boolean = `true` | no | Solo en RUCs demo. | | `pruebas_restantes_hoy` | integer | no | Solo en RUCs demo. Operaciones demo que le quedan hoy a la cuenta (tope 200 por día, se reinicia a medianoche hora de Perú). | #### GET /guias/{external_id}/status — Estado de una guía - operationId: `getDispatchGuideStatus` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/guias/{external_id}/status` - Grupo: Guías de remisión Consulta a SUNAT el ticket de envío de la guía y devuelve su estado. No consume crédito. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `external_id` | path | sí | Identificador (UUID) devuelto al emitir. | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Estado de la guía. - `401`: API key ausente, inválida o cuenta desactivada. - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: Guía no encontrada. - `422`: Falta el RUC o la consulta a SUNAT falló. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`DispatchStatusResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `external_id` | string (uuid) | sí | | | `estado` | string (`01`, `03`, `05`, `07`, `09`, `11`, `13`) | sí | Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. | | `sunat` | object/null | no | Respuesta cruda de la consulta del ticket GRE a SUNAT. | Ejemplo `404`: ```json { "success": false, "message": "Guía no encontrada.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` ### Herramientas Calcular importes y consultar catálogos SUNAT. No emiten nada ni consumen crédito. #### POST /calcular — Calcular importes (items y totales) sin emitir - operationId: `calculateDocument` - URL: `POST https://s2.factusmart.pe/api/v1/issuer/calcular` - Grupo: Herramientas Arma `items` y `totales` listos para copiar tal cual en `POST /documents`, para que no tengas que calcular el IGV a mano. **No emite nada ni consume crédito** y no necesita RUC (aunque mandes `ruc` o `X-RUC`, no se usa). Da exactamente el mismo resultado que `calcularComprobante()` / `calcular_comprobante()` de los SDK: redondeo half-up a 2 decimales por ítem y aritmética decimal exacta. - Con `valor_unitario` (sin IGV): `base = redondeo(valor × cantidad)`, `igv = redondeo(base × porcentaje)`, `total = base + igv`. - Con `precio_unitario` (con IGV) se respeta lo que paga el cliente: `total = redondeo(precio × cantidad)`, `base = redondeo(total ÷ (1 + porcentaje))`, `igv = total − base`. Así 3 menús de S/ 10.00 suman S/ 30.00 y no S/ 29.99. - Afectación `40` (exportación) suma en `total_exportacion` con IGV 0. Una exportación necesita factura con `codigo_tipo_operacion` `0200`. - **Descuentos: todavía no.** Si mandas `descuento` en un ítem → `422`. Envía el precio ya descontado. - Para otras afectaciones (gratuitas, ISC, ICBPER…) arma el ítem a mano. Flujo típico: `POST /calcular` → copia `items` y `totales` → `POST /documents` con tu cabecera. **Cuerpo (JSON)** — esquema `CalculateRequest` | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `porcentaje_igv` | number (`18`, `10.5`) | no | IGV de los ítems gravados (afectación `10`). `10.5` = tasa reducida Ley 31556 (restaurantes y hoteles MYPE), solo si tu RUC está acogido. Por defecto `18`. | | `items` | array de `CalculateItem` (ver Esquemas) | sí | 1 a 500 elementos. | Ejemplo de petición — Gravado, exonerado, inafecto y exportación (valor sin IGV): ```json { "items": [ { "descripcion": "A", "cantidad": 3, "valor_unitario": 10.555, "afectacion": "10" }, { "descripcion": "B", "cantidad": 2, "valor_unitario": 5, "afectacion": "20" }, { "descripcion": "C", "cantidad": 1, "valor_unitario": 7.1, "afectacion": "30" }, { "descripcion": "D", "cantidad": 1, "valor_unitario": 100, "afectacion": "40" } ] } ``` Ejemplo de petición — Precio con IGV (lo que paga el cliente): ```json { "items": [ { "codigo_interno": "M1", "descripcion": "Menu", "cantidad": 3, "precio_unitario": 10, "afectacion": "10", "unidad_de_medida": "ZZ" } ] } ``` Ejemplo de petición — Tasa reducida Ley 31556 (10.5 %): ```json { "porcentaje_igv": 10.5, "items": [ { "descripcion": "Menu", "cantidad": 1, "valor_unitario": 100, "afectacion": "10" } ] } ``` **Respuestas** - `200`: Items y totales calculados, listos para `POST /documents`. - `401`: API key ausente, inválida o cuenta desactivada. - `402`: El plan de la cuenta venció (`PLAN_VENCIDO`). - `422`: Datos inválidos. El detalle por ítem viene en `campos` (clave `items.N.campo`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`CalculateResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `items` | array de `ItemComprobante` (ver Esquemas) | sí | Ítems en el formato exacto de `POST /documents` (`items`). | | `totales` | objeto `Totales` (ver Esquemas) | sí | Importes del comprobante. Si un total viene en 0 o vacío, el sistema lo recalcula desde los ítems. | Ejemplo `200` — Respuesta del caso común: ```json { "success": true, "items": [ { "descripcion": "A", "unidad_de_medida": "NIU", "cantidad": 3, "valor_unitario": 10.56, "codigo_tipo_precio": "01", "precio_unitario": 12.45, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 31.67, "porcentaje_igv": 18, "total_igv": 5.7, "total_impuestos": 5.7, "total_valor_item": 31.67, "total_item": 37.37 }, { "descripcion": "B", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 5, "codigo_tipo_precio": "01", "precio_unitario": 5, "codigo_tipo_afectacion_igv": "20", "total_base_igv": 10, "porcentaje_igv": 0, "total_igv": 0, "total_impuestos": 0, "total_valor_item": 10, "total_item": 10 }, { "descripcion": "C", "unidad_de_medida": "NIU", "cantidad": 1, "valor_unitario": 7.1, "codigo_tipo_precio": "01", "precio_unitario": 7.1, "codigo_tipo_afectacion_igv": "30", "total_base_igv": 7.1, "porcentaje_igv": 0, "total_igv": 0, "total_impuestos": 0, "total_valor_item": 7.1, "total_item": 7.1 }, { "descripcion": "D", "unidad_de_medida": "NIU", "cantidad": 1, "valor_unitario": 100, "codigo_tipo_precio": "01", "precio_unitario": 100, "codigo_tipo_afectacion_igv": "40", "total_base_igv": 100, "porcentaje_igv": 0, "total_igv": 0, "total_impuestos": 0, "total_valor_item": 100, "total_item": 100 } ], "totales": { "total_operaciones_gravadas": 31.67, "total_operaciones_exoneradas": 10, "total_operaciones_inafectas": 7.1, "total_exportacion": 100, "total_igv": 5.7, "total_impuestos": 5.7, "total_valor": 148.77, "total_venta": 154.47 } } ``` Ejemplo `200` — Respuesta con precio con IGV (3 × S/ 10.00 = S/ 30.00): ```json { "success": true, "items": [ { "codigo_interno": "M1", "descripcion": "Menu", "unidad_de_medida": "ZZ", "cantidad": 3, "valor_unitario": 8.47, "codigo_tipo_precio": "01", "precio_unitario": 10, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 25.42, "porcentaje_igv": 18, "total_igv": 4.58, "total_impuestos": 4.58, "total_valor_item": 25.42, "total_item": 30 } ], "totales": { "total_operaciones_gravadas": 25.42, "total_operaciones_exoneradas": 0, "total_operaciones_inafectas": 0, "total_exportacion": 0, "total_igv": 4.58, "total_impuestos": 4.58, "total_valor": 25.42, "total_venta": 30 } } ``` Ejemplo `402`: ```json { "success": false, "message": "El plan de tu cuenta venció. Renuévalo para volver a emitir.", "codigo": "PLAN_VENCIDO", "reintentable": false } ``` Ejemplo `422` — camposInvalidos: ```json { "success": false, "message": "Datos inválidos: Falta la descripción. cantidad debe ser un número mayor que 0.", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "items.0.descripcion": [ "Falta la descripción." ], "items.0.cantidad": [ "cantidad debe ser un número mayor que 0." ] } } ``` Ejemplo `422` — sinItems: ```json { "success": false, "message": "Datos inválidos: Envía \"items\": una lista con al menos un producto o servicio.", "codigo": "DATOS_INVALIDOS", "reintentable": false, "campos": { "items": [ "Envía \"items\": una lista con al menos un producto o servicio." ] } } ``` #### GET /catalogos — Listar catálogos SUNAT disponibles - operationId: `listCatalogs` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/catalogos` - Grupo: Herramientas Lista los catálogos SUNAT que puedes consultar con `GET /catalogos/{nombre}`. No consume crédito. | Nombre | Catálogo SUNAT | Contenido | Campos extra | |---|---|---|---| | `tipos-documento` | 01 | Tipos de comprobante | `abreviatura` | | `monedas` | 02 | Monedas | `simbolo` | | `unidades` | 03 | Unidades de medida | `simbolo` | | `documentos-identidad` | 06 | Tipos de documento de identidad | — | | `afectacion-igv` | 07 | Afectación del IGV | `es_exportacion`, `es_gratuito` | | `motivos-nota-credito` | 09 | Motivos de nota de crédito | — | | `motivos-nota-debito` | 10 | Motivos de nota de débito | — | | `modalidad-transporte` | 18 | Modalidad de transporte (guías) | — | | `motivos-traslado` | 20 | Motivos de traslado (guías) | — | | `tipos-operacion` | 51 | Tipos de operación | `es_exportacion` | | `detracciones` | 54 | Bienes y servicios sujetos a detracción | `porcentaje` | | `productos-sunat` | 25 | Código de producto SUNAT (UNSPSC v14, ~53 mil códigos) | — | El catálogo 25 no se lista entero: búscalo con `GET /catalogos/productos-sunat?q=` (ver [Buscar el código de producto SUNAT](#operation/searchSunatProductCodes)). Acepta `?ruc=` / `X-RUC` como el resto de endpoints, pero la lista es la misma para todos los RUC: el RUC solo cambia las filas de `GET /catalogos/{nombre}`. Si lo mandas, debe ser de tu cuenta (si no, `403 RUC_NO_AUTORIZADO`). **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Lista de catálogos. - `401`: API key ausente, inválida o cuenta desactivada. - `402`: El plan de la cuenta venció (`PLAN_VENCIDO`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`CatalogListResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `catalogos` | array de objetos | sí | | | `catalogos[].nombre` | string | sí | | | `catalogos[].catalogo_sunat` | string | sí | | | `catalogos[].descripcion` | string | sí | | | `catalogos[].url` | string | sí | | | `nota` | string | no | | Ejemplo `200`: ```json { "success": true, "catalogos": [ { "nombre": "tipos-documento", "catalogo_sunat": "01", "descripcion": "Tipos de comprobante", "url": "/api/v1/issuer/catalogos/tipos-documento" }, { "nombre": "monedas", "catalogo_sunat": "02", "descripcion": "Monedas", "url": "/api/v1/issuer/catalogos/monedas" }, { "nombre": "unidades", "catalogo_sunat": "03", "descripcion": "Unidades de medida", "url": "/api/v1/issuer/catalogos/unidades" }, { "nombre": "productos-sunat", "catalogo_sunat": "25", "descripcion": "Código de producto SUNAT (UNSPSC v14) — buscador: ?q=texto o código, ?limite= (máx. 50)", "url": "/api/v1/issuer/catalogos/productos-sunat" } ], "nota": "Código de producto SUNAT (UNSPSC, catálogo 25): búscalo con GET /api/v1/issuer/catalogos/productos-sunat?q=texto|codigo y envíalo en \"codigo_producto_sunat\" del ítem." } ``` Ejemplo `402`: ```json { "success": false, "message": "El plan de tu cuenta venció. Renuévalo para volver a emitir.", "codigo": "PLAN_VENCIDO", "reintentable": false } ``` #### GET /catalogos/productos-sunat — Buscar el código de producto SUNAT - operationId: `searchSunatProductCodes` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/catalogos/productos-sunat` - Grupo: Herramientas Buscador del **código de producto SUNAT** (catálogo 25 = UNSPSC v14, 8 dígitos): el valor que va en `codigo_producto_sunat` de cada ítem. No consume crédito y **no necesita RUC** (el catálogo es el mismo para todos; si mandas `?ruc=` se ignora). - `?q=` con **dígitos** busca por código: exacto o prefijo de 2 a 8 dígitos (`4321` lista esa familia). Primero va el código exacto, luego el resto en orden. - `?q=` con **texto** (mínimo 3 letras) busca todas las palabras en la descripción y la categoría, sin importar tildes ni mayúsculas. Primero los productos (nivel 4, el que va en el comprobante) cuya propia descripción contiene la frase, los más cortos primero. - `total` = cuántos resultados vienen en esta respuesta (no el total de coincidencias). `hay_mas: true` = hay más resultados que `limite`: afina la búsqueda o sube `limite`. **Es opcional en factura y boleta**: SUNAT no lo exige para emitirlas. Envíalo solo si tu caso lo requiere; si lo envías, usa un código de nivel `producto`. Fuente: el Excel oficial de SUNAT (`CCNU_MOD_2.xlsm`, hoja "Bienes y Servicios") publicado en . Resultados en caché 6 horas. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `q` | query | sí | Código (2 a 8 dígitos, exacto o prefijo) o texto (3 a 100 caracteres). | | `limite` | query | no | Máximo de resultados (1 a 50). | | `nivel` | query | no | Filtra por nivel de la jerarquía UNSPSC. Para el comprobante usa `producto`. | **Respuestas** - `200`: Códigos encontrados (`datos` puede venir vacío si no hay coincidencias). - `401`: API key ausente, inválida o cuenta desactivada. - `422`: Parámetros inválidos (`DATOS_INVALIDOS`, detalle en `campos`): falta `q`, código de menos de 2 o más de 8 dígitos, texto de menos de 3 letras o más de 100 caracteres, `limite` fuera de 1..50, o `nivel` desconocido. - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. - `503`: El catálogo todavía no está cargado en este servidor (`CATALOGO_NO_DISPONIBLE`). Reintentar no lo arregla: mientras tanto envía el código de 8 dígitos en `codigo_producto_sunat` (es opcional para SUNAT) o emite sin él. Campos de la respuesta `200` (`SunatProductSearchResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `catalogo` | string = `productos-sunat` | sí | | | `catalogo_sunat` | string = `25` | sí | | | `descripcion` | string | sí | | | `q` | string | sí | La búsqueda tal como se recibió. | | `total` | integer | sí | Filas devueltas en `datos` (como mucho `limite`). | | `hay_mas` | boolean | sí | true si hay más resultados que `limite`. | | `datos` | array de objetos | sí | | | `datos[].codigo` | string | sí | Código UNSPSC de 8 dígitos. El de nivel `producto` es el que va en `codigo_producto_sunat`. | | `datos[].descripcion` | string | sí | | | `datos[].nivel` | string (`segmento`, `familia`, `clase`, `producto`) | sí | | | `datos[].categoria` | string/null | sí | Ruta en la jerarquía (segmento > familia > clase). | Ejemplo `200` — GET /catalogos/productos-sunat?q=computador&limite=3: ```json { "success": true, "catalogo": "productos-sunat", "catalogo_sunat": "25", "descripcion": "Código de producto SUNAT (UNSPSC v14). Va en \"codigo_producto_sunat\" de cada ítem.", "q": "computador", "total": 3, "hay_mas": true, "datos": [ { "codigo": "43211516", "descripcion": "Minicomputadores", "nivel": "producto", "categoria": "Difusión de Tecnologías de Información y Telecomunicaciones > Equipo informático y accesorios > Computadores" }, { "codigo": "43202222", "descripcion": "Cables de computador", "nivel": "producto", "categoria": "Difusión de Tecnologías de Información y Telecomunicaciones > Componentes para tecnología de la información, difusión o telecomunicaciones > Subconjuntos para dispositivos electrónicos" }, { "codigo": "43211503", "descripcion": "Computadores notebook", "nivel": "producto", "categoria": "Difusión de Tecnologías de Información y Telecomunicaciones > Equipo informático y accesorios > Computadores" } ] } ``` Ejemplo `200` — GET /catalogos/productos-sunat?q=432115&limite=2: ```json { "success": true, "catalogo": "productos-sunat", "catalogo_sunat": "25", "descripcion": "Código de producto SUNAT (UNSPSC v14). Va en \"codigo_producto_sunat\" de cada ítem.", "q": "432115", "total": 2, "hay_mas": true, "datos": [ { "codigo": "43211500", "descripcion": "Computadores", "nivel": "clase", "categoria": "Difusión de Tecnologías de Información y Telecomunicaciones > Equipo informático y accesorios" }, { "codigo": "43211501", "descripcion": "Servidores de computador", "nivel": "producto", "categoria": "Difusión de Tecnologías de Información y Telecomunicaciones > Equipo informático y accesorios > Computadores" } ] } ``` Ejemplo `422`: ```json { "campos": { "q": [ "Escribe al menos 3 letras para buscar por texto." ] }, "success": false, "message": "Datos inválidos: Escribe al menos 3 letras para buscar por texto.", "codigo": "DATOS_INVALIDOS", "reintentable": false } ``` Ejemplo `503`: ```json { "success": false, "message": "El catálogo de códigos de producto SUNAT todavía no está cargado en este servidor. Mientras tanto, envía el código de 8 dígitos en \"codigo_producto_sunat\" (es opcional para SUNAT).", "codigo": "CATALOGO_NO_DISPONIBLE", "reintentable": false } ``` #### GET /catalogos/{nombre} — Filas de un catálogo SUNAT - operationId: `getCatalog` - URL: `GET https://s2.factusmart.pe/api/v1/issuer/catalogos/{nombre}` - Grupo: Herramientas Devuelve las filas de un catálogo. No consume crédito. Se guarda en caché 12 horas. - `{nombre}`: el nombre (`unidades`) o el número SUNAT (`03`); `/catalogos/07` = `/catalogos/afectacion-igv`. - `?q=` filtra por código o descripción (máx. 100 caracteres). - Con `X-RUC` / `?ruc=` lee el catálogo de ese RUC (cada empresa puede tener filas desactivadas). Sin RUC se usa el primer RUC de la cuenta. - Cada fila trae `codigo`, `descripcion`, `activo` y los campos extra del catálogo. **Usa solo las filas con `activo: true`**: el RUC valida contra ellas al emitir. **Parámetros** | Nombre | En | Obligatorio | Descripción | |---|---|---|---| | `nombre` | path | sí | Nombre del catálogo o su número SUNAT (ver `GET /catalogos`). | | `q` | query | no | Filtro por código o descripción (no distingue mayúsculas). Máx. 100 caracteres. | | `ruc` | query | no | RUC emisor (11 dígitos). Obligatorio si la cuenta tiene varios RUCs. También se acepta `emisor_ruc`. | | `X-RUC` | header | no | Alternativa al campo `ruc` (se usa solo si `ruc` y `emisor_ruc` no vienen). | **Respuestas** - `200`: Filas del catálogo. - `401`: API key ausente, inválida o cuenta desactivada. - `402`: El plan de la cuenta venció (`PLAN_VENCIDO`). - `403`: El RUC indicado no pertenece a la cuenta, o el RUC está desactivado. - `404`: El catálogo no existe. Trae la lista en `catalogos_disponibles`. - `422`: `q` demasiado largo (`DATOS_INVALIDOS`), o la cuenta todavía no tiene ningún RUC (`RUC_REQUERIDO`; crea uno con `POST /rucs`). - `429`: Límite de peticiones superado (120/min por API key, 60/min por IP). Espera los segundos indicados en `Retry-After` (o `retry_after`) y reintenta. - `500`: Error interno del servidor (`codigo: ERROR_INTERNO`, reintentable). Los errores técnicos (SQL, PHP) se devuelven con un mensaje genérico, nunca con rutas de archivos ni líneas de código. Campos de la respuesta `200` (`CatalogResponse`): | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `true` | sí | | | `catalogo` | string | sí | | | `catalogo_sunat` | string | sí | | | `descripcion` | string | sí | | | `total` | integer | sí | | | `datos` | array de objetos | sí | | | `datos[].codigo` | string | sí | | | `datos[].descripcion` | string | sí | | | `datos[].activo` | boolean | sí | Usa solo las filas activas. | | `datos[].abreviatura` | string/null | no | Solo en tipos-documento. | | `datos[].simbolo` | string/null | no | Solo en monedas y unidades. | | `datos[].es_exportacion` | boolean | no | Solo en afectacion-igv y tipos-operacion. | | `datos[].es_gratuito` | boolean | no | Solo en afectacion-igv. | | `datos[].porcentaje` | number/string/null | no | Solo en detracciones. | Ejemplo `200` — GET /catalogos/unidades?q=kilo: ```json { "success": true, "catalogo": "unidades", "catalogo_sunat": "03", "descripcion": "Unidades de medida", "total": 2, "datos": [ { "codigo": "KGM", "descripcion": "Kilos", "activo": true, "simbolo": "KG" }, { "codigo": "KWH", "descripcion": "Kilovatio hora", "activo": false, "simbolo": "KWxH" } ] } ``` Ejemplo `200` — GET /catalogos/07 (recortado): ```json { "success": true, "catalogo": "afectacion-igv", "catalogo_sunat": "07", "descripcion": "Tipos de afectación del IGV", "total": 19, "datos": [ { "codigo": "10", "descripcion": "Gravado - Operación Onerosa", "activo": true, "es_exportacion": false, "es_gratuito": false }, { "codigo": "11", "descripcion": "Gravado – Retiro por premio", "activo": true, "es_exportacion": false, "es_gratuito": true } ] } ``` Ejemplo `402`: ```json { "success": false, "message": "El plan de tu cuenta venció. Renuévalo para volver a emitir.", "codigo": "PLAN_VENCIDO", "reintentable": false } ``` Ejemplo `404`: ```json { "catalogos_disponibles": [ "tipos-documento", "monedas", "unidades", "documentos-identidad", "afectacion-igv", "motivos-nota-credito", "motivos-nota-debito", "modalidad-transporte", "motivos-traslado", "tipos-operacion", "detracciones" ], "success": false, "message": "Catálogo \"noexiste\" no existe. Lista disponible en GET /catalogos.", "codigo": "NO_ENCONTRADO", "reintentable": false } ``` Ejemplo `422`: ```json { "success": false, "message": "Tu cuenta todavía no tiene ningún RUC enlazado: los catálogos se leen del sistema de facturación de tu RUC. Crea uno con POST /rucs.", "codigo": "RUC_REQUERIDO", "reintentable": false } ``` ## 15. Webhook: formato del aviso ### Webhook `comprobanteEstado` — Aviso de cambio de estado de un comprobante **FactuSmart llama a TU servidor** (no es un endpoint que tú llames). Se envía cuando un comprobante **emitido por el API** pasa a `05` Aceptado (`comprobante.aceptado`), `07` Observado (`comprobante.observado`), `09` Rechazado (`comprobante.rechazado`) u `11` Anulado (`comprobante.anulado`). El botón **Enviar prueba** del portal manda el evento `prueba` (un solo intento, sin reintentos). Los comprobantes emitidos desde el panel web no generan aviso. **Configuración:** solo desde el portal del integrador, pestaña **Webhooks**: una URL por cuenta (recibes los avisos de todos tus RUCs), debe ser `https://` y pública (no se aceptan `localhost`, IPs privadas ni de metadatos de nube). Al guardarla se genera un secreto `whsec_…`, que puedes regenerar cuando quieras. Para desactivarlo, deja la URL vacía y guarda. **Verificar la firma:** `X-FactuSmart-Firma` es el HMAC-SHA256 en hexadecimal del **cuerpo crudo** (tal cual llega, sin volver a serializarlo) usando tu secreto. Compáralo en tiempo constante; si no coincide, responde `401` e ignora el aviso. Con los SDK: - Node: `FactuSmart.verificarFirmaWebhook(cuerpoCrudo, firma, secreto)` → `boolean` - PHP: `\FactuSmart\Cliente::verificarFirmaWebhook($cuerpoCrudo, $firma, $secreto)` → `bool` - Python: `factusmart.verificar_firma_webhook(cuerpo_crudo, firma, secreto)` → `bool` **Reintentos:** si tu servidor no responde `2xx` en **5 segundos** (error, timeout, conexión rechazada o una redirección `301`/`302`, que no se sigue), se reintenta con espera creciente: **6 intentos en total** — inmediato, +1 min, +5 min, +30 min, +2 h y +6 h. Si fallan todos, el aviso queda como **Error** en el portal (**Webhooks → Últimas entregas**, 50 más recientes, 30 días de historial) y puedes seguir consultando `GET /documents/{external_id}`. El `id` es el mismo en todos los reintentos: guárdalo para no procesar dos veces el mismo aviso. Si el proceso es largo, responde `200` de inmediato y procésalo después. El envío no retrasa la emisión. **Headers que recibes** | Header | Descripción | |---|---| | `X-FactuSmart-Evento` | Tipo de evento (igual que `evento` del cuerpo). | | `X-FactuSmart-Id` | Identificador único del aviso (igual que `id` del cuerpo). No cambia entre reintentos. | | `X-FactuSmart-Intento` | Número de intento, de `1` a `6`. | | `X-FactuSmart-Firma` | HMAC-SHA256 en hexadecimal del cuerpo crudo, con tu secreto `whsec_…`. | | `User-Agent` | string = `FactuSmart-Webhooks/1.0` | **Cuerpo** | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `id` | string (uuid) | sí | Identificador único del aviso. Es el mismo en todos los reintentos; úsalo para no procesar dos veces el mismo aviso. | | `evento` | string (`comprobante.aceptado`, `comprobante.observado`, `comprobante.rechazado`, `comprobante.anulado`, `prueba`) | sí | `comprobante.aceptado` (05), `comprobante.observado` (07), `comprobante.rechazado` (09), `comprobante.anulado` (11) o `prueba` (enviado con el botón "Enviar prueba" del portal). | | `ruc` | string | sí | RUC emisor del comprobante. | | `external_id` | string | sí | El mismo `external_id` que devolvió `POST /documents`. | | `serie_numero` | string | sí | Serie y número, ej. `F001-123`. | | `tipo_documento` | string (`01`, `03`, `07`, `08`) | sí | `01` factura, `03` boleta, `07` nota de crédito, `08` nota de débito. | | `estado` | string (`05`, `07`, `09`, `11`) | sí | Estado actual del comprobante. | | `estado_descripcion` | string/null | sí | Descripción del estado, ej. `Aceptado`. | | `aceptado_por_sunat` | boolean | sí | `true` solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | `prueba` (beta SUNAT, sin validez fiscal) o `produccion` (también si el RUC envía por OSE). | | `sunat` | object | sí | Lo que respondió SUNAT. Ambos campos pueden ser `null`. | | `sunat.codigo` | string/null | sí | | | `sunat.descripcion` | string/null | sí | | | `fecha` | string (date-time) | sí | Momento del cambio de estado (ISO 8601, hora de Lima). No cambia entre reintentos (el último llega ~8,5 h después), así que no descartes avisos por antigüedad; deduplica por `id`. | Ejemplo: ```json { "id": "0fbb616d-ec40-4bbd-a89f-591d9d404630", "evento": "comprobante.aceptado", "ruc": "20777666555", "external_id": "ad4d29e9-7510-4efd-963e-3f8b6fa2facd", "serie_numero": "BA01-35", "tipo_documento": "03", "estado": "05", "estado_descripcion": "Aceptado", "aceptado_por_sunat": true, "entorno": "prueba", "sunat": { "codigo": "0", "descripcion": "La Boleta numero BA01-35, ha sido aceptada" }, "fecha": "2026-10-14T15:29:43-05:00" } ``` ## 16. Esquemas Campos de los objetos que se repiten en varios endpoints. Los nombres anidados usan punto (`objeto.campo`) y `[]` para listas. ### EmitDocumentRequest Comprobante (factura, boleta, nota de crédito o débito). Los campos desconocidos se ignoran. | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `ruc` | string | no | RUC emisor. Obligatorio si la cuenta tiene varios RUCs. Formato `^\d{11}$`. | | `codigo_anexo` | string | no | Código de anexo del local desde el que se emite (ej. `0001`), el de `GET /establecimientos`. Si no lo envías: el local dueño de la `serie_documento` enviada o, sin serie, el local predeterminado (`0000`). Un código que no existe en el RUC → `422 DATOS_INVALIDOS`. Formato `^\d{4}$`. | | `codigo_establecimiento` | string | no | Nombre antiguo de `codigo_anexo` (mismo dato; se aceptan los dos). Si envías ambos, gana este. | | `serie_documento` | string | no | Serie (ej. FA01, BA01, FN01). Opcional si el establecimiento tiene una sola serie para el tipo. | | `numero_documento` | string | no | `#` para correlativo automático. Un número explícito ya usado da `422`. Por defecto `#`. | | `fecha_de_emision` | string (date) | sí | Fecha de emisión (YYYY-MM-DD) **en hora de Perú** (zona `America/Lima`, UTC-5, sin horario de verano). Debe ser la fecha real de hoy en Perú. Ojo: un servidor en UTC que use su fecha local después de las 19:00 de Perú mandaría la fecha de mañana. Es obligatoria: el API no la completa si falta (la emisión se rechaza con `422` y no se cobra). | | `hora_de_emision` | string | sí | Hora de emisión (HH:MM:SS, 24 h) en hora de Perú (`America/Lima`). Obligatoria; el API no la completa. Formato `^\d{2}:\d{2}:\d{2}$`. | | `fecha_de_vencimiento` | string (date) | no | Solo facturas/boletas. Si no se envía, se usa la fecha de emisión. | | `codigo_tipo_documento` | string (`01`, `03`, `07`, `08`) | sí | 01 Factura, 03 Boleta de venta, 07 Nota de crédito, 08 Nota de débito. | | `codigo_tipo_operacion` | string | no | Catálogo 51 SUNAT (0101 venta interna, 0200 exportación, 1001 detracción, 1004 detracción transporte de carga...). Obligatorio en la práctica para 01/03; no aplica a notas. | | `codigo_tipo_moneda` | string (`PEN`, `USD`) | sí | Código de moneda (catálogo del RUC; se crea con PEN y USD). | | `factor_tipo_de_cambio` | number | no | Tipo de cambio de venta. Por defecto `1`. | | `numero_orden_de_compra` | string | no | Referencia de orden de compra (aparece en PDF y XML). | | `numero_de_placa` | string | no | Placa vehicular (ventas de combustible, etc.). | | `informacion_adicional` | string | no | Texto libre que aparece en el PDF y viaja en el XML como nota adicional. | | `codigo_condicion_de_pago` | string (`01`, `02`) | no | `01` Contado, `02` Crédito (requiere `cuotas`). Un texto como "Credito" se ignora y se emite al contado. Por defecto `01`. | | `cuotas` | array de objetos | no | Cuotas del crédito. La suma debe coincidir con `total_venta`. | | `cuotas[].fecha` | string (date) | sí | Fecha de vencimiento de la cuota. | | `cuotas[].codigo_tipo_moneda` | string (`PEN`, `USD`) | sí | Código de moneda (catálogo del RUC; se crea con PEN y USD). | | `cuotas[].monto` | number | sí | | | `cuotas[].codigo_metodo_de_pago` | string | no | | | `pagos` | array de objetos | no | Pagos recibidos (solo 01/03). | | `pagos[].codigo_metodo_pago` | string | sí | Código de método de pago del RUC. | | `pagos[].codigo_destino_pago` | string | no | | | `pagos[].referencia` | string | no | | | `pagos[].monto` | number | no | Por defecto `0`. | | `pagos[].pago_recibido` | number | no | | | `datos_del_cliente_o_receptor` | objeto `Cliente` (ver Esquemas) | sí | | | `totales` | objeto `Totales` (ver Esquemas) | sí | Importes del comprobante. Si un total viene en 0 o vacío, el sistema lo recalcula desde los ítems. | | `items` | array de `ItemComprobante` (ver Esquemas) | sí | 1 a ∞ elementos. | | `detraccion` | object | no | Datos de detracción. La leyenda 2006 se agrega automáticamente. | | `detraccion.codigo_tipo_detraccion` | string | sí | Código de bien/servicio (catálogo 54), ej. `022`. | | `detraccion.porcentaje` | number | sí | | | `detraccion.monto` | number | sí | Porcentaje aplicado sobre el total con IGV, a 2 decimales. | | `detraccion.codigo_metodo_pago` | string | sí | Catálogo 59, ej. `001` depósito en cuenta. | | `detraccion.cuenta_bancaria` | string | sí | Cuenta de detracciones del Banco de la Nación. | | `detraccion.ubigeo_origen` | string | no | Obligatorio con operación 1004. Formato `^\d{6}$`. | | `detraccion.ubigeo_destino` | string | no | Obligatorio con operación 1004. Formato `^\d{6}$`. | | `detraccion.direccion_origen` | string | no | | | `detraccion.direccion_destino` | string | no | | | `detraccion.detalle_viaje` | string | no | | | `detraccion.valor_referencial_carga_util` | number | no | | | `detraccion.valor_referencial_servicio_transporte` | number | no | | | `detraccion.valor_referencia_carga_efectiva` | number | no | | | `percepcion` | object | no | Percepción o retención. | | `percepcion.codigo` | string | sí | | | `percepcion.porcentaje` | number | sí | | | `percepcion.monto` | number | sí | | | `percepcion.base` | number | sí | | | `retencion` | object | no | Percepción o retención. | | `retencion.codigo` | string | sí | | | `retencion.porcentaje` | number | sí | | | `retencion.monto` | number | sí | | | `retencion.base` | number | sí | | | `cargos` | array de objetos | no | | | `cargos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `cargos[].descripcion` | string | sí | | | `cargos[].factor` | number | sí | | | `cargos[].monto` | number | sí | | | `cargos[].base` | number | sí | | | `descuentos` | array de objetos | no | | | `descuentos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `descuentos[].descripcion` | string | sí | | | `descuentos[].factor` | number | sí | | | `descuentos[].monto` | number | sí | | | `descuentos[].base` | number | sí | | | `anticipos` | array de objetos | no | | | `anticipos[].numero` | string | sí | | | `anticipos[].codigo_tipo_documento` | string | sí | | | `anticipos[].monto` | number | sí | | | `anticipos[].total` | number | sí | | | `guias` | array de objetos | no | Guías de remisión relacionadas. | | `guias[].numero` | string | sí | | | `guias[].codigo_tipo_documento` | string | sí | | | `relacionados` | array de objetos | no | Otros documentos relacionados. | | `relacionados[].numero` | string | sí | | | `relacionados[].codigo_tipo_documento` | string | sí | | | `relacionados[].monto` | number | sí | | | `leyendas` | array de objetos | no | | | `leyendas[].codigo` | string | sí | Catálogo 52 SUNAT. | | `leyendas[].valor` | string | sí | | | `acciones` | object | no | | | `acciones.enviar_email` | boolean | no | Enviar el comprobante por correo al cliente. | | `acciones.enviar_xml_firmado` | boolean | no | | | `acciones.formato_pdf` | string | no | Formato del PDF (ej. a4, ticket). | | `codigo_tipo_nota` | string | no | Obligatorio en 07/08. Crédito (cat. 09): 01 Anulación de la operación, 07 Devolución por ítem, etc.; débito (cat. 10): 01 Intereses por mora, 02 Aumento en el valor, 03 Penalidades. | | `motivo_o_sustento_de_nota` | string | no | Solo 07/08. Motivo de la nota (si se omite queda vacío). | | `documento_afectado` | object | no | Obligatorio en 07/08 (sin este bloque la emisión de la nota falla con 422). | | `documento_afectado.external_id` | string (uuid) | no | | | `documento_afectado.serie_documento` | string | no | | | `documento_afectado.numero_documento` | string | no | | | `documento_afectado.codigo_tipo_documento` | string (`01`, `03`) | no | | ### Cliente | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `codigo_tipo_documento_identidad` | string (`0`, `1`, `4`, `6`, `7`, `A`, `B`, `C`, `D`, `E`) | sí | Catálogo 06 SUNAT: 0 Doc. trib. no domiciliado sin RUC, 1 DNI, 4 Carnet de extranjería, 6 RUC, 7 Pasaporte, A-E otros. | | `numero_documento` | string | sí | | | `apellidos_y_nombres_o_razon_social` | string | no | Opcional con RUC (6) o DNI (1), se autocompleta en SUNAT/RENIEC; obligatorio con otros tipos. | | `nombre_comercial` | string | no | | | `codigo_pais` | string | no | Por defecto `PE`. | | `ubigeo` | string | no | Ubigeo de 6 dígitos (se valida contra el catálogo cuando el cliente tiene RUC). Formato `^\d{6}$`. | | `direccion` | string | no | | | `correo_electronico` | string (email) | no | | | `telefono` | string | no | | | `codigo_tipo_direccion` | string | no | | ### ItemComprobante | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `codigo_interno` | string | no | Código/SKU de tu sistema. Se registra en el catálogo del RUC y se reusa. Sin él, todas las ventas usan un producto genérico compartido. | | `descripcion` | string | sí | Descripción que se imprime en el PDF/XML. | | `nombre` | string | no | | | `nombre_secundario` | string | no | | | `codigo_tipo_item` | string | no | Por defecto `01`. | | `codigo_producto_sunat` | string | no | Código de producto SUNAT (catálogo 25, UNSPSC, 8 dígitos). Búscalo con [`GET /catalogos/productos-sunat`](#operation/searchSunatProductCodes). Opcional: SUNAT no lo exige en factura ni boleta. | | `unidad_de_medida` | string | sí | Catálogo 03: `NIU` para bienes/productos, `ZZ` para servicios (`KGM`, `LTR`... si vendes por peso o volumen). Lista completa: `GET /catalogos/03`. | | `cantidad` | number | sí | | | `valor_unitario` | number | no | Precio unitario sin IGV. | | `codigo_tipo_precio` | string | no | 01 precio unitario, 02 valor referencial (gratuitas). | | `precio_unitario` | number | no | Precio unitario con IGV. | | `codigo_tipo_afectacion_igv` | string (`10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `20`, `21`, `30`, `31`, `32`, `33`, `34`, `35`, `36`, `37`, `40`) | no | Catálogo 07 SUNAT. 10 Gravado onerosa; 11-16 Gravado retiro/bonificación (gratuitas); 17 IVAP; 20 Exonerado onerosa; 21 Exonerado gratuita; 30 Inafecto onerosa; 31-37 Inafecto retiro/gratuita; 40 Exportación. | | `total_base_igv` | number | no | | | `porcentaje_igv` | number | no | | | `total_igv` | number | no | | | `codigo_tipo_sistema_isc` | string | no | | | `total_base_isc` | number | no | | | `porcentaje_isc` | number | no | | | `total_isc` | number | no | | | `total_base_otros_impuestos` | number | no | | | `porcentaje_otros_impuestos` | number | no | | | `total_otros_impuestos` | number | no | | | `total_impuestos_bolsa_plastica` | number | no | | | `total_impuestos` | number | no | | | `total_valor_item` | number | no | Valor de venta del ítem sin impuestos. | | `total_cargos` | number | no | | | `total_descuentos` | number | no | | | `total_item` | number | no | Total del ítem con impuestos. | | `informacion_adicional` | string | no | | | `actualizar_descripcion` | boolean | no | Si el `codigo_interno` ya existe, actualiza la descripción del producto en el catálogo. Por defecto `true`. | | `nombre_producto_pdf` | string | no | Texto alternativo para el PDF (por defecto, `descripcion`). | | `nombre_producto_xml` | string | no | Texto alternativo para el XML (por defecto, `descripcion`). | | `datos_adicionales` | array de objetos | no | | | `datos_adicionales[].codigo` | string | sí | | | `datos_adicionales[].descripcion` | string | sí | | | `datos_adicionales[].valor` | string | no | | | `datos_adicionales[].fecha_inicio` | string (date) | no | | | `datos_adicionales[].fecha_fin` | string (date) | no | | | `datos_adicionales[].duracion` | string | no | | | `descuentos` | array de objetos | no | | | `descuentos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `descuentos[].descripcion` | string | sí | | | `descuentos[].factor` | number | sí | | | `descuentos[].monto` | number | sí | | | `descuentos[].base` | number | sí | | | `cargos` | array de objetos | no | | | `cargos[].codigo` | string | sí | Catálogo 53 SUNAT. | | `cargos[].descripcion` | string | sí | | | `cargos[].factor` | number | sí | | | `cargos[].monto` | number | sí | | | `cargos[].base` | number | sí | | ### Totales Importes del comprobante. Si un total viene en 0 o vacío, el sistema lo recalcula desde los ítems. | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `total_operaciones_gravadas` | number | no | | | `total_operaciones_exoneradas` | number | no | | | `total_operaciones_inafectas` | number | no | | | `total_operaciones_gratuitas` | number | no | | | `total_exportacion` | number | no | | | `total_igv` | number | no | | | `total_igv_operaciones_gratuitas` | number | no | | | `total_base_isc` | number | no | | | `total_isc` | number | no | | | `total_base_otros_impuestos` | number | no | | | `total_otros_impuestos` | number | no | | | `total_impuestos_bolsa_plastica` | number | no | | | `total_impuestos` | number | no | | | `total_valor` | number | no | | | `total_anticipos` | number | no | | | `total_descuentos` | number | no | | | `total_cargos` | number | no | | | `subtotal_venta` | number | no | Si no se envía, se usa `total_venta`. | | `total_venta` | number | sí | Importe total con impuestos. | | `total_pendiente_pago` | number | no | | ### CalculateItem Envía `valor_unitario` (sin IGV) **o** `precio_unitario` (con IGV), no los dos. | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `descripcion` | string | sí | Máx. 500 caracteres. | | `cantidad` | number | sí | | | `valor_unitario` | number | no | Precio unitario SIN IGV. | | `precio_unitario` | number | no | Precio unitario CON IGV. | | `afectacion` | string (`10`, `20`, `30`, `40`) | sí | `10` gravado, `20` exonerado, `30` inafecto, `40` exportación. Para otras afectaciones arma el ítem a mano. | | `unidad_de_medida` | string | no | Código SUNAT del catálogo 03. `NIU` para bienes/productos, `ZZ` para servicios (`KGM`, `LTR`… si vendes por peso o volumen). Lista completa en `GET /catalogos/03` (o `/catalogos/unidades`). Por defecto `NIU`. Formato `^[A-Za-z0-9]{1,5}$`. | | `codigo_interno` | string | no | Se copia tal cual al resultado. | ### CobroOperacion Campos de cobro de las operaciones que consumen crédito. `modo_demo` y `pruebas_restantes_hoy` solo aparecen si la operación fue en un RUC en modo demo (sin costo). | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `creditos_restantes` | integer | sí | Tu saldo real de créditos después de la operación (en demo no cambia). | | `credito_cobrado` | boolean | sí | `true` si esta operación descontó 1 crédito. En demo siempre `false`; en `/guias` también `false` si la guía no se pudo enviar (se devolvió). | | `modo_demo` | boolean = `true` | no | Solo en RUCs demo. | | `pruebas_restantes_hoy` | integer | no | Solo en RUCs demo. Operaciones demo que le quedan hoy a la cuenta (tope 200 por día, se reinicia a medianoche hora de Perú). | ### SunatFeedback Diagnóstico SUNAT que se agrega a las respuestas de comprobantes. | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `sunat` | object | no | Código y mensaje de SUNAT guardados para el comprobante (solo si existen). | | `sunat.codigo` | string/null | no | | | `sunat.descripcion` | string/null | no | | | `aceptado_por_sunat` | boolean | sí | true solo si `estado` es `05`. | | `entorno` | string (`prueba`, `produccion`) | sí | Entorno en el que se emitió ESTE comprobante. Un RUC que envía por OSE siempre es `produccion` (el OSE emite real). | | `tiene_validez_fiscal` | boolean | sí | | | `accion_requerida` | string | no | Solo en estados `01` y `09`; qué hacer a continuación. | ### Error Forma de TODA respuesta de error del API. `success` y `message` se mantienen por compatibilidad; decide tu lógica con `codigo` y `reintentable`. Algunos endpoints agregan campos propios. | Campo | Tipo | Obligatorio | Descripción | |---|---|---|---| | `success` | boolean = `false` | sí | | | `message` | string | sí | Mensaje legible en español. Puede cambiar de redacción: no lo compares. | | `codigo` | string (`NO_AUTENTICADO`, `PLAN_VENCIDO`, `SIN_CREDITOS`, `RUC_NO_AUTORIZADO`, `RUC_DESACTIVADO`, `LLAVE_DE_PRUEBA_EN_PRODUCCION`, `SIN_PERMISO`, `NO_ENCONTRADO`, `RUTA_NO_ENCONTRADA`, `METODO_NO_PERMITIDO`, `EMISION_EN_CURSO`, `CONFLICTO`, `DATOS_INVALIDOS`, `RUC_REQUERIDO`, `RUC_SIN_TENANT`, `CERTIFICADO_NO_CONFIGURADO`, `ERROR_EMISION`, `RECHAZADO_SUNAT`, `REENVIO_FALLIDO`, `SUNAT_NO_DISPONIBLE`, `LIMITE_PETICIONES`, `LIMITE_PRUEBAS_DIARIO`, `ANULACION_MIXTA`, `ERROR_INTERNO`, `PDF_NO_GENERADO`, `LIMITE_ESTABLECIMIENTOS`, `CATALOGO_NO_DISPONIBLE`) | sí | Código estable del error (ver la tabla "Errores" de la descripción general). La lista puede crecer en el futuro: trata un código desconocido según `reintentable`. | | `reintentable` | boolean | sí | true si repetir la misma petición más tarde puede funcionar (429 LIMITE_PETICIONES, 409 EMISION_EN_CURSO, 5xx, SUNAT_NO_DISPONIBLE). `LIMITE_PRUEBAS_DIARIO` es 429 pero NO es reintentable. | ### Catálogos fijos (enums) - `EstadoCodigo`: Estado del comprobante: 01 Registrado, 03 Enviado, 05 Aceptado, 07 Observado, 09 Rechazado, 11 Anulado, 13 Por anular. Valores: `01`, `03`, `05`, `07`, `09`, `11`, `13`. - `TipoDocumentoComprobante`: 01 Factura, 03 Boleta de venta, 07 Nota de crédito, 08 Nota de débito. Valores: `01`, `03`, `07`, `08`. - `TipoDocumentoIdentidad`: Catálogo 06 SUNAT: 0 Doc. trib. no domiciliado sin RUC, 1 DNI, 4 Carnet de extranjería, 6 RUC, 7 Pasaporte, A-E otros. Valores: `0`, `1`, `4`, `6`, `7`, `A`, `B`, `C`, `D`, `E`. - `AfectacionIgv`: Catálogo 07 SUNAT. 10 Gravado onerosa; 11-16 Gravado retiro/bonificación (gratuitas); 17 IVAP; 20 Exonerado onerosa; 21 Exonerado gratuita; 30 Inafecto onerosa; 31-37 Inafecto retiro/gratuita; 40 Exportación. Valores: `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `20`, `21`, `30`, `31`, `32`, `33`, `34`, `35`, `36`, `37`, `40`. - `Moneda`: Código de moneda (catálogo del RUC; se crea con PEN y USD). Valores: `PEN`, `USD`. - `CodigoError`: Código estable del error (ver la tabla "Errores" de la descripción general). La lista puede crecer en el futuro: trata un código desconocido según `reintentable`. Valores: `NO_AUTENTICADO`, `PLAN_VENCIDO`, `SIN_CREDITOS`, `RUC_NO_AUTORIZADO`, `RUC_DESACTIVADO`, `LLAVE_DE_PRUEBA_EN_PRODUCCION`, `SIN_PERMISO`, `NO_ENCONTRADO`, `RUTA_NO_ENCONTRADA`, `METODO_NO_PERMITIDO`, `EMISION_EN_CURSO`, `CONFLICTO`, `DATOS_INVALIDOS`, `RUC_REQUERIDO`, `RUC_SIN_TENANT`, `CERTIFICADO_NO_CONFIGURADO`, `ERROR_EMISION`, `RECHAZADO_SUNAT`, `REENVIO_FALLIDO`, `SUNAT_NO_DISPONIBLE`, `LIMITE_PETICIONES`, `LIMITE_PRUEBAS_DIARIO`, `ANULACION_MIXTA`, `ERROR_INTERNO`, `PDF_NO_GENERADO`, `LIMITE_ESTABLECIMIENTOS`, `CATALOGO_NO_DISPONIBLE`. Catálogos SUNAT completos y en vivo: `GET /catalogos/{nombre}`. ## 17. Checklist para el agente que integra 1. Pide al usuario su URL base (portal → Configuración) y su API key; usa variables de entorno, nunca la escribas en el código. 2. `GET /ping` para validar la key y ver `tipo_llave`; `GET /account` o `GET /rucs` para ver sus RUCs y si están en modo prueba o producción. 3. Desarrolla contra un RUC en modo prueba (no gasta créditos; tope 200/día). 4. Arma cada venta con `POST /calcular` (o `calcularComprobante` del SDK) y emite con `POST /documents` + `Idempotency-Key`. 5. Guarda `external_id`, `serie_numero`, `estado` y `aceptado_por_sunat`. Nunca trates un 200 como aceptado. 6. Maneja errores por `codigo`/`reintentable`; reintenta solo si `reintentable` es true. 7. Configura el webhook (portal) y verifica la firma sobre el cuerpo crudo; deduplica por `id`. 8. Una vez al día: `GET /documents?estado=01` y `/reenviar` o `/consultar-sunat`. 9. Anula facturas y boletas en peticiones separadas (`POST /anulaciones`) y consulta el ticket. operationId disponibles: `apiRoot` (GET /), `ping` (GET /ping), `getAccount` (GET /account), `listSeries` (GET /series), `listRucs` (GET /rucs), `createRuc` (POST /rucs), `setRucActivation` (PATCH /rucs/{ruc}/activation), `listEstablishments` (GET /establecimientos), `createEstablishment` (POST /establecimientos), `listDocuments` (GET /documents), `emitDocument` (POST /documents), `getDocument` (GET /documents/{external_id}), `downloadDocumentFile` (GET /documents/{external_id}/{type}), `resendDocument` (POST /documents/{external_id}/reenviar), `querySunatStatus` (POST /documents/{external_id}/consultar-sunat), `getDocumentHistory` (GET /documents/{external_id}/historial), `voidDocument` (POST /anulaciones), `getVoidStatus` (GET /anulaciones/{ticket}/status), `emitDispatchGuide` (POST /guias), `getDispatchGuideStatus` (GET /guias/{external_id}/status), `calculateDocument` (POST /calcular), `listCatalogs` (GET /catalogos), `searchSunatProductCodes` (GET /catalogos/productos-sunat), `getCatalog` (GET /catalogos/{nombre}).