Documentación de la API
Emite comprobantes electrónicos válidos ante SUNAT desde tu sistema. API REST, JSON, autenticación por API key.
¿Primera vez? Empieza por el Inicio rápido. ¿Buscas un endpoint exacto? Mira la Referencia del API.
¿Integras con un agente de IA? Dale este enlace: https://factusmart.pe/api-facturacion-electronica/llms-full.txt
Empieza en 3 pasos
- 1. Copia tu API key y tu URL base desde tu panel (pestaña Configuración).
- 2. Prueba que funciona:
GET {tu_url_base}/api/v1/issuer/pingcon el headerX-API-Key. - 3. Emite tu primera factura de prueba (ejemplo mínimo abajo, en Emitir comprobante). Sale en modo prueba, sin validez fiscal y sin consumir créditos (hasta 200 pruebas al día).
Impórtala en Postman, pon tu api_key y url_base en las variables, y ya tienes todos los endpoints listos para probar.
Introducción
La API te permite emitir boletas, facturas, notas de crédito/débito y anulaciones a SUNAT, y obtener el PDF, el XML firmado y el CDR. Tú envías la venta en JSON; nosotros armamos el XML UBL 2.1, lo firmamos y lo enviamos a SUNAT.
URL base:
https://TU_URL_BASE/api/v1/issuerReemplaza TU_URL_BASE por la URL que aparece en tu panel, junto a tu API key (pestaña Configuración). No es la misma para todas las cuentas.
Modelo de cuenta: tu cuenta tiene un saldo de créditos compartido (el cupo de tu plan más los créditos extra) y puede emitir para uno o varios RUCs. Cada comprobante emitido en un RUC en producción descuenta 1 crédito de tu cuenta. Las pruebas en un RUC en modo prueba no consumen créditos (ver Créditos).
Autenticación
Toda petición lleva tu API key en el header:
X-API-Key: TU_API_KEYSi tu cuenta tiene varios RUCs, indica el RUC emisor en cada petición con el campo ruc (en el body o como query ?ruc=20100070970). Si tu cuenta tiene un solo RUC, se usa automáticamente.
Llave de prueba y llave de producción
Cada cuenta puede tener dos llaves. Las dos se envían igual (header X-API-Key o Authorization: Bearer) y usan la misma URL base.
| Llave de producción | Llave de prueba | |
|---|---|---|
| Formato | 64 letras y números, sin prefijo (la de siempre, también las antiguas) | 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 prueba |
| Consultar, listar, descargar, historial, series, cuenta, ping | Todos tus RUCs | Todos tus RUCs |
| Crear y listar RUCs | Sí | Sí |
| Activar / desactivar un RUC | Sí | Solo si el RUC está en modo prueba |
| Créditos | RUC en producción: 1 por operación. RUC en modo prueba: gratis (hasta 200 pruebas al día) | Gratis: solo opera en RUCs en modo prueba (hasta 200 pruebas al 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, no cambias nada: esa es tu llave de producción. Úsala en desarrollo y staging para que tu sistema de pruebas nunca pueda emitir por error un comprobante real: si apunta a un RUC que ya está en producción, la API responde 403 con codigo: LLAVE_DE_PRUEBA_EN_PRODUCCION en vez de mandarlo a SUNAT. El control es inmediato: si el RUC pasa a producción, la llave de prueba deja de emitir en él al instante.
GET /ping te dice qué llave estás usando en tipo_llave (prueba o produccion). Las llaves de producción antiguas (las que tenías antes de que existiera la llave de prueba) no tienen prefijo y siguen siendo válidas: /ping las reporta como produccion. El campo entorno de cada comprobante no depende de la llave: dice en qué entorno de SUNAT salió ese comprobante.
Lo que decide si una operación cobra crédito no es la llave, es el modo del RUC: en un RUC en modo prueba no se cobra con ninguna de las dos llaves; en un RUC en producción se cobra (y ahí solo funciona la llave de producción).
Cada llave tiene su propio botón Regenerar en el portal: regenerar una no afecta a la otra, y la anterior deja de funcionar al momento.
Créditos
- Todo prepago: eliges un plan mensual o anual con un cupo mensual de comprobantes (también en los planes anuales). El cupo no es acumulable: lo que no usas en el mes se pierde. Si se te acaba, subes de plan o pides créditos extra para tu plan. Sin cobro automático, sin tarjeta y sin facturación a fin de mes. Precios sin IGV.
- Cuesta 1 crédito (en un RUC en producción): cada boleta, factura, nota de crédito, nota de débito, cada guía de remisión (al enviarse) y cada anulación (al enviarse).
- El modo prueba es gratis: en un RUC en modo prueba (SUNAT beta, sin validez fiscal), emitir, enviar guías y anular no cobra crédito, con cualquiera de las dos llaves. Tope: 200 operaciones de prueba por cuenta y por día (detalle abajo).
- Es gratis: consultar el estado, descargar PDF/XML/CDR, ver tu saldo, /calcular y /catalogos (esto nunca consume crédito).
- Al registrarte recibes 50 créditos de regalo, por única vez, para empezar a emitir en producción (con validez fiscal). Probar en modo prueba no los gasta. Al contratar tu primer plan, los que te queden se reemplazan por el cupo del plan (no se suman).
- Cuándo NO se cobra: si la emisión falla antes de crearse el comprobante — datos inválidos (
422), sin saldo (402) u otro error que impide generarlo. En ese caso el crédito se devuelve automáticamente. - Cuándo SÍ se cobra aunque SUNAT no lo acepte (RUC en producción): si el comprobante se llegó a crear (tiene serie-número y
external_id) pero quedó pendiente (01, p. ej. SUNAT caída) o SUNAT lo rechazó (09), el crédito ya se consumió. Reenviarlo con /reenviar no vuelve a cobrar. - Compartidos por todos los RUCs de tu cuenta. Al emitir se usa primero el cupo del plan y después los créditos extra.
- Créditos extra y de regalo: vencen al año de acreditarse, y te avisamos por correo cuando su saldo baja al 20% y al 10%. El cupo del plan no lleva estos avisos: se renueva cada mes.
- Si te quedas sin créditos, la API responde
402y no emite (subes de plan o pides créditos extra).
Modo prueba: gratis, con tope diario
- Lo que decide si se cobra es el modo del RUC, no la llave: en un RUC en modo prueba no se cobra ni con la llave de prueba (
fs_test_…) ni con la de producción. - Tope: 200 operaciones de prueba por cuenta y por día, sumando todas tus empresas (emisiones + guías + anulaciones). Se reinicia a la medianoche hora de Perú. Una operación que falla con
422no cuenta. - Al pasarte, la API responde
429concodigo: LIMITE_PRUEBAS_DIARIOyreintentable: false: no lo reintentes, espera al día siguiente. Traepruebas_restantes_hoy: 0,limite_pruebas_diario: 200ycreditos_restantes. - En producción no hay ese tope.
Las respuestas de POST /documents, POST /guias y POST /anulaciones te dicen si se cobró:
credito_cobrado(siempre):truesi esa operación descontó 1 crédito; en modo prueba,false.modo_demo: trueypruebas_restantes_hoy(número): solo vienen cuando el RUC está en modo prueba.creditos_restantes: tu saldo real. En modo prueba no baja.
Pasar a producción
Cada RUC nace en modo prueba (SUNAT beta, sin validez fiscal). Puedes integrar y probar de una vez, sin certificado ni clave SOL. Cuando estés listo para emitir comprobantes reales, activa producción en ese RUC.
Paso 1 — En el portal SUNAT del RUC de tu empresa
Con la Clave SOL de la empresa, dentro del portal de SUNAT:
- Crea un usuario secundario con permisos de facturación electrónica (no uses la Clave SOL principal). Ver cómo ↗
- Descarga tu Certificado Digital Tributario (CDT) desde SUNAT. Ver cómo ↗
Paso 2 — En el panel del RUC (FactuSmart)
- Entra al panel del RUC. Es una dirección propia por cada RUC:
https://TU_RUC.s2.factusmart.pe(la misma URL base de tu API, con tu RUC adelante). Ingresa con el correo y la contraseña que pusiste al dar de alta el RUC. - Ve a Configuración → Mi Negocio → "Credenciales y certificados".
- Sube tu certificado digital (CDT). En la tarjeta de certificado, escribe la contraseña de tu archivo
.pfxy súbelo. - Cambia el entorno a "Producción". En la tarjeta "Entorno del sistema", cambia SOAP Tipo de Demo a Producción. Al hacerlo aparecen los campos de la clave SOL.
- Ingresa tu Usuario Secundario SOL. En SOAP Usuario va
RUC + usuario secundario(ej.20100070970MIUSUARIO) y en SOAP Password la clave de ese usuario secundario. Guarda.
Importante
- Antes de activar producción, borra los comprobantes de prueba que hayas emitido en ese RUC (el sistema te lo pide).
- No cambia nada en tu integración: la misma API key y los mismos endpoints siguen funcionando. Solo que ahora ese RUC emite comprobantes con validez fiscal.
- Si usas la llave de prueba (
fs_test_…), desde ese momento ya no puede emitir en ese RUC (responde403 LLAVE_DE_PRUEBA_EN_PRODUCCION). Para emitir real usa tu llave de producción en tu servidor en vivo.
¿No puedes descargar el Certificado Digital (CDT) de SUNAT?
Si perdiste tu certificado o ya usaste todos los certificados gratuitos que SUNAT otorga, necesitas un servicio PSE que reemplace el firmado del comprobante. Recomendamos smartpse.pe. Se configura en el panel del RUC (Empresa → Credenciales y certificados → PSE).
¿Tu RUC es PRICO (Principal Contribuyente)?
Los RUC catalogados como PRICO en SUNAT no pueden enviar los comprobantes directo a SUNAT: requieren una OSE (Operador de Servicios Electrónicos) para la validación. Contrata un servicio de validación OSE — se integra de forma nativa en nuestra API desde la configuración de la empresa. La OSE también sirve para reemplazar la validación de SUNAT cuando sus servidores tienen intermitencias y el comprobante puede demorar en aceptarse.
Crear y gestionar RUCs por API
Si tu integración da de alta clientes propios (marca blanca, multi-empresa), puedes crear y administrar sus RUCs sin pasar por el portal — todo con tu misma X-API-Key. Los créditos de la cuenta se comparten entre todos los RUCs que crees.
Crear un RUC
POST /api/v1/issuer/rucs
X-API-Key: tu_api_key
{
"ruc": "20609999994",
"empresa": "Mi Cliente SAC",
"correo": "[email protected]",
"clave": "una-clave-de-al-menos-6-caracteres"
}El RUC nace en modo prueba (igual que uno creado desde el portal) — sigue Pasar a producción cuando esté listo para emitir real. Si reintentas con el mismo RUC (ej. tras un timeout de red), la API detecta que ya es tuyo y te devuelve sus datos en vez de duplicarlo o dar error — es seguro reintentar.
Listar tus RUCs
GET /api/v1/issuer/rucs
X-API-Key: tu_api_key
→ { "success": true, "rucs": [
{ "ruc": "20609999994", "nombre": "Mi Cliente SAC", "entorno": "demo", "activo": true, ... }
] }Activar / desactivar un RUC
PATCH /api/v1/issuer/rucs/20609999994/activation
X-API-Key: tu_api_key
{ "activo": false }Un RUC desactivado deja de poder emitir, consultar o descargar nada por la API (ni desde el panel del RUC) hasta que lo reactives con el mismo endpoint enviando { "activo": true }. No borra ningún dato — es reversible en cualquier momento.
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). Es un freno de seguridad (evita que una API key filtrada cree empresas sin control), no un límite comercial.
Al llegar al tope, POST /rucs responde 422 con "codigo": "DATOS_INVALIDOS" y el mensaje "Alcanzaste el máximo de N RUCs. Amplíalo tú mismo desde tu portal…".
Para ampliarlo entra a tu portal → Empresas y pulsa "Ampliar límite (+50)". Sube de 50 en 50 y el botón aparece cuando te faltan 5 RUC o menos para llegar al tope.
No hay endpoint para eliminar un RUC
Borrar un tenant es una operación destructiva e irreversible (pierdes el historial fiscal). Si necesitas dar de baja un RUC definitivamente, contáctanos — el resto de la gestión (crear, listar, activar, desactivar) es 100% self-service.
Emitir comprobante
Lo más fácil: arma items y totales con POST /calcular (gratis) y cópialos tal cual en este cuerpo. Así no calculas IGV a mano.
Con "numero_documento": "#" el correlativo lo asigna FactuSmart. Si tu propio sistema lleva la numeración y quieres mandar tu serie y número, también se puede — ver Numeración. Cambia codigo_tipo_documento según el tipo (01 factura, 03 boleta, 07/08 notas).
Ejemplo — factura (cURL):
curl -X POST https://TU_URL_BASE/api/v1/issuer/documents \
-H "X-API-Key: TU_API_KEY" \
-H "Idempotency-Key: venta-000123" \
-H "Content-Type: application/json" \
-d '{
"ruc": "20100070970",
"numero_documento": "#",
"fecha_de_emision": "2026-06-17",
"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.00,
"total_igv": 18.00,
"total_impuestos": 18.00,
"total_valor": 100.00,
"total_venta": 118.00
},
"items": [
{
"codigo_interno": "PROD-001",
"descripcion": "Servicio de consultoría",
"unidad_de_medida": "NIU",
"cantidad": 1,
"valor_unitario": 100.00,
"codigo_tipo_precio": "01",
"precio_unitario": 118.00,
"codigo_tipo_afectacion_igv": "10",
"total_base_igv": 100.00,
"porcentaje_igv": 18,
"total_igv": 18.00,
"total_impuestos": 18.00,
"total_valor_item": 100.00,
"total_item": 118.00
}
]
}'Respuesta:
{
"success": true,
"estado": "05",
"estado_descripcion": "Aceptado",
"serie_numero": "FA01-1234",
"external_id": "9822f52c-108e-47ff-...",
"aceptado_por_sunat": true,
"sunat": { "codigo": "0", "descripcion": "La Factura FA01-1234 ha sido aceptada" },
"entorno": "produccion",
"tiene_validez_fiscal": true,
"creditos_restantes": 4830,
"credito_cobrado": true
}credito_cobrado dice si esta emisión descontó 1 crédito. Si el RUC está en modo prueba, viene false y además llegan "modo_demo": true y pruebas_restantes_hoy (cuántas pruebas gratis te quedan hoy, de 200). creditos_restantes es tu saldo real y en modo prueba no baja. Ver Pruebas en modo prueba.
🔴 Mira entorno antes de hacer pruebas
Cada respuesta te dice si ese comprobante salió en "prueba" (SUNAT beta, sin validez) o en "produccion" (SUNAT real, con validez fiscal). El campo tiene_validez_fiscal dice lo mismo como booleano, para que tu código lo revise sin interpretar texto.
Un comprobante emitido en producción es real aunque sea de S/ 0.10 y aunque lo hayas hecho «para probar»: entra a tu registro de ventas y solo se puede quitar anulándolo, con plazos que vencen. Si vas a probar, verifica que el RUC esté en modo prueba antes de emitir.
⚠️ Revisa aceptado_por_sunat, no solo el HTTP 200
Un 200 significa que generamos tu comprobante — no que SUNAT lo aceptó. SUNAT puede estar caída o rechazarlo, y entonces recibirás estado: "01" con aceptado_por_sunat: false y el motivo exacto en sunat.descripcion. Qué hacer en ese caso ↓
Para boleta usa "codigo_tipo_documento": "03". Para nota de crédito/débito usa "07"/"08" e incluye documento_afectado y codigo_tipo_nota.
Recomendado: manda tu propio código de producto en codigo_interno
Usa el ID/SKU del producto de tu propio sistema. La primera vez lo registramos en el catálogo del RUC; en los siguientes envíos lo reusamos — no se crean productos duplicados por cada emisión. El precio de cada comprobante sale siempre de lo que envías en ese comprobante (no del catálogo), así que puedes variar precios entre ventas sin problema. Si no envías codigo_interno, la emisión funciona igual, pero todas tus ventas se agrupan bajo un único producto genérico del catálogo.
RUC o DNI: el cliente lo puedes mandar con solo su documento
Si codigo_tipo_documento_identidad es "6" (RUC) o "1" (DNI), no necesitas mandar apellidos_y_nombres_o_razon_social, direccion ni ubigeo. Con el número basta — la API busca el resto automáticamente en SUNAT (RUC) o RENIEC (DNI):
"datos_del_cliente_o_receptor": {
"codigo_tipo_documento_identidad": "6",
"numero_documento": "20131312955"
}Si prefieres, puedes mandar también apellidos_y_nombres_o_razon_social, direccion y ubigeo — si los envías, se usan tal cual en vez de autocompletarse. Si el RUC/DNI no se encuentra, la API responde 422 pidiendo el nombre manualmente.
Otros documentos (carnet de extranjería, pasaporte, etc.)
Solo válidos en boleta, no en factura (la factura exige RUC). No hay autocompletado — manda apellidos_y_nombres_o_razon_social manualmente. No hace falta dirección ni ubigeo para ningún tipo de documento que no sea RUC.
Campos obligatorios que no debes omitir
Incluye siempre fecha_de_emision, hora_de_emision y, para facturas a crédito, fecha_de_vencimiento. Del cliente, siempre son obligatorios codigo_tipo_documento_identidad y numero_documento; el nombre es obligatorio solo si el documento no es RUC/DNI (ver nota de arriba). Si faltan, la API responde 422 indicando el dato ausente.
Calcular importes con /calcular
Es la forma más fácil de armar un comprobante. Le mandas tus productos (descripción, cantidad, precio y afectación) y te devuelve items y totales listos para copiar tal cual en POST /documents. No consume crédito y no necesita RUC.
Da el mismo resultado que calcularComprobante() de los SDK: redondeo half-up a 2 decimales por ítem, con aritmética decimal exacta.
Qué le mandas
| Campo | Qué es |
|---|---|
| porcentaje_igv | Opcional. 18 por defecto, o 10.5 (tasa reducida de la Ley 31556, solo si el RUC está acogido). Solo afecta a los ítems con afectación 10. |
| items[] | De 1 a 500 ítems. |
| items[].descripcion | Obligatorio, máximo 500 caracteres. |
| items[].cantidad | Mayor que 0. |
| items[].valor_unitario | Precio SIN IGV. Manda este o precio_unitario, uno de los dos (no ambos). |
| items[].precio_unitario | Precio CON IGV (lo que paga el cliente). Manda este o valor_unitario, no ambos. |
| items[].afectacion | "10" gravado, "20" exonerado, "30" inafecto, "40" exportación. Para otras afectaciones, arma el ítem a mano. |
| items[].unidad_de_medida | Opcional (NIU por defecto). Catálogo SUNAT 03: NIU para bienes/productos, ZZ para servicios (menú servido, delivery, consultoría…); KGM, LTR… si vendes por peso o volumen. Lista completa: GET /catalogos/03. |
| items[].codigo_interno | Opcional. Tu código de producto; se copia al ítem. |
Con precio_unitario se respeta lo que paga el cliente: 3 menús de S/ 10.00 suman S/ 30.00, no 29.99. Si vendes con precios que ya incluyen IGV (lo normal en una tienda o restaurante), usa este campo.
Descuentos no soportados: si mandas descuento, responde 422. Envía el precio ya descontado.
Ejemplo (cURL)
curl -X POST https://TU_URL_BASE/api/v1/issuer/calcular \
-H "X-API-Key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"descripcion": "Menu",
"cantidad": 3,
"precio_unitario": 10,
"afectacion": "10",
"unidad_de_medida": "ZZ",
"codigo_interno": "M1"
}
]
}'Respuesta (200)
{
"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
}
}Copia items y totales en tu POST /documents, junto con el RUC, el cliente y las fechas.
Gravados y exonerados en el mismo comprobante
Cada ítem lleva su propia afectacion. /calcular reparte las bases en total_operaciones_gravadas y total_operaciones_exoneradas, y el IGV solo sale de los gravados. Respuesta real del API:
curl -X POST https://TU_URL_BASE/api/v1/issuer/calcular \
-H "X-API-Key: TU_API_KEY" \
-H "Content-Type: 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" }
]
}'{
"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 como cualquier comprobante (codigo_tipo_operacion: "0101", copiando items y totales). Usa 20 solo para bienes o servicios que la ley exonera del IGV; si dudas de un producto, que lo confirme el contador del emisor.
Ejemplo completo en Node.js (fetch, sin SDK): calcular → emitir → PDF
Node 18 o superior. 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 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.
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'
// Fecha y hora de Perú (America/Lima), aunque tu servidor esté en UTC
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 external_id, serie_numero, estado y aceptado_por_sunat en tu venta.
// HTTP 200 NO es "aceptado": mira aceptado_por_sunat.
if (!doc.aceptado_por_sunat) console.warn(doc.serie_numero, doc.estado, doc.sunat, doc.accion_requerida)
// 3) PDF (?ruc= si la cuenta tiene varios RUC; no hace falta un header 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
}Errores
{
"success": false,
"codigo": "DATOS_INVALIDOS",
"reintentable": false,
"campos": {
"items.0.cantidad": ["cantidad debe ser un número mayor que 0."]
}
}422 DATOS_INVALIDOS con el detalle por ítem en campos. Ojo: si el plan de tu cuenta venció, responde 402 PLAN_VENCIDO como el resto de endpoints.
Calcular IGV y totales en tu propio código
Recomendado: usa POST /calcular o calcularComprobante() de los SDK. Esta sección es para quien prefiere no hacer una llamada extra.
La API espera los importes ya calculados (IGV, base, totales por ítem y del comprobante). Si quieres hacer esa cuenta en tu sistema, copia esta función: recibe cada ítem con precio sin IGV, cantidad y tipo de afectación, y devuelve el bloque items + totales listos para enviar.
Afectación: 10 gravado (18% IGV), 20 exonerado, 30 inafecto (estos dos sin IGV).
JavaScript
const IGV = 0.18;
const r2 = n => Math.round(n * 100) / 100;
// items: [{ codigo_interno, descripcion, unidad_de_medida, cantidad, valor_unitario, afectacion }]
function construirComprobante(items) {
let gravadas = 0, exoneradas = 0, inafectas = 0, totalIgv = 0;
const itemsApi = items.map(it => {
const base = r2(it.valor_unitario * it.cantidad);
const gravado = it.afectacion === '10';
const igv = gravado ? r2(base * IGV) : 0;
if (gravado) gravadas += base;
else if (it.afectacion === '20') exoneradas += base;
else inafectas += base;
totalIgv += igv;
return {
codigo_interno: it.codigo_interno,
descripcion: it.descripcion,
unidad_de_medida: it.unidad_de_medida || 'NIU',
cantidad: it.cantidad,
valor_unitario: r2(it.valor_unitario),
codigo_tipo_precio: '01',
precio_unitario: r2(it.valor_unitario * (gravado ? 1 + IGV : 1)),
codigo_tipo_afectacion_igv: it.afectacion,
total_base_igv: base,
porcentaje_igv: gravado ? 18 : 0,
total_igv: igv,
total_impuestos: igv,
total_valor_item: base,
total_item: r2(base + igv),
};
});
const totalValor = r2(gravadas + exoneradas + inafectas);
return {
items: itemsApi,
totales: {
total_operaciones_gravadas: r2(gravadas),
total_operaciones_exoneradas: r2(exoneradas),
total_operaciones_inafectas: r2(inafectas),
total_igv: r2(totalIgv),
total_impuestos: r2(totalIgv),
total_valor: totalValor,
total_venta: r2(totalValor + totalIgv),
},
};
}PHP
<?php
function construirComprobante(array $items): array {
$igvRate = 0.18;
$gravadas = 0; $exoneradas = 0; $inafectas = 0; $totalIgv = 0;
$itemsApi = [];
foreach ($items as $it) {
$base = round($it['valor_unitario'] * $it['cantidad'], 2);
$gravado = $it['afectacion'] === '10';
$igv = $gravado ? round($base * $igvRate, 2) : 0;
if ($gravado) $gravadas += $base;
elseif ($it['afectacion'] === '20') $exoneradas += $base;
else $inafectas += $base;
$totalIgv += $igv;
$itemsApi[] = [
'codigo_interno' => $it['codigo_interno'] ?? null,
'descripcion' => $it['descripcion'],
'unidad_de_medida' => $it['unidad_de_medida'] ?? 'NIU',
'cantidad' => $it['cantidad'],
'valor_unitario' => round($it['valor_unitario'], 2),
'codigo_tipo_precio' => '01',
'precio_unitario' => round($it['valor_unitario'] * ($gravado ? 1 + $igvRate : 1), 2),
'codigo_tipo_afectacion_igv' => $it['afectacion'],
'total_base_igv' => $base,
'porcentaje_igv' => $gravado ? 18 : 0,
'total_igv' => $igv,
'total_impuestos' => $igv,
'total_valor_item' => $base,
'total_item' => round($base + $igv, 2),
];
}
$totalValor = round($gravadas + $exoneradas + $inafectas, 2);
return [
'items' => $itemsApi,
'totales' => [
'total_operaciones_gravadas' => round($gravadas, 2),
'total_operaciones_exoneradas' => round($exoneradas, 2),
'total_operaciones_inafectas' => round($inafectas, 2),
'total_igv' => round($totalIgv, 2),
'total_impuestos' => round($totalIgv, 2),
'total_valor' => $totalValor,
'total_venta' => round($totalValor + $totalIgv, 2),
],
];
}El resultado tiene items y totales — únelos con el resto del comprobante (ruc, cliente, fechas) y envía.
Numeración, fechas y locales
Tienes dos formas de numerar tus comprobantes: que la numere FactuSmart, o que la numere tu propio sistema. Ambas funcionan desde siempre — elige una por serie y no las mezcles en la misma serie.
Opción A — FactuSmart pone el número (lo más simple)
Manda "numero_documento": "#" y no envíes serie. Usamos la serie ya configurada en tu RUC y le asignamos el correlativo siguiente:
{
"ruc": "20100070970",
"codigo_tipo_documento": "01",
"numero_documento": "#",
...
}Nosotros nos encargamos de que no haya saltos ni números repetidos, incluso si emites varias ventas al mismo tiempo. Es el modo recomendado si tu sistema no lleva su propia numeración de comprobantes.
Opción B — Tu sistema manda la serie y el número
Si tu ERP o punto de venta ya lleva su propia numeración y necesitas conservarla, mándala tal cual: en vez de "#", pon el número real y agrega serie_documento:
{
"ruc": "20100070970",
"codigo_tipo_documento": "01",
"serie_documento": "F001",
"numero_documento": "125",
...
}Respetamos exactamente lo que mandes: ese comprobante se emite como F001-125, sin tocarlo.
3 cosas que debes saber antes de usar esta opción
- La serie debe estar dada de alta en el RUC. No basta con inventarla en el JSON. Consulta las que existen con
GET /api/v1/issuer/series?ruc=20100070970. Si mandas una que no existe, la API responde:La serie ingresada F001, es incorrecta.Si necesitas una serie distinta a la que trae el RUC, escríbenos y la registramos. - No puedes repetir serie + número. Si mandas uno ya emitido, la API lo rechaza con:
El documento: 01 F001-125 ya se encuentra registrado.Esto es a propósito — te protege de emitir dos comprobantes con el mismo número. - La continuidad queda de tu lado. Al tomar el control de la numeración, tu sistema es el responsable de que no haya huecos ni saltos. SUNAT rechaza tanto los duplicados como los correlativos salteados.
¿Migrando desde otro sistema?
Esta es la opción que necesitas para continuar tu numeración actual sin volver a empezar desde 1. Averigua en qué número va cada serie ante SUNAT y continúa desde el siguiente. Nunca emitas la misma serie desde dos sistemas a la vez — ver el error 1033.
Fecha y hora de emisión: en hora de Perú
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) y deben ser reales (las de ahora), no una fecha futura. Una fecha futura genera comprobantes que luego no podrás anular (la comunicación de baja no puede ser anterior a la fecha del documento).
No uses la fecha local de tu servidor: uno 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. En Node:
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: son obligatorias y, si faltan, la emisión se rechaza con 422 (no se cobra).
¿Varios locales?
Por defecto se emite desde tu establecimiento principal (código 0000) y no necesitas indicar nada. Si tu RUC tiene más locales, cada uno con sus series, mira Varios locales (anexos).
Varios locales (anexos)
Un RUC puede emitir desde varios locales registrados en SUNAT. Cada local tiene un código de anexo de 4 dígitos (0000 = domicilio fiscal) y sus propias series. La dirección del local sale en el XML y en el PDF del comprobante. Ninguno de estos endpoints gasta créditos.
Ver tus locales
{
"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": "[email protected]", "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", ..., "predeterminado": false,
"series": [ { "serie": "FA02", ... }, { "serie": "B002", ... }, { "serie": "BA02", ... } ] }
]
}predeterminado: true es el local desde donde se emite si no indicas otro. series lista los comprobantes SUNAT del local (01, 03, 07, 08, 09, 31, 20, 40, 04).
Crear un local
| Campo | Qué es |
|---|---|
| codigo_anexo | Obligatorio. Los 4 dígitos que SUNAT asignó al local. Único en el RUC. |
| nombre | Obligatorio. Único en el RUC. |
| direccion | Obligatorio. |
| ubigeo | Obligatorio. Código INEI del distrito, 6 dígitos (ej. 150122 = Miraflores). |
| telefono, email | Opcionales. |
| crear_series_por_defecto | Opcional, true por defecto: crea las series del local igual que el sistema web (FA0N, BA0N, FN0N, BN0N, FD0N, BD0N, guías…). |
| series | Opcional. Series extra: [{"codigo_tipo_documento":"03","serie":"B002"}]. Tipos 01, 03, 07, 08; máximo 20; mismas reglas que el sistema web (factura F + 3, boleta B + 3). |
POST /api/v1/issuer/establecimientos
{
"ruc": "20100070970",
"codigo_anexo": "0001",
"nombre": "Tienda Miraflores",
"direccion": "Av. Larco 345",
"ubigeo": "150122",
"telefono": "014445566",
"series": [{ "codigo_tipo_documento": "03", "serie": "B002" }]
}
→ 201
{
"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", "distrito": "Miraflores",
"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" }, ... ] }
}- Registra antes el anexo en SUNAT (Clave SOL): el API no lo da de alta en SUNAT.
- Datos inválidos →
422 DATOS_INVALIDOScon el detalle encampos(nombre o código repetido, ubigeo inválido, serie con formato incorrecto o ya usada en el RUC…). - El plan del RUC no permite más locales →
403 LIMITE_ESTABLECIMIENTOS. Se amplía el plan desde el sistema. - Con la llave de prueba (
fs_test_…) solo en RUCs en modo prueba; en producción →403 LLAVE_DE_PRUEBA_EN_PRODUCCION. - Editar o eliminar un local se hace desde el sistema web (Empresa → Establecimientos).
Emitir desde un local
En POST /documents, cualquiera de estas dos formas:
"codigo_anexo": "0001"(también se acepta el nombre antiguocodigo_establecimiento). Si el local tiene una sola serie de ese tipo, no hace faltaserie_documento.- Solo la serie del local (
"serie_documento": "BA02"), sin código: el sistema usa el local dueño de esa serie.
{
"ruc": "20100070970",
"codigo_anexo": "0001",
"serie_documento": "B002",
"numero_documento": "#",
"codigo_tipo_documento": "03",
...
}
→ { "success": true, "estado": "05", "serie_numero": "B002-1", ... }Sin ninguno de los dos se emite desde el local predeterminado, como siempre. Un código que no existe en tu RUC (ej. "0009") responde 422 DATOS_INVALIDOS. En el XML firmado el anexo va en cbc:AddressTypeCode.
Evitar comprobantes duplicados
Manda un header Idempotency-Key con un valor único por cada venta (por ejemplo, el ID de la venta en tu sistema):
Idempotency-Key: venta-000123Si tu sistema reintenta el mismo pedido (porque se cortó el internet o hubo un timeout), te devolvemos el mismo comprobante que ya emitimos, sin emitir ni cobrar de nuevo. Es la forma segura de reintentar sin arriesgar un duplicado fiscal.
- Misma
Idempotency-Key+ emisión ya completada → devuelve el comprobante original. - Misma key mientras la primera petición aún se procesa → responde
409(reintenta en unos segundos). - Si la emisión falló, la key queda libre: puedes reintentar con la misma.
- Usa una key distinta por cada comprobante nuevo.
- No generes un UUID nuevo en cada intento: la key sale de tu venta y es la misma en todos los reintentos; si cambia, el reintento emite un segundo comprobante. La key vale por cuenta, no por RUC: si emites para varios RUC con ids que pueden repetirse, inclúyelo (
20100070970-venta-123). Ver el ejemplo en Node.
Detracción
Para emitir una factura sujeta a detracción usa "codigo_tipo_operacion": "1001" (o "1004" para transporte de carga) y agrega el bloque detraccion. La detracción aplica a operaciones desde S/ 700.
{
"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.00,
"total_igv": 180.00,
"total_impuestos": 180.00,
"total_valor": 1000.00,
"total_venta": 1180.00
},
"detraccion": {
"codigo_tipo_detraccion": "022",
"porcentaje": 12,
"monto": 141.60,
"codigo_metodo_pago": "001",
"cuenta_bancaria": "00-000-000000"
},
"items": [
{
"descripcion": "Servicio empresarial sujeto a detracción",
"unidad_de_medida": "ZZ",
"cantidad": 1,
"valor_unitario": 1000.00,
"codigo_tipo_precio": "01",
"precio_unitario": 1180.00,
"codigo_tipo_afectacion_igv": "10",
"total_base_igv": 1000.00,
"porcentaje_igv": 18,
"total_igv": 180.00,
"total_impuestos": 180.00,
"total_valor_item": 1000.00,
"total_item": 1180.00
}
]
}Campos del bloque detraccion:
codigo_tipo_detraccion— el código del bien/servicio (tabla de abajo).porcentaje— el % según el código (ver tabla).monto— la detracción calculada: % sobre el total con IGV (ej. 1180 × 12% = 141.60), redondeado a 2 decimales.codigo_metodo_pago—"001"(Depósito en cuenta), el más común.cuenta_bancaria— tu cuenta del Banco de la Nación.
Códigos de bien/servicio más usados y sus porcentajes:
| Código | % | Tipo de operación | Bien / servicio |
|---|---|---|---|
| 001 | 10% | 1001 | Azúcar y melaza de caña |
| 003 | 10% | 1001 | Alcohol etílico |
| 005 | 4% | 1001 | Maíz amarillo duro |
| 008 | 4% | 1001 | Madera |
| 016 | 10% | 1001 | Aceite de pescado |
| 019 | 10% | 1001 | Arrendamiento de bienes |
| 020 | 12% | 1001 | Mantenimiento y reparación de bienes muebles |
| 022 | 12% | 1001 | Otros servicios empresariales |
| 023 | 4% | 1001 | Leche |
| 025 | 10% | 1001 | Fabricación de bienes por encargo |
| 027 | 4% | 1004 | Servicio de transporte de carga |
| 030 | 4% | 1001 | Contratos de construcción |
La lista completa está en el Anexo del catálogo 54 de SUNAT. El código va tal cual al comprobante, así que cualquier código oficial vigente es válido.
Exportación de servicios
Para facturar a un cliente del extranjero (licencias de software, consultoría, hospedaje a no domiciliados, etc.). La operación no lleva IGV y el cliente no necesita RUC peruano.
No tienes que activar nada en tu cuenta — funciona con la misma API key y la misma serie de facturas que ya usas.
Los 3 campos que cambian respecto a una factura normal
| Campo | Valor |
|---|---|
codigo_tipo_operacion | "0201" — el más común (servicio prestado íntegramente en el Perú). Los demás, al final de esta sección. |
codigo_tipo_afectacion_igv (en cada ítem) | "40" — Exportación. El IGV va en 0. |
total_exportacion (en totales) | El importe va aquí, no en total_operaciones_gravadas (que queda en 0). |
El comprobante es una factura ("codigo_tipo_documento": "01") con tu misma serie de facturas — SUNAT no exige una serie aparte para exportación.
Cómo identificar al cliente extranjero (sin RUC)
| Código | Documento |
|---|---|
0 | Documento tributario de no domiciliado, sin RUC |
7 | Pasaporte |
B | Documento de identidad del país de residencia |
C | TIN — Tax Identification Number (persona natural) |
D | IN — Identification Number (persona jurídica) |
Agrega también codigo_pais con el código ISO de 2 letras (US, ES, MX…).
⚠️ El número de documento no admite guiones ni espacios
SUNAT lo rechaza con el código 2802 ("el número de documento de identidad del receptor no cumple con el formato establecido"). Un TIN enviado como US-EIN-98-7654321 es rechazado; el mismo dato como 987654321 es aceptado.
El límite de S/ 700 NO aplica aquí
Esa regla —la que obliga a identificar al cliente cuando la venta pasa de S/ 700— es de boletas. En una factura de exportación puedes facturar cualquier importe a un cliente sin RUC peruano.
Ejemplo completo
{
"ruc": "20100070970",
"numero_documento": "#",
"fecha_de_emision": "2026-09-18",
"hora_de_emision": "10:30:00",
"codigo_tipo_operacion": "0201",
"codigo_tipo_documento": "01",
"codigo_tipo_moneda": "USD",
"datos_del_cliente_o_receptor": {
"codigo_tipo_documento_identidad": "0",
"numero_documento": "987654321",
"apellidos_y_nombres_o_razon_social": "ACME SOFTWARE LLC",
"direccion": "Wilmington, Delaware, USA",
"codigo_pais": "US"
},
"totales": {
"total_exportacion": 1500.00,
"total_operaciones_gravadas": 0,
"total_igv": 0,
"total_impuestos": 0,
"total_valor": 1500.00,
"total_venta": 1500.00
},
"items": [
{
"codigo_interno": "LIC-SW-001",
"descripcion": "Licencia de software - uso fuera del Peru",
"unidad_de_medida": "ZZ",
"cantidad": 1,
"valor_unitario": 1500.00,
"codigo_tipo_precio": "01",
"precio_unitario": 1500.00,
"codigo_tipo_afectacion_igv": "40",
"total_base_igv": 1500.00,
"porcentaje_igv": 0,
"total_igv": 0,
"total_impuestos": 0,
"total_valor_item": 1500.00,
"total_item": 1500.00
}
]
}La moneda puede ser USD o PEN, según lo que acuerdes con tu cliente.
Otros tipos de exportación
| Código | Cuándo se usa |
|---|---|
0200 | Exportación de bienes (mercadería, no servicios) |
0201 | Servicio prestado íntegramente en el Perú — el más común |
0202 | Hospedaje a no domiciliados |
0203 | Transporte de navieras |
0204 | Servicios a naves y aeronaves de bandera extranjera |
0205 | Servicios que conforman un paquete turístico |
0206 | Servicios complementarios al transporte de carga |
0208 | Servicio prestado parcialmente en el extranjero |
Para exportar servicios debes estar inscrito en el Registro de Exportadores de Servicios de SUNAT. Ese trámite lo haces tú ante SUNAT — no depende de FactuSmart ni de la API.
Orden de compra, crédito y nota adicional
{
"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.00 },
{ "fecha": "2026-09-08", "codigo_tipo_moneda": "PEN", "monto": 118.00 }
],
"informacion_adicional": "Entrega en almacén central - Contactar antes de las 5pm",
"datos_del_cliente_o_receptor": {
"codigo_tipo_documento_identidad": "6",
"numero_documento": "20131312955"
},
"totales": {
"total_operaciones_gravadas": 200.00,
"total_igv": 36.00,
"total_impuestos": 36.00,
"total_valor": 200.00,
"total_venta": 236.00
},
"items": [
{
"descripcion": "Servicio de consultoría",
"unidad_de_medida": "NIU",
"cantidad": 2,
"valor_unitario": 100.00,
"codigo_tipo_precio": "01",
"precio_unitario": 118.00,
"codigo_tipo_afectacion_igv": "10",
"total_base_igv": 200.00,
"porcentaje_igv": 18,
"total_igv": 36.00,
"total_impuestos": 36.00,
"total_valor_item": 200.00,
"total_item": 236.00
}
]
}Orden de compra
numero_orden_de_compra — un texto libre. Si lo envías, se agrega al comprobante como referencia de orden de compra visible tanto en el PDF como en el XML que recibe SUNAT.
Condición de pago (contado / crédito)
codigo_condicion_de_pago — manda el código, no una palabra. Si no envías el campo, se asume Contado.
| Código | Condición |
|---|---|
| 01 | Contado (default si no envías el campo) |
| 02 | Crédito — requiere el array cuotas |
"Credito" en vez del código "02" no funciona. No da error: el sistema no lo reconoce y emite el comprobante como Contado, sin cuotas.Cuotas (solo si codigo_condicion_de_pago es "02")
fecha— fecha de vencimiento de la cuota.codigo_tipo_moneda— moneda de la cuota (normalmente igual a la del comprobante).monto— importe de la cuota. La suma de todas las cuotas debe coincidir contotal_venta.codigo_metodo_de_pago— opcional.
Información adicional
informacion_adicional — texto libre (ej. instrucciones de entrega, referencia interna). Aparece en el PDF del comprobante y también viaja dentro del XML UBL enviado a SUNAT como nota adicional.
Campos opcionales avanzados
El API también acepta estos campos en POST /documents. No hacen falta para una venta normal; la estructura exacta de cada uno (subcampos obligatorios y tipos) está en la Referencia del API.
| Campo | Para qué sirve |
|---|---|
cargos | Cargos globales al comprobante (ej. recargo por servicio). Cada uno con código del catálogo 53 de SUNAT, descripción, factor, monto y base. |
descuentos | Descuentos globales al comprobante, con la misma estructura que los cargos (catálogo 53). |
anticipos | Anticipos cobrados antes y que se descuentan en esta factura: número y tipo del comprobante del anticipo, monto y total. |
percepcion | Percepción del IGV: código, porcentaje, monto y base. |
retencion | Retención del IGV: código, porcentaje, monto y base. |
pagos | Pagos recibidos en la venta (solo facturas y boletas). |
guias | Guías de remisión relacionadas con la venta (número y tipo de documento). |
relacionados | Otros documentos relacionados (número, tipo de documento y monto). |
leyendas | Leyendas adicionales del comprobante (código del catálogo 52 de SUNAT y texto). |
factor_tipo_de_cambio | Tipo de cambio de venta, para comprobantes en moneda extranjera. |
numero_de_placa | Placa vehicular (por ejemplo, en ventas de combustible). |
Anulaciones y correcciones
Notas de crédito / débito
Una nota es como una factura/boleta, pero con dos datos extra: codigo_tipo_nota y documento_afectado (el comprobante original al que corrige). Usa "codigo_tipo_documento": "07" para nota de crédito y "08" para nota de débito.
Aquí serie_documento es obligatorio (aunque dejes que numeremos nosotros)
A diferencia de facturas y boletas, cada RUC tiene dos series de nota por tipo: una para las notas que corrigen facturas y otra para las que corrigen boletas. Como el sistema no puede adivinar cuál, aquí debes indicar serie_documento siempre — incluso con la Opción A ("numero_documento": "#"), donde en facturas y boletas la serie sí es opcional. Consulta las series exactas de tu RUC:
Busca en la respuesta las series con codigo_tipo_documento 07 (crédito) u 08 (débito). Por convención suelen ser FN01/FD01 para notas de factura y BN01/BD01 para notas de boleta, pero usa siempre lo que devuelva el endpoint.
Ejemplo — nota de crédito (campos extra):
{
"ruc": "20100070970",
"serie_documento": "FN01",
"numero_documento": "#",
"fecha_de_emision": "2026-06-30",
"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": {
"external_id": "7cbce41c-...",
"serie_documento": "FA01",
"numero_documento": "1",
"codigo_tipo_documento": "01"
},
"codigo_tipo_moneda": "PEN",
"datos_del_cliente_o_receptor": { "...igual que la factura original..." },
"totales": { "...": "..." },
"items": [ { "...": "..." } ]
}El resto (cliente, totales, items) va igual que en una factura/boleta. Códigos comunes de codigo_tipo_nota: crédito 01 anulación, 07 devolución por ítem; débito 02 aumento en el valor.
Anulaciones (facturas y boletas)
El mismo endpoint anula facturas y boletas (y sus notas de crédito/débito). Según lo que anules, enviamos a SUNAT el trámite que corresponde:
| Qué anulas | Trámite en SUNAT | tipo | Identificador |
|---|---|---|---|
| Facturas y sus notas (series F…) | Comunicación de baja | baja | RA-AAAAMMDD-N |
| Boletas y sus notas (series B…) | Resumen diario de anulación. Solo boletas ya aceptadas por SUNAT (estado 05). | resumen | RC-AAAAMMDD-N |
No mezcles facturas y boletas en la misma anulación
Si en documentos van facturas (o sus notas) junto con boletas (o sus notas), la API responde 422 con codigo: ANULACION_MIXTA. Haz una petición por tipo. El plazo para anular es el que tenga configurado la empresa (por defecto, 7 días).
¿Cuándo usar esto? Solo cuando un comprobante ya Aceptado (05) resultó estar mal (monto equivocado, cliente equivocado, venta que no se concretó) y necesitas dejarlo sin efecto ante SUNAT. No es para corregir — no puedes editar un comprobante ya aceptado. El flujo correcto es: anular el que está mal, y si corresponde, emitir uno nuevo con los datos correctos.
¿Uno o dos documentos, cliente presente?
Si el cliente sigue ahí y el error se nota al toque (ej. cobraste de más), casi siempre es más simple emitir una nota de crédito en vez de anular — la nota de crédito corrige el monto sin dejar el comprobante original completamente sin efecto. Reserva la anulación para cuando el comprobante entero debe desaparecer (la venta nunca debió emitirse).
Es un proceso asíncrono porque SUNAT no lo confirma al instante: envías la anulación, SUNAT te devuelve un ticket, y minutos después consultas ese ticket para saber si SUNAT la aceptó. La fecha (fecha_de_emision_de_documentos) va en formato DD-MM-AAAA y debe ser exactamente la misma fecha de emisión de los comprobantes que anulas (no la fecha de hoy). En un RUC en producción, cada anulación enviada cuesta 1 crédito (si el envío falla, se devuelve); en un RUC en modo prueba es gratis (cuenta para el tope de 200 pruebas diarias).
Paso 1 — Enviar la anulación
{
"ruc": "20100070970",
"fecha_de_emision_de_documentos": "30-06-2026",
"documentos": [
{ "external_id": "9822f52c-...", "motivo_anulacion": "Error en el documento" }
]
}Puedes mandar varios documentos en la misma anulación si todos comparten la misma fecha de emisión y son del mismo tipo (si son de días distintos, envía una anulación por cada fecha) — útil si te diste cuenta tarde de un lote entero mal emitido.
Respuesta (ejemplo: boleta en un RUC en modo prueba)
{
"success": true,
"message": "Anulación enviada a SUNAT. Consulta el resultado con el ticket.",
"tipo": "resumen",
"ticket": "1792012278669",
"external_id": "87c0049e-...",
"identificador": "RC-20261014-1",
"creditos_restantes": 14945,
"credito_cobrado": false,
"modo_demo": true,
"pruebas_restantes_hoy": 197
}tipo es baja (facturas) o resumen (boletas). Guarda el ticket. En producción, credito_cobrado viene true y no llegan modo_demo ni pruebas_restantes_hoy.
Paso 2 — Consultar el resultado (con el ticket que te devolvió el paso 1)
Sirve para los dos tipos. Espera unos minutos antes de consultar — SUNAT procesa la anulación de forma asíncrona. Si consultas muy pronto, el resultado puede seguir en trámite; vuelve a consultar más tarde. La respuesta trae:
tipo:bajaoresumen.estadode la anulación:05aceptada,09rechazada,03enviada (todavía en trámite).documentos:[{ external_id, serie_numero, estado }], con el estado de cada comprobante:11anulado,13por anular.- Si SUNAT rechaza un resumen de boletas, esas boletas vuelven al estado
01.
Consultar estado más tarde
Al emitir ya recibes el estado del comprobante en la misma respuesta — no necesitas llamar a este endpoint justo después de emitir. Úsalo cuando quieras revisar un comprobante en otro momento, distinto al de la emisión:
- Tu panel de ventas muestra el historial y quieres refrescar el estado de comprobantes antiguos sin volver a emitir nada.
- El cliente pide de nuevo el PDF días o semanas después (por WhatsApp, correo, etc.) — consultas para obtener el link de descarga actual.
- Tienes un proceso propio que revisa periódicamente los comprobantes que quedaron pendientes (aunque para eso normalmente es mejor el listado con filtro de estado, que trae varios a la vez).
- Guardaste el
external_idde una emisión y quieres recuperar sus datos completos (serie-número, total, links) sin tenerlos ya en tu BD.
{
"success": true,
"estado": "05",
"estado_descripcion": "Aceptado",
"serie_numero": "FA01-1234",
"total": 118.00,
"aceptado_por_sunat": true,
"sunat": { "codigo": "0", "descripcion": "La Factura FA01-1234 ha sido aceptada" },
"links": { "pdf": "...", "xml": "...", "cdr": "..." }
}Este endpoint lee lo que ya tenemos guardado — es instantáneo y no consume crédito, pero no le pregunta nada nuevo a SUNAT. Si el comprobante quedó en 01 (no confirmado) y necesitas saber si SUNAT lo aceptó en realidad, este endpoint no te lo dirá — usa consultar-sunat, que sí pregunta en vivo.
Si un comprobante no sale "Aceptado"
⚠️ La regla de oro: HTTP 200 no significa "aceptado por SUNAT"
Un 200 significa que generamos tu comprobante. Que SUNAT lo acepte es otra cosa: SUNAT puede estar caída, tardar, o rechazarlo. Mira siempre aceptado_por_sunat, nunca solo el código HTTP.
Los 3 estados que te importan
| Estado | Significa | Qué haces |
|---|---|---|
05 | Aceptado por SUNAT. Tiene validez fiscal. | Nada. Entrégalo a tu cliente. |
01 | No llegó a SUNAT o SUNAT no lo confirmó. Sin validez fiscal todavía. | Lee sunat.descripcion y actúa (abajo). |
09 | SUNAT lo rechazó. Ese número quedó quemado. | Corrige los datos y emite uno nuevo. |
1. Encuentra los atascados
Cada uno te dice por qué se quedó ahí, sin que tengas que preguntar:
{
"success": true,
"comprobantes": [
{
"external_id": "9f8e7d6c-...",
"serie_numero": "BA01-1318",
"estado": "01",
"aceptado_por_sunat": false,
"sunat": {
"codigo": "0111",
"descripcion": "No tiene el perfil para enviar comprobantes electronicos"
},
"accion_requerida": "El comprobante NO fue aceptado por SUNAT todavía. Revisa
\"sunat.descripcion\", corrige lo que indique y reintenta
con POST /documents/9f8e7d6c-.../reenviar"
}
]
}Filtros: estado, desde, hasta, limite (máx. 100). No consume crédito.
Si tienes más de 100, pagina con antes_de: cada respuesta trae siguiente_cursor — mándalo como antes_de en la próxima llamada para seguir. siguiente_cursor: null significa que no hay más.
2. Mira el historial completo
Toda la línea de tiempo del comprobante: cada intento y qué respondió SUNAT en cada uno.
{
"historial": [
{ "fecha": "2026-07-15 14:20:08", "evento": "creado",
"mensaje": "Comprobante generado" },
{ "fecha": "2026-07-15 14:20:11", "evento": "error", "codigo_sunat": "0111",
"mensaje": "No tiene el perfil para enviar comprobantes electronicos" },
{ "fecha": "2026-07-15 18:03:26", "evento": "reintento",
"mensaje": "Reintento de envío solicitado por el integrador (API)" },
{ "fecha": "2026-07-15 18:03:29", "evento": "aceptado", "codigo_sunat": "0",
"mensaje": "La Boleta BA01-1318 ha sido aceptada",
"detalle": { "estado": "05", "estado_anterior": "01" } }
]
}Eventos: creado, registrado (quedó en estado 01), enviado, aceptado, observado, rechazado, error, reintento, anulacion_enviada, por_anular (anulación enviada, esperando a SUNAT) y anulado. No consume crédito.
3. Reenvía a SUNAT
Retomamos esto en su propia sección más abajo, con todo el detalle: Reenviar a SUNAT ↓.
4. Pregúntale a SUNAT en vivo
Consulta el estado real en SUNAT (no el nuestro) y lo sincroniza. Úsalo cuando el envío quedó en el aire y no sabes si SUNAT alcanzó a aceptarlo — a veces SUNAT sí lo recibió aunque la conexión se cortó antes de que nos confirmara. No consume crédito.
Los errores más comunes y qué significan de verdad
| Código | Causa real | Solución |
|---|---|---|
0111 | El usuario SOL existe pero no tiene el perfil de Facturación Electrónica. | Detente. Ese RUC no puede emitir nada. Entra al portal SUNAT con la Clave SOL principal y dale el permiso de facturación electrónica al usuario secundario. Reenviar no sirve hasta arreglarlo. |
0102 | Usuario o contraseña SOL incorrectos. | Detente. Corrige las credenciales en el panel del RUC. Mismo caso: afecta a todo el RUC. |
1033 | SUNAT ya tiene ese serie-número con otros datos. Típico si migraste de otro sistema, o si sigues emitiendo desde tu sistema anterior con la misma serie. | No reenvíes: ese número está quemado. Averigua en qué número va realmente SUNAT y continúa desde el siguiente. Nunca emitas la misma serie desde dos sistemas a la vez. |
0306 y otros 1001–1077 | Error en el contenido del comprobante (datos inválidos). | Reenviar no ayuda. Corrige los datos y emite uno nuevo. |
| Sin código, o mensaje de red / timeout | SUNAT no respondió (caída o lenta). Tu comprobante está bien — SUNAT no estaba disponible. | Reenvía más tarde. Nosotros ya reintentamos solos 3 veces al día, así que muchas veces se resuelve sin que hagas nada. |
Cuando SUNAT se cae (pasa seguido)
SUNAT tiene caídas e intermitencias con frecuencia. Cuando pasa, tus comprobantes quedan en 01 — no se pierden ni se cobran dos veces, solo esperan.
Nosotros los reintentamos automáticamente 3 veces al día. La mayoría se resuelve sola. Si necesitas que salga ya, usa /reenviar. Lo importante: no reemitas el comprobante — crearías un duplicado y quemarías otro correlativo.
La estrategia que usamos nosotros (cópiala)
- Al emitir, guarda siempre el
external_idy revisaaceptado_por_sunat. Si esfalse, leesunat.codigoen ese mismo momento. - Si el código es 0111 o 0102 → detén la emisión de ese RUC. Es un problema de configuración: todas las boletas que mandes van a fallar igual. Arréglalo en SUNAT primero. (Este error le costó un día entero a un integrador que siguió emitiendo sin mirar el mensaje.)
- Si es 1033 → no reenvíes. Revisa tu numeración; ese número ya existe en SUNAT.
- Si es un timeout o SUNAT no respondió → no hagas nada de inmediato. Nosotros reintentamos solos.
- Una vez al día, corre
GET /documents?estado=01por si algo quedó colgado, y usa/reenviaro/consultar-sunatsegún el caso. Con eso cubres todo — no necesitas estar preguntando a cada rato.
Por qué nunca marcamos como "Aceptado" algo que SUNAT no aceptó
Si SUNAT devuelve un código de duplicado (1033 y similares), dejamos el comprobante en 01 aunque parezca que "se envió". Preferimos que veas un estado incómodo antes que darte un PDF verde de una boleta que para SUNAT no existe — eso termina en multas durante una fiscalización.
Reenviar a SUNAT
¿Para qué sirve? Un comprobante que quedó en 01 (no confirmado) casi siempre necesita un reintento, no un comprobante nuevo. Este endpoint retoma el envío de ese mismo comprobante — no crea uno nuevo ni gasta un correlativo extra. Es el mismo botón "Reenviar" que usamos nosotros desde el panel del RUC.
¿Cuándo usarlo? Solo cuando la causa es algo que ya arreglaste o que se resolvió solo — SUNAT estaba caída, corregiste el usuario SOL, etc. Si el código de error dice que ya arreglar no basta (por ejemplo 1033, número ya usado), reenviar no sirve — revisa la tabla de errores comunes antes de reenviar a ciegas.
No consume crédito — ya lo pagaste al emitirlo la primera vez.
curl -X POST "{TU_URL_BASE}/api/v1/issuer/documents/9f8e7d6c-.../reenviar" \
-H "X-API-Key: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ruc": "20100070970"}'
# → Si se reenvía correctamente:
# { "success": true, "estado": "05", "aceptado_por_sunat": true,
# "sunat": { "codigo": "0", "descripcion": "...ha sido aceptada" } }⚠️ "success: true" en /reenviar solo significa "el reintento se ejecutó sin error de sistema"
Si SUNAT rechaza el reenvío (por ejemplo, código 1033 porque ese número ya existe), la respuesta sigue siendo HTTP 200:
{ "success": true, "estado": "09", "aceptado_por_sunat": false,
"sunat": { "codigo": "1033", "descripcion": "El comprobante fue registrado
previamente con otros datos" } }Aplica la misma regla de oro de siempre: mira aceptado_por_sunat, nunca solo success.
Descargar PDF / XML / CDR
Basta el header X-API-Key: no hace falta un header Accept especial. Si tu cuenta tiene varios RUC, agrega ?ruc= (o el header X-RUC); sin él responde 422 RUC_REQUERIDO. Con un solo RUC puedes omitirlo.
Los 3 archivos existen para casos distintos — normalmente solo necesitas el PDF, los otros dos son para escenarios específicos:
PDF — para tu cliente
El comprobante listo para imprimir o enviar al cliente (por correo, WhatsApp, o para que lo descargue desde tu sistema). Es el que usa el 99% de las integraciones — muestra este link apenas termina la venta.
PDF en ticket u otro tamaño
| 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 en A4. - Solo aplica al PDF: en
/xmlo/cdr, o con un formato que no existe, responde422 DATOS_INVALIDOS(traeformatos_validos). - Si el generador falla dos veces seguidas responde
500 PDF_NO_GENERADO(reintentable): vuelve a pedirlo en unos segundos. Nunca recibes un PDF de otro tamaño "disfrazado". GET /documents/{external_id}trae tambiénlinks.pdf_ticket(el de 80 mm).
XML — para contabilidad
El archivo firmado digitalmente (formato UBL 2.1) que SUNAT procesó. Lo pide un contador para declarar, o un cliente empresa que necesita el XML para sustentar su compra ante SUNAT — no un consumidor final que solo quiere su boleta.
CDR — la prueba de que SUNAT lo aceptó
Es el ZIP que emite SUNAT confirmando la recepción (Constancia de Recepción). Úsalo si un cliente o una fiscalización pide demostrar que el comprobante realmente fue aceptado por SUNAT y no solo generado por tu sistema — el CDR es la evidencia oficial de SUNAT, no algo que emitimos nosotros.
Qué puedes descargar según el estado
- PDF y XML: se generan al emitir, así que se descargan aunque el comprobante siga en
01(no confirmado). Ojo: en ese estado todavía no tiene validez fiscal; no lo entregues como definitivo hasta queaceptado_por_sunatseatrue. - CDR: solo existe cuando SUNAT ya respondió. Mientras esté en
01, el endpoint del CDR responde404.
Si el comprobante no existe o el archivo todavía no existe, la descarga responde 404 en JSON con "codigo": "NO_ENCONTRADO" (igual que el resto de errores, ver Códigos de error). Revisa el código HTTP antes de guardar la respuesta como archivo. Si el comprobante no sale aceptado, ve a Si no sale "Aceptado".
Webhooks: avisos automáticos de estado
Un webhook es un aviso que FactuSmart envía a tu servidor cuando cambia el estado de un comprobante. Así no tienes que consultar GET /documents/{external_id} cada cierto tiempo para saber si SUNAT ya lo aceptó.
- Una sola URL por cuenta: recibes los avisos de todos tus RUC.
- Solo se avisa de comprobantes emitidos por el API (no de los emitidos desde el panel web).
- El envío no retrasa la emisión:
POST /documentsresponde igual y el aviso sale aparte.
Cómo configurarlo
- Entra a tu portal del integrador → pestaña Webhooks. (No hay endpoint del API para configurarlo: es solo desde el portal.)
- Pega la URL de tu servidor (
https://y pública) y pulsa Guardar. - Se genera un secreto que empieza con
whsec_. Cópialo y guárdalo en tu servidor (ej. variable de entornoFACTUSMART_WEBHOOK_SECRET): sirve para verificar que el aviso viene de FactuSmart. - Pulsa Enviar prueba para comprobar que tu servidor lo recibe.
- Para desactivarlo, deja la URL vacía y guarda.
Tu servidor debe responder con un código 2xx en menos de 8 segundos. Si tienes un proceso largo, responde 200 de inmediato y procésalo después.
Eventos
| Evento | Estado | Cuándo |
|---|---|---|
| comprobante.aceptado | 05 | SUNAT lo aceptó (CDR recibido) |
| comprobante.observado | 07 | SUNAT lo aceptó con observaciones |
| comprobante.rechazado | 09 | SUNAT lo rechazó |
| comprobante.anulado | 11 | La baja (anulación) fue aceptada |
| prueba | — | Lo enviaste tú con el botón "Enviar prueba" del portal |
La petición que recibes
POST https://tu-sistema.com/webhooks/factusmart
Content-Type: application/json
User-Agent: FactuSmart-Webhooks/1.0
X-FactuSmart-Evento: comprobante.aceptado
X-FactuSmart-Id: 0fbb616d-ec40-4bbd-a89f-591d9d404630
X-FactuSmart-Intento: 1
X-FactuSmart-Firma: b4d50ab151a9830257a596046c45b2f3e56c79793b3b10083fc7b3db4a72c202
{
"id": "0fbb616d-ec40-4bbd-a89f-591d9d404630",
"evento": "comprobante.aceptado",
"ruc": "20777666551",
"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"
}| Header / campo | Qué es |
|---|---|
| X-FactuSmart-Evento | El evento (igual que evento en el cuerpo). |
| X-FactuSmart-Id | Igual al id del cuerpo. Es el mismo en todos los reintentos. |
| X-FactuSmart-Intento | Número de intento, de 1 a 6. |
| X-FactuSmart-Firma | HMAC-SHA256 en hexadecimal del cuerpo crudo, firmado con tu secreto whsec_…. |
| id | Identificador único del aviso: guárdalo para no procesar dos veces el mismo aviso. |
| ruc / external_id | RUC emisor y el external_id que te devolvió POST /documents. |
| serie_numero / tipo_documento | Ej. F001-123. Tipo: 01 factura, 03 boleta, 07 nota de crédito, 08 nota de débito. |
| estado / estado_descripcion | Estado actual: 05, 07, 09 u 11. |
| aceptado_por_sunat | true solo si el estado es 05. |
| entorno | prueba (beta SUNAT, sin validez fiscal) o produccion. |
| sunat.codigo / sunat.descripcion | Lo que respondió SUNAT. Pueden venir en null. |
| fecha | Cuándo ocurrió el cambio (ISO 8601, hora de Lima). |
Verificar la firma (con los SDK)
Antes de confiar en un aviso, verifica que X-FactuSmart-Firma coincide. Los tres SDK traen la función lista (compara en tiempo constante). Si no coincide, responde 401 e ignora el aviso.
import express from 'express'
import { FactuSmart } from 'factusmart'
const app = express()
// express.raw: necesitas el cuerpo CRUDO (Buffer), no el JSON ya parseado
app.post('/webhooks/factusmart', express.raw({ type: 'application/json' }), (req, res) => {
const firma = req.get('X-FactuSmart-Firma') || ''
if (!FactuSmart.verificarFirmaWebhook(req.body, firma, process.env.FACTUSMART_WEBHOOK_SECRET)) {
return res.sendStatus(401)
}
const aviso = JSON.parse(req.body.toString('utf8'))
// Deduplica por aviso.id (es el mismo en todos los reintentos) y procesa después
res.sendStatus(200)
})Usa el cuerpo CRUDO, tal cual llegó
La firma se calcula sobre los bytes exactos que enviamos. Si tu framework ya convirtió el cuerpo a JSON y lo vuelves a serializar (JSON.stringify, json_encode, json.dumps), cambian espacios u orden y la firma no va a coincidir. Por eso en Express se usa express.raw, en PHP php://input y en Flask request.get_data().
¿Sin SDK? Es un HMAC-SHA256 estándar:
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))Reintentos
Si tu servidor no responde 2xx (error, más de 8 s, conexión rechazada), reintentamos con espera creciente. Son 6 intentos en total:
| Intento | Cuándo |
|---|---|
| 1 | Al momento del cambio de estado |
| 2 | 1 minuto después |
| 3 | 5 minutos después |
| 4 | 30 minutos después |
| 5 | 2 horas después |
| 6 | 6 horas después |
- Si fallan los 6, el aviso queda como Error en el portal. Siempre puedes consultar el estado con
GET /documents/{external_id}. - Un mismo aviso puede llegarte más de una vez (por ejemplo, si respondiste tarde). Deduplica por
id: es el mismo en todos los reintentos. - Si cambias la URL mientras hay reintentos pendientes, los siguientes van a la URL nueva.
- En el portal, Webhooks → Últimas entregas muestra los 50 avisos más recientes: fecha, evento, comprobante, código HTTP que respondió tu servidor y resultado (Entregado / Reintentando / Error, con el motivo). El historial se guarda 30 días.
Seguridad
- Solo URLs
https://públicas: no se aceptanlocalhost, IPs privadas (10.x, 192.168.x, 172.16–31.x) ni direcciones de metadatos de nube. - No seguimos redirecciones: un
301/302cuenta como fallo. Usa la URL final. - Puedes regenerar el secreto cuando quieras desde el portal; los avisos siguientes se firman con el nuevo.
- Guarda los
idya procesados: así un aviso repetido (o uno viejo reenviado por alguien que lo capturó) no se procesa dos veces. - Ojo si filtras por antigüedad: un reintento lleva la misma
fechaque el primer envío y el último llega unas 8 h 36 min después. Una ventana corta (de minutos) te haría descartar reintentos legítimos.
Guías de remisión electrónica (GRE 2.0)
La API emite guías de remisión (traslado de mercadería). Es asíncrona: se envía a SUNAT (devuelve un external_id) y consultas el resultado con el endpoint de estado. En un RUC en producción cuesta 1 crédito al enviarse correctamente; en un RUC en modo prueba es gratis (cuenta para el tope de 200 pruebas diarias). La respuesta trae credito_cobrado y, en modo prueba, modo_demo y pruebas_restantes_hoy.
Requisito
La GRE 2.0 usa una API de SUNAT distinta a la de comprobantes, con credenciales propias de guías (aparte del certificado digital). El RUC emisor debe tenerlas configuradas en el panel antes de emitir guías. Además, en guías sí debes enviar serie_documento (TA01 para guía del remitente, VA01 para guía del transportista).
Ejemplo — guía del remitente (transporte privado):
{
"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
}
]
}Campos clave:
codigo_tipo_documento:09guía del remitente (la que emite quien traslada),31guía del transportista.codigo_motivo_traslado:01venta,02compra,04traslado entre establecimientos,18traslado emisor itinerante, entre otros (catálogo 20 de SUNAT).codigo_modo_transporte:01público (transportista) o02privado (vehículo y chofer propios, como el ejemplo).datos_del_emisor.codigo_del_domicilio_fiscal: el código de tu establecimiento (0000el principal).peso_total+unidad_peso_total(KGM),direccion_partidaydireccion_llegadacon su ubigeo.- Los ítems de una guía no llevan precio — solo
descripcion,unidad_de_medidaycantidad.
Transporte público (modo 01)
Cuando el traslado lo hace una empresa de transporte, usa "codigo_modo_transporte": "01" y, en vez de vehiculo y chofer, envía el bloque transportista (el resto del payload es igual):
"codigo_modo_transporte": "01",
"transportista": {
"codigo_tipo_documento_identidad": "6",
"numero_documento": "20131312955",
"apellidos_y_nombres_o_razon_social": "TRANSPORTES RAPIDOS S.A.C.",
"numero_mtc": "1234567"
}Vehículo ligero categoría M1 o L (auto o moto)
Si trasladas en un vehículo M1 (auto de pasajeros) o L (moto), SUNAT permite una guía simplificada: no declaras conductor ni datos del vehículo, solo marcas el indicador y la placa. Usa modo privado (02) con:
"codigo_modo_transporte": "02",
"es_traslado_m1l": true,
"numero_de_placa_m1l": "MOT123"En este caso no envíes los bloques vehiculo ni chofer.
Consultar saldo
Úsalo para anticipar un corte de servicio, no para reaccionar a él. Si te quedas sin créditos a mitad de una venta, la emisión responde 402 y esa venta no genera comprobante — mejor evitarlo que descubrirlo con un cliente esperando en caja.
Casos típicos de uso:
- Mostrar el saldo en tu propio panel, junto al de tu negocio, para que el administrador lo vea sin entrar a FactuSmart.
- Alertarte automáticamente (correo, Slack, lo que uses) cuando
creditos_disponiblesbaje de un umbral que definas — por ejemplo, si emites ~50 comprobantes al día, avisa bajo 200. - Decidir si subes de plan o pides créditos extra antes de que ocurra un
402en producción.
{
"success": true,
"cuenta": "Mi Integración",
"creditos_disponibles": 4830,
"creditos_recargados_total": 5000,
"creditos_consumidos": 170,
"rucs": [ { "ruc": "20100070970", "nombre": "..." } ]
}No consume crédito. Es gratis consultarlo tantas veces como quieras.
Códigos de error
Una respuesta 200/201 es éxito (y aun así revisa aceptado_por_sunat, ver Si no sale "Aceptado"). Toda respuesta de error, en cualquier endpoint, trae siempre el mismo formato:
{
"success": false,
"message": "El campo es obligatorio.",
"codigo": "DATOS_INVALIDOS",
"reintentable": false,
"campos": {
"datos_del_cliente_o_receptor.numero_documento": ["El campo es obligatorio."]
}
}codigo: estable, en MAYÚSCULAS. Decide con este campo.reintentable:truesolo si repetir la misma petición más tarde puede funcionar. Si te llega uncodigoque no conoces, guíate por este campo.campos: solo en errores de validación, con el detalle por campo ({"campo": ["mensaje"]}).message: texto para personas. Puede cambiar de redacción: no lo compares en tu código.
| codigo | HTTP | Reintentable | Cuándo |
|---|---|---|---|
| NO_AUTENTICADO | 401 | No | API key faltante, inválida o de otro servidor. |
| PLAN_VENCIDO | 402 | No | Tu plan venció. Renuévalo desde el portal. Corta todo menos /ping, /account y /rucs (también /calcular y /catalogos). |
| SIN_CREDITOS | 402 | No | Te quedaste sin créditos: no se emitió nada. Trae creditos_restantes: 0. |
| LIMITE_PRUEBAS_DIARIO | 429 | No | Llegaste a las 200 operaciones de prueba del día en RUCs en modo prueba (emisiones + guías + anulaciones, todas tus empresas juntas). Se reinicia a medianoche hora de Perú. Es el único 429 que NO se reintenta. Trae pruebas_restantes_hoy: 0, limite_pruebas_diario: 200 y creditos_restantes. |
| RUC_NO_AUTORIZADO | 403 | No | El RUC no pertenece a tu cuenta. |
| RUC_DESACTIVADO | 403 | No | El RUC está desactivado. Reactívalo con 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 | Acceso denegado por otro motivo (código genérico de 403). |
| NO_ENCONTRADO | 404 | No | No existe el comprobante, la guía o la anulación, o el archivo PDF/XML/CDR todavía no existe. |
| RUTA_NO_ENCONTRADA | 404 | No | La URL no corresponde a ningún endpoint. Revisa la ruta. |
| METODO_NO_PERMITIDO | 405 | No | El endpoint existe pero no con ese método (ej. GET en vez de POST). |
| EMISION_EN_CURSO | 409 | Sí | Ya se está procesando una emisión con esa misma Idempotency-Key. Reintenta con la misma clave en unos segundos. |
| CONFLICTO | 409 | No | Conflicto distinto de una emisión en curso (código genérico de 409). |
| DATOS_INVALIDOS | 422 | No | Datos inválidos. Mira campos para el detalle por campo. También se usa al llegar al tope de RUC. |
| RUC_REQUERIDO | 422 | No | Falta indicar el RUC emisor (tu cuenta tiene más de uno). En /catalogos: tu cuenta todavía no tiene ningún RUC. |
| ANULACION_MIXTA | 422 | No | Mezclaste facturas (o sus notas) con boletas (o sus notas) en la misma anulación. Haz una petición por tipo. |
| RUC_SIN_TENANT | 422 | No | El RUC está en tu cuenta pero no tiene su espacio (tenant) asociado. Contáctanos. |
| CERTIFICADO_NO_CONFIGURADO | 422 | No | El RUC no tiene certificado digital (ni PSE) configurado para firmar. |
| ERROR_EMISION | 422 | No | No se pudo generar el comprobante. El crédito se devuelve. |
| RECHAZADO_SUNAT | 422 | No | SUNAT rechazó el comprobante al reenviarlo con /reenviar (queda en estado 09). Corrige y emite uno nuevo. |
| REENVIO_FALLIDO | 422 | No | El reenvío no se pudo completar. |
| SUNAT_NO_DISPONIBLE | 422 o 5xx | Sí | SUNAT no respondió o está caída. Reintenta más tarde. |
| LIMITE_PETICIONES | 429 | Sí | Superaste el límite de peticiones. Espera los segundos del header Retry-After. |
| ERROR_INTERNO | 5xx | Sí | Error de nuestro lado. Reintenta con espera creciente. |
| PDF_NO_GENERADO | 500 | Sí | Pediste el PDF con ?formato= y el generador falló dos veces seguidas. Reintenta en unos segundos. |
| LIMITE_ESTABLECIMIENTOS | 403 | No | POST /establecimientos: el plan del RUC no permite más locales. Amplía el plan desde el sistema. |
| CATALOGO_NO_DISPONIBLE | 503 | No | GET /catalogos/productos-sunat: el catálogo de códigos de producto SUNAT aún no está cargado en el servidor. Reintentar no lo arregla: emite sin el código (es opcional). |
Hay dos límites y se aplican los dos a la vez: 120 peticiones por minuto por API key y 60 peticiones por minuto por IP (este se cuenta antes de revisar la API key, así que si todo sale desde un solo servidor, en la práctica tu tope es 60/min). Si superas cualquiera, la API responde 429 con el header Retry-After (segundos a esperar), y el mismo dato en retry_after del JSON. Espera ese tiempo y reintenta.
Excepción: el 429 con codigo: LIMITE_PRUEBAS_DIARIO (tope de 200 pruebas diarias en modo prueba) trae reintentable: false: no se reintenta, se reinicia a la medianoche hora de Perú.
Catálogos SUNAT
Los códigos que pide SUNAT (unidades de medida, afectación del IGV, motivos de nota, etc.), para que no los copies a mano. No consumen crédito. El primero lista los catálogos disponibles; el segundo devuelve las filas de uno.
{nombre}: el nombre o el número SUNAT./catalogos/07es lo mismo que/catalogos/afectacion-igv.?q=: filtra por código o descripción (máximo 100 caracteres).- Opcional
X-RUC(header) o?ruc=: lee el catálogo de ese RUC, porque cada empresa puede desactivar filas. Sin RUC se usa el primer RUC de tu cuenta. - Cada fila trae
codigo,descripcionyactivo(más los extras de la tabla). Usa solo las filas conactivo: true. - Puedes guardarlos en caché: se actualizan cada 12 horas.
| Nombre | N.° SUNAT | Contenido | Campos extra |
|---|---|---|---|
| tipos-documento | 01 | Tipos de comprobante | abreviatura |
| monedas | 02 | Monedas | simbolo |
| unidades | 03 | Unidades de medida | simbolo |
| documentos-identidad | 06 | Documentos de identidad | — |
| afectacion-igv | 07 | Tipos de 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 |
El código de producto SUNAT (UNSPSC, catálogo 25) tiene más de 50 mil códigos y no se lista entero: búscalo con el buscador de abajo.
Ejemplo
curl "https://TU_URL_BASE/api/v1/issuer/catalogos/unidades?q=kilo" \
-H "X-API-Key: TU_API_KEY"
{
"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" }
]
}Errores: 404 NO_ENCONTRADO si el catálogo no existe (trae catalogos_disponibles); 422 RUC_REQUERIDO si tu cuenta todavía no tiene ningún RUC; 402 PLAN_VENCIDO si tu plan venció.
Buscar el código de producto SUNAT
Busca el código de 8 dígitos (UNSPSC v14, catálogo 25) que va en items[].codigo_producto_sunat. No consume crédito y no necesita RUC. Los códigos salen del Excel oficial que publica SUNAT.
?q=laptop: texto (mínimo 3 letras), sin importar tildes ni mayúsculas. Primero salen los productos cuyo nombre contiene lo que buscaste.?q=43211503o?q=4321: código exacto o prefijo (2 a 8 dígitos) para recorrer una familia o clase.?limite=: de 1 a 50 (20 por defecto). Si vienehay_mas: true, afina la búsqueda.?nivel=opcional:segmento,familia,claseoproducto. En el comprobante usa uno de nivelproducto.- Es opcional: SUNAT no lo exige en factura ni boleta. Envíalo solo si tu caso lo pide.
Ejemplo
curl "https://TU_URL_BASE/api/v1/issuer/catalogos/productos-sunat?q=computador&limite=3" \
-H "X-API-Key: TU_API_KEY"
{
"success": true,
"catalogo": "productos-sunat",
"catalogo_sunat": "25",
"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" }
]
}Errores: 422 DATOS_INVALIDOS si falta q, es muy corto o limite está fuera de 1 a 50 (detalle en campos); 503 CATALOGO_NO_DISPONIBLE si el catálogo aún no está cargado en el servidor (reintentar no lo arregla: emite sin el código o envía uno que ya conozcas).
Tipos de comprobante
| codigo_tipo_documento | Comprobante |
|---|---|
| 01 | Factura |
| 03 | Boleta de venta |
| 07 | Nota de crédito |
| 08 | Nota de débito |
| 09 | Guía de remisión — remitente |
| 31 | Guía de remisión — transportista |
¿Listo para integrar?
Crea tu cuenta gratis y recibe tu API key al instante, con 50 créditos de regalo para empezar a emitir en producción. El modo prueba es gratis y no los consume.
Crear cuenta gratis