Facta — Documentación de integración (SUNAT · Perú)
Facta es un facturador electrónico SUNAT (SEE - Del Contribuyente) con una API REST server-to-server. Tu sistema envía la venta a Facta; Facta firma el XML con tu certificado, lo envía a SUNAT, interpreta la respuesta (CDR) y te devuelve el estado. Este documento es completo y autocontenido: cubre autenticación, emisión, idempotencia, estados, webhooks, anulaciones, manejo de errores, la referencia de todos los endpoints y ejemplos en curl, Node.js y PHP.
- Base URL (producción):
https://sunat.lexarsolutions.com - Autenticación: header
Authorization: Bearer <api_key>(formatonf_live_…) - Formato: JSON en request y response. Todas las rutas bajo
/api/v1/*(salvo/api/v1/health) exigen API key. - Documentación legible por máquina (este archivo):
GET /llms.txt - Documentación legible por humanos:
GET /docs
1. Modelo mental
Tu sistema nunca habla con SUNAT directamente. Habla con Facta:
Tu sistema ──HTTPS + API key──▸ Facta (API REST) ──firma + envío──▸ SUNAT
(ecommerce, · cifra cert/SOL (beta o
ERP, POS…) ◂──── webhook ──── · reintentos + estado producción)
comprobante.updated
Conceptos que aparecen en toda la API:
- Tenant: tu organización dentro de Facta. Todo lo que ves y operas queda aislado a tu tenant.
- Emisor: la empresa que factura (RUC + certificado
.p12+ credenciales SOL). Puedes tener varios. Se identifica conemisorId. - API key: credencial
nf_live_…de un "consumidor". Identifica a tu sistema y lo ata a un tenant. - Comprobante: una factura, boleta o nota. Tiene
id, número (F001-123) yestado. - externalRef: tu identificador de la operación (p. ej. el
orderId). Es la clave de idempotencia.
La emisión es síncrona: el POST espera la respuesta de SUNAT. Los procesos que SUNAT resuelve
por ticket (anulaciones) y los reintentos ante caídas son asíncronos — ahí entran los webhooks.
2. Requisitos previos
- Un emisor configurado en el panel (RUC, certificado
.p12, usuario/clave SOL y series). En beta el SOL universal esMODDATOS/moddatos: mientras el emisor esta en BETA, NextFact envia siempre ese usuario aunque se haya guardado la SOL real, que solo viaja a produccion. - Una API key de consumidor (créala en
/dashboard/consumidores). Se muestra una sola vez; guárdala como secreto en tu backend. - El
emisorId, que obtienes listando tus emisores (GET /api/v1/emisores).
Cada emisor tiene un ambiente: BETA (pruebas, sin validez fiscal) o PRODUCCION. Integra y prueba todo en BETA; pasar a producción exige SOL real, certificado vigente y confirmación explícita. La API es idéntica en ambos ambientes.
3. La empresa emisora: cómo tu sistema la descubre y la elige
El API key te ata a tu organización, pero no elige por sí solo con cuál de tus RUCs facturar:
eso lo decide tu sistema enviando emisorId en cada emisión. Tu sistema no adivina ese id —
lo descubre con un endpoint.
Descubre tus emisores
GET /api/v1/emisores devuelve los emisores (empresas) de tu organización, cada uno con su id.
El API key ya te identifica, así que solo ves los tuyos:
{
"success": true,
"data": [
{
"id": "cmr7sneym00017gq167k2pxf5",
"ruc": "20123456789",
"razonSocial": "EMPRESA A SAC",
"environment": "PRODUCCION",
"active": true,
"series": [{ "tipo": "FACTURA", "serie": "F001", "correlativo": 42 }]
}
]
}
Guarda el id del emisor con el que vas a facturar: es estable (no cambia). Luego lo mandas en
cada POST /api/v1/comprobantes.
Una sola empresa
Pides la lista una vez, guardas el único emisorId en tu configuración y lo mandas siempre igual.
(El emisor se da de alta desde el panel en /dashboard/emisores o con POST /api/v1/emisores.)
Varias empresas (multiempresa)
Tu organización puede tener varios emisores. El patrón es el de cualquier plugin de facturación:
- El usuario pega su API key en tu sistema/CMS.
- Tu sistema llama
GET /api/v1/emisoresy muestra un selector "Empresa emisora" (razonSocial (RUC)). - El usuario elige; guardas ese
emisorId(uno por tienda/sucursal si aplica). - En cada venta mandas el
emisorIdcorrespondiente.
Ciclo de vida de la integración:
pegar API key → GET /emisores (descubrir) → elegir empresa (guardar emisorId) → POST /comprobantes { emisorId, … }
Aislamiento. Dentro de una misma organización, cualquier API key puede emitir por cualquiera de sus emisores. Si quieres que dos empresas queden totalmente separadas (que cada key solo pueda facturar por la suya), crea una organización por empresa, cada una con su propio API key.
4. Autenticación
Toda ruta /api/v1/* (salvo /api/v1/health) exige el API key como Bearer token:
Authorization: Bearer nf_live_xxxxxxxxxxxxxxxxxxxxxxxx
También se acepta el header X-Api-Key: nf_live_….
Seguridad — solo server-to-server. El API key permite emitir documentos fiscales con tu RUC. Nunca lo pongas en el navegador, una app móvil ni en frontend. Llama a Facta siempre desde tu backend. Si se filtra, regenéralo en el panel: el anterior queda inválido al instante.
Cada consumidor pertenece a un tenant; solo ve y opera sus emisores y comprobantes. Un id de otro
tenant responde 404.
5. Formato de respuesta
Éxito:
{ "success": true, "data": { ... } }
Error (4xx / 5xx):
{ "success": false, "error": "mensaje legible", "detail": { ... } }
detail aparece en errores de validación de esquema (HTTP 422) con el desglose de campos.
6. Emisión de comprobantes, paso a paso
Paso 1 — Emite el comprobante
POST /api/v1/comprobantes con tu externalRef (el id de la orden en tu sistema).
Reglas de negocio:
- Factura → cliente con RUC válido (11 dígitos).
- Boleta → DNI / CE / pasaporte / sin documento. Una boleta > S/ 700 sin documento exige DNI.
Cuerpo del request:
{
"emisorId": "cmr7sneym00017gq167k2pxf5",
"tipo": "FACTURA",
"externalRef": "orden-1024",
"moneda": "PEN",
"preciosIncluyenIgv": true,
"cliente": {
"tipoDoc": "RUC",
"numDoc": "20123456789",
"razonSocial": "CLIENTE SAC",
"direccion": "Av. Ejemplo 123",
"email": "cliente@correo.pe"
},
"items": [
{
"descripcion": "Servicio de consultoría",
"cantidad": 1,
"precio": 100.00,
"afectacion": "GRAVADO",
"descuento": 0,
"unidad": "NIU",
"codigo": "SERV-01"
}
],
"observaciones": "Texto libre opcional"
}
Campos:
| Campo | Tipo | Req. | Notas |
|---|---|---|---|
emisorId |
string | sí | Id del emisor (de GET /api/v1/emisores). |
tipo |
enum | sí | FACTURA o BOLETA. |
externalRef |
string | no* | Tu id de operación. Recomendado: es la clave de idempotencia. |
moneda |
string | no | Default PEN. También USD, EUR. |
preciosIncluyenIgv |
bool | no | Default true. Si true, precio ya incluye IGV. |
cliente.tipoDoc |
enum | sí | DNI | RUC | CE | PASAPORTE | SIN_DOC. |
cliente.numDoc |
string | según tipo | Requerido salvo SIN_DOC. |
cliente.razonSocial |
string | sí | Nombre/razón social. |
cliente.direccion |
string | no | |
cliente.email |
string | no | Para el envío por correo. |
items[].descripcion |
string | sí | |
items[].cantidad |
number | sí | > 0. |
items[].precio |
number | sí | Precio unitario (≥ 0). |
items[].afectacion |
enum | no | GRAVADO (default) | EXONERADO | INAFECTO. |
items[].descuento |
number | no | Descuento del ítem (≥ 0). |
items[].unidad |
string | no | Unidad SUNAT (default NIU). |
items[].codigo |
string | no | Tu SKU/código de producto. |
serie |
string | no | 4 caracteres [A-Z0-9]. Si se omite, usa la serie por defecto del tipo. |
observaciones |
string | no | Texto libre opcional. |
detraccion.codigoBien |
string | no (1) | Catálogo 54, 3 dígitos (p. ej. 037 demás servicios gravados). |
detraccion.porcentaje |
number | no (1) | Porcentaje de detracción (p. ej. 12). |
detraccion.cuenta |
string | no (1) | Cuenta de detracciones del emisor en el Banco de la Nación. |
detraccion.medioPago |
string | no | Catálogo 59. Default 001 (depósito en cuenta). |
detraccion.tipoCambio |
number | según moneda | Obligatorio si moneda no es PEN: SUNAT pide el monto de la detracción en soles. |
formaPago |
enum | no | CONTADO (default) | CREDITO. |
cuotas[].monto |
number | con CREDITO |
Monto de la cuota, en la moneda del comprobante. |
cuotas[].fechaPago |
string | con CREDITO |
AAAA-MM-DD, posterior a la fecha de emisión. De 1 a 60 cuotas. |
(1) detraccion, formaPago y cuotas solo van en facturas. Si mandas detraccion, sus tres campos
codigoBien, porcentaje y cuenta son obligatorios.
Detracción y pago a crédito (solo facturas)
Facta calcula el monto de la detracción (total × porcentaje / 100, en soles) y lo imprime en el XML y en el
PDF junto con la leyenda y la cuenta del Banco de la Nación. A crédito, las cuotas deben sumar el total menos
la detracción (en la moneda del comprobante); si no cuadran, la API responde 400 con la suma esperada.
{
"tipo": "FACTURA",
"moneda": "PEN",
"detraccion": { "codigoBien": "037", "porcentaje": 12, "cuenta": "00-000-123456" },
"formaPago": "CREDITO",
"cuotas": [
{ "monto": 528.00, "fechaPago": "2026-11-30" },
{ "monto": 528.00, "fechaPago": "2026-12-31" }
]
}
(Con un total de S/ 1200.00: detracción S/ 144.00 y S/ 1056.00 en dos cuotas. Se omiten cliente e items.)
Respuesta 201:
{
"success": true,
"data": {
"id": "cmr8l6ygt00017g5ru6f9wrl8",
"tipo": "FACTURA",
"serie": "F001",
"correlativo": 123,
"numero": "F001-123",
"estado": "ACEPTADO",
"moneda": "PEN",
"total": "118.00",
"igv": "18.00",
"externalRef": "orden-1024",
"environment": "PRODUCCION",
"sunatCode": "0",
"sunatMessage": "La Factura numero F001-123 ha sido aceptada",
"requiereAtencion": false,
"xmlUrl": "https://…/F001-123.xml",
"cdrUrl": "https://…/F001-123-cdr.zip",
"hashCpe": "aA1b2C…",
"emitidoAt": "2026-07-06T02:14:19.134Z",
"createdAt": "2026-07-06T02:14:18.900Z"
}
}
Paso 2 — Interpreta el estado
- ACEPTADO — SUNAT lo aceptó. Guarda
id,numero,cdrUrl. Listo. - OBSERVADO — aceptado con observaciones. Es válido; revisa
sunatMessage. - RECHAZADO — SUNAT lo rechazó (dato inválido). Corrige y reemite: un
externalRefrechazado puede reusarse. - PENDIENTE — fallo transitorio (red / SUNAT caído). No se perdió: Facta reintenta solo.
- PENDIENTE con
requiereAtencion=true— SUNAT no recibió el comprobante por una causa que no se arregla sola: un código01xxde acceso (usuario o clave SOL, perfil del usuario secundario) o de envío.sunatMessageempieza con «SUNAT no recibió el comprobante (código X: …)». Facta no lo reintenta solo: corrige la causa y llama aPOST /api/v1/comprobantes/:id/retry; sale con el mismo número. Los01xxde servicio (0100,0109,0130–0138) sí se reintentan solos.
Paso 3 — Si quedó PENDIENTE, deja que el reconciliador trabaje
No martilles con reintentos manuales. Facta reintenta el mismo número con backoff exponencial + jitter
y un circuit breaker cuando SUNAT está caído. Entérate del desenlace por webhook (recomendado) o por
polling. Para forzar un reintento puntual: POST /api/v1/comprobantes/:id/retry.
Paso 4 — Persiste el enlace
Guarda el id de Facta y el numero junto a tu orden. Con eso reconcilias, reimprimes y consultas después
sin depender de tu request original.
Paso 5 — Entrega el comprobante
GET /api/v1/comprobantes/:id/pdf (A4) o ?formato=ticket (80 mm). Lleva el QR oficial de SUNAT. También
puedes enviarlo por correo (POST /api/v1/comprobantes/:id/email). El XML firmado y el CDR para guardarlos
en tu sistema: GET /api/v1/comprobantes/:id/archivo?f=xml o ?f=cdr.
Quien recibe el comprobante puede consultarlo y bajar su PDF, XML y CDR sin cuenta en la página pública
/consulta (RUC del emisor, tipo, serie, número, fecha y total). Si la instancia tiene su URL pública
configurada, el pie del PDF lleva el enlace; en tus correos puedes poner https://<tu-instancia>/consulta.
7. Idempotencia
Manda siempre un externalRef igual al identificador de la operación en tu sistema. Con él, Facta garantiza
que una orden = un comprobante, aunque tu request se repita:
- Un reintento de red con el mismo
externalRefdevuelve el mismo comprobante, no uno nuevo. - Vale ante requests concurrentes: un lock por
(emisor, externalRef)los serializa; solo se crea uno. - Si el comprobante previo quedó RECHAZADO, se permite reemitir con el mismo
externalRef.
Patrón recomendado. La emisión es síncrona y puede tardar. Usa un timeout generoso (~40 s). Si tu request
expira sin respuesta, no asumas fracaso: consulta por externalRef (GET /api/v1/comprobantes?externalRef=…)
antes de decidir. Reintentar el POST también es seguro.
8. Máquina de estados
SUNAT responde
PENDIENTE ──────────────────────────────▸ ACEPTADO
│ ▲ OBSERVADO ──anular──▸ ANULADO
│ │ reintento (backoff) RECHAZADO
│ └───────────────────────────┐ │
└── agota reintentos ─▸ requiereAtencion └── reemitir (mismo externalRef)
- PENDIENTE: aún no confirmado por SUNAT (transitorio). Reintentable, nunca se pierde. Si agota reintentos queda con
requiereAtencion=true(visible en el panel). Con un código01xxde acceso o de envío queda así de inmediato y recibes el webhook (ver Paso 2). - ACEPTADO: válido y aceptado. Estado final feliz.
- OBSERVADO: aceptado con observaciones — es válido. Revisa el mensaje.
- RECHAZADO: rechazado por dato inválido. Corrige y reemite.
- ANULADO: dado de baja tras una anulación aceptada por SUNAT (proceso por ticket).
Solo un comprobante ACEPTADO / OBSERVADO puede anularse o recibir una nota. La baja solo vale hasta 7 días
después de la emisión y sin notas vivas; después, nota de crédito 01.
9. Webhooks (recomendado para el estado asíncrono)
Configura una URL de webhook para tu consumidor en el panel (/dashboard/consumidores). Facta te enviará un
POST firmado cuando un comprobante cambie a un estado resuelto (ACEPTADO/OBSERVADO/RECHAZADO), quede marcado
para atención, o pase a ANULADO.
En producción la URL debe ser https con un dominio público: no se aceptan localhost, IPs internas o
privadas, nombres sin dominio (intranet, *.local, *.internal) ni usuario y clave dentro de la URL. Facta
vuelve a comprobar el destino (DNS) en cada envío y no sigue redirecciones: un 3xx cuenta como fallo.
Payload (Content-Type: application/json):
{
"event": "comprobante.updated",
"id": "cmr8l6ygt00017g5ru6f9wrl8",
"ruc": "20123456789",
"tipo": "FACTURA",
"numero": "F001-123",
"estado": "ACEPTADO",
"requiereAtencion": false,
"sunatCode": "0",
"sunatMessage": "…",
"externalRef": "orden-1024",
"total": "118.00",
"ts": "2026-07-06T02:14:19.134Z"
}
Verifica la firma. Cada entrega trae el header X-Facta-Signature: un HMAC-SHA256 del cuerpo crudo
(raw body) usando el secreto whsec_… que te dio el panel. Compáralo en tiempo constante antes de confiar en
el payload.
Contrato del receptor:
- Responde
2xxrápido (encola el trabajo pesado). - Un no-2xx o un timeout se reintenta con backoff hasta 10 veces; luego la entrega queda
FALLIDOy visible en/dashboard/webhookspara reintento manual. - Haz tu handler idempotente: una entrega puede repetirse (at-least-once). Deduplica por
id+estado.
10. Polling (alternativa / respaldo)
Si no puedes exponer un webhook, consulta el estado. Ideal tras un PENDIENTE o para reconciliar:
GET /api/v1/comprobantes/:id— detalle por id.GET /api/v1/comprobantes?externalRef=orden-1024— buscar por tu referencia.
Consulta con un intervalo razonable (30–60 s) hasta ver un estado terminal. El reconciliador interno ya empuja el comprobante hacia su resolución; no hace falta un cron agresivo.
11. Correcciones y anulaciones
| Caso | Qué usar | Endpoint |
|---|---|---|
| Anular una factura | Nota de crédito (cat. 09) o baja (RA) | POST /api/v1/comprobantes/:id/nota-credito |
| Anular una boleta | Baja por Resumen Diario (RC estado 3) | POST /api/v1/comprobantes/:id/anular |
| Corregir o devolver una boleta | Nota de crédito en serie B (p. ej. BC01): el emisor necesita una serie de NOTA_CREDITO que empiece con B |
POST /api/v1/comprobantes/:id/nota-credito |
| Aumentar el importe (mora, penalidad) | Nota de débito (cat. 10) | POST /api/v1/comprobantes/:id/nota-debito |
| Consultar el ticket de una baja | Consulta el Resumen (o espera el reconciliador) | POST /api/v1/resumenes/:id/consultar |
La anulación es asíncrona. POST /api/v1/comprobantes/:id/anular devuelve un Resumen con un ticket
en estado EN_PROCESO. SUNAT confirma la baja después; cuando lo hace, el comprobante pasa a ANULADO y —si
tienes webhook— recibes el aviso. El reconciliador interno consulta los tickets pendientes por ti.
Nota de crédito / débito — motivos:
{ "codigoMotivo": "01", "motivo": "Anulación de la operación" }
- Cat. 09 (nota de crédito):
01anulación de la operación,02anulación por error en el RUC,03corrección por error en la descripción,04descuento global,05descuento por ítem,06devolución total,07devolución por ítem… - Cat. 10 (nota de débito):
01intereses por mora,02aumento en el valor,03penalidad u otros conceptos.
12. Manejo de errores
| HTTP | Significado | Qué hacer |
|---|---|---|
| 200 / 201 | OK | Continúa. |
| 400 | Regla de negocio (p. ej. factura sin RUC) | Corrige el dato. No reintentar igual. |
| 401 | API key faltante o inválida | Revisa el header Authorization. |
| 403 | Sin permiso / límite del plan | Revisa el plan o los permisos del consumidor. |
| 404 | No encontrado (o de otro tenant) | Verifica el id / emisorId. |
| 409 | Conflicto (ya existe una nota / baja en curso, o un emisor con ese RUC) | Ya está en curso; consulta el estado. Con el emisor: usa PATCH /api/v1/emisores/:id. |
| 422 | Esquema inválido | Corrige según detail. |
| 5xx | Error interno / gateway | Reintentable. En emisión suele reflejarse como PENDIENTE. |
Regla práctica: 4xx = arregla tú el request (salvo 409/timeout); 5xx y fallos de red = transitorios, y
en emisión Facta ya los convierte en PENDIENTE y los reintenta.
13. Referencia de endpoints
Base: https://sunat.lexarsolutions.com. Todos exigen Authorization: Bearer <api_key> salvo donde se indica.
Salud
GET /api/v1/health— liveness de la app (sin auth).{ success, service, ts }.GET /api/v1/health/gateway— sondea el gateway PHP interno y expone el estado de los circuit breakers de SUNAT.200si responde y Greenter cargó;503si no.
Emisores
GET /api/v1/emisores— lista tus emisores (credenciales enmascaradas, series).POST /api/v1/emisores— crea un emisor (valida el.p12antes de guardar; respeta el límite del plan). Nace en BETA: mandar"environment": "PRODUCCION"da400; para pasar a producción usa/environment. Un RUC que ya tienes da409, y un certificado emitido a otro RUC,400.PATCH /api/v1/emisores/:id— renueva el certificado, cambia la clave SOL, corrige datos fiscales (razonSocial,nombreComercial,direccionFiscal,ubigeo) o agrega series. Solo se cambia lo que mandas; un certificado nuevo se valida igual que en el alta.POST /api/v1/emisores/:id/validate-cert— revalida el certificado.{ valid, message, validTo, subject, rucs, rucCoincide, rucAviso }:rucsson los RUC que figuran en el certificado,rucCoincidedice si el del emisor está entre ellos (nullsi el certificado no trae RUC) yrucAvisoexplica el problema cuando no coincide.POST /api/v1/emisores/:id/environment— cambia el ambiente. A producción:{ "environment": "PRODUCCION", "confirm": true }(exige SOL real y cert vigente).
Alta de emisor:
{
"ruc": "20123456789",
"razonSocial": "MI EMPRESA SAC",
"nombreComercial": "MI EMPRESA",
"direccionFiscal": "Av. Siempre Viva 123",
"ubigeo": "150101",
"environment": "BETA",
"certificate": { "pfxBase64": "<.p12 en base64>", "password": "clave-cert" },
"sol": { "usuario": "MODDATOS", "clave": "moddatos" },
"series": [
{ "tipo": "FACTURA", "serie": "F001", "correlativoInicial": 0 },
{ "tipo": "BOLETA", "serie": "B001", "correlativoInicial": 0 },
{ "tipo": "NOTA_CREDITO", "serie": "FC01", "correlativoInicial": 0 },
{ "tipo": "NOTA_DEBITO", "serie": "FD01", "correlativoInicial": 0 }
]
}
Comprobantes
POST /api/v1/comprobantes— emite factura/boleta (síncrono; idempotente porexternalRef).GET /api/v1/comprobantes— lista (máx 100). Filtros:?emisorId=,?estado=,?externalRef=.GET /api/v1/comprobantes/:id— detalle.GET /api/v1/comprobantes/:id/pdf[?formato=ticket]— PDF con QR (A4 por defecto;ticket= 80 mm). Devuelveapplication/pdf.GET /api/v1/comprobantes/:id/archivo?f=xml|cdr— XML firmado o CDR (R-...zip) con el nombre de SUNAT; sirve aunquexmlUrl/cdrUrlseannull. Sin archivo todavía: 404archivoNoDisponible.POST /api/v1/comprobantes/:id/nota-credito—{ codigoMotivo, motivo?, externalRef?, items?, preciosIncluyenIgv? }(cat. 09). Sinitemsva por el total (01,02,06siempre); conitems, parcial. Varias NC por comprobante mientras la suma no pase su total (si no, 400 sin gastar número). MismoexternalRef= misma nota. Respondeid, serie, correlativo, numero, estado, total, subtotal, igv.POST /api/v1/comprobantes/:id/nota-debito—{ codigoMotivo, motivo? }(cat. 10).POST /api/v1/comprobantes/:id/retry— reintenta unPENDIENTE/RECHAZADO(mismo serie-número).POST /api/v1/comprobantes/:id/anular— da de baja un aceptado (asíncrono; devuelve un Resumen conticket). Body:{ "motivo": "…" }. 409 pasados 7 días de la emisión o con una nota viva.POST /api/v1/comprobantes/:id/email— envía el PDF (+XML si está archivado) al cliente.{ "to": "correo@…" }opcional. Requiere SMTP configurado.
Resúmenes / bajas (asíncrono por ticket)
GET /api/v1/resumenes— lista RC (resúmenes) y RA (bajas). Filtros:?emisorId=,?estado=.POST /api/v1/resumenes/:id/consultar— consulta el ticket en SUNAT. Al aceptarse una baja, el comprobante pasa aANULADO.
Consulta pública (sin API key)
La usa la página /consulta; sirve para quien recibe el comprobante, no para tu sistema.
POST /api/consulta—{ "ruc", "tipo", "serie", "numero", "fecha": "YYYY-MM-DD", "total" }(tipo:FACTURA,BOLETA,NOTA_CREDITOoNOTA_DEBITO). Los seis datos tienen que coincidir. Devuelve{ emisor, ruc, tipo, numero, fecha, moneda, total, estado, token }. Solo encuentra comprobantesACEPTADO,OBSERVADOoANULADOy, en producción, solo de emisores enPRODUCCION.404si no hay coincidencia,422si falta un dato,429(conRetry-After) pasados 5 intentos por minuto o 30 por hora desde la misma IP.GET /api/consulta/descarga?t=<token>&f=pdf|xml|cdr— baja el archivo. Eltokenvale 15 minutos (403vencido o alterado);400formato inválido;404si ese archivo no existe para el comprobante;429pasadas 30 descargas por minuto.
14. Catálogos SUNAT usados
- Tipo de comprobante:
01factura ·03boleta ·07nota de crédito ·08nota de débito. - Documento de identidad del cliente (cat. 06):
DNI=1 ·CE=4 ·RUC=6 ·PASAPORTE=7 ·SIN_DOC=0. - Afectación IGV (cat. 07):
GRAVADO=10 ·EXONERADO=20 ·INAFECTO=30. - Moneda:
PEN,USD,EUR. - IGV = 18%. Las fechas de emisión se calculan en zona horaria
America/Lima.
15. Ejemplos completos
Emitir (curl)
curl -X POST https://sunat.lexarsolutions.com/api/v1/comprobantes \
-H "Authorization: Bearer $FACTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emisorId": "cmr7sneym00017gq167k2pxf5",
"tipo": "BOLETA",
"externalRef": "orden-1024",
"cliente": { "tipoDoc": "DNI", "numDoc": "44556677", "razonSocial": "JUAN PEREZ" },
"items": [{ "descripcion": "Polo algodón", "cantidad": 2, "precio": 59.90 }]
}'
Emitir (Node.js / TypeScript)
const res = await fetch("https://sunat.lexarsolutions.com/api/v1/comprobantes", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FACTA_API_KEY}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(40_000), // emisión síncrona: timeout generoso
body: JSON.stringify({
emisorId: "cmr7sneym00017gq167k2pxf5",
tipo: "FACTURA",
externalRef: order.id, // idempotencia = tu orderId
cliente: { tipoDoc: "RUC", numDoc: "20123456789", razonSocial: "CLIENTE SAC" },
items: order.items.map((i) => ({ descripcion: i.name, cantidad: i.qty, precio: i.price })),
}),
});
const { success, data, error } = await res.json();
if (!success) throw new Error(error);
switch (data.estado) {
case "ACEPTADO":
case "OBSERVADO":
await saveInvoice(order, data); // válido
break;
case "RECHAZADO":
await flagForReview(order, data.sunatMessage);
break;
case "PENDIENTE":
await saveInvoice(order, data); // espera el webhook
break;
}
Emitir (PHP)
$ch = curl_init("https://sunat.lexarsolutions.com/api/v1/comprobantes");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 40,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("FACTA_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"emisorId" => "cmr7sneym00017gq167k2pxf5",
"tipo" => "BOLETA",
"externalRef" => $order->id,
"cliente" => ["tipoDoc" => "DNI", "numDoc" => "44556677", "razonSocial" => "JUAN PEREZ"],
"items" => [["descripcion" => "Polo", "cantidad" => 2, "precio" => 59.90]],
]),
]);
$out = json_decode(curl_exec($ch), true);
if (empty($out["success"])) throw new Exception($out["error"] ?? "error");
$comprobante = $out["data"]; // ["estado"], ["numero"], ["id"] …
Verificar la firma de un webhook (Node.js / Express)
import { createHmac, timingSafeEqual } from "node:crypto";
// Usa el cuerpo CRUDO (raw), no el JSON ya parseado.
app.post("/webhooks/facta", express.raw({ type: "*/*" }), (req, res) => {
const firma = req.header("X-Facta-Signature") ?? "";
const esperado = createHmac("sha256", process.env.FACTA_WEBHOOK_SECRET)
.update(req.body) // req.body es un Buffer (raw)
.digest("hex");
const a = Buffer.from(firma), b = Buffer.from(esperado);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();
const evento = JSON.parse(req.body.toString());
enqueue(evento); // idempotente: encola por evento.id y responde ya
res.status(200).end();
});
Verificar la firma de un webhook (PHP)
$raw = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_FACTA_SIGNATURE"] ?? "";
$calc = hash_hmac("sha256", $raw, getenv("FACTA_WEBHOOK_SECRET"));
if (!hash_equals($calc, $sig)) { http_response_code(401); exit; }
$evento = json_decode($raw, true);
// procesa $evento["id"], $evento["estado"], $evento["externalRef"] …
http_response_code(200);
16. Checklist de producción
- Integra en BETA primero (emite, anula, prueba webhooks). Recién luego pasa el emisor a PRODUCCION (SOL real + cert vigente + confirmación).
- El API key vive en el backend. Nunca en frontend/móvil. Rota si se filtra.
- Siempre
externalRefigual a tu orderId — tu red de seguridad ante reintentos y timeouts. - Guarda
id+numero+estadojunto a tu orden apenas emites. - Trata OBSERVADO como válido — no lo confundas con RECHAZADO.
- Configura un webhook y verifica su firma. Deja el polling por
externalRefcomo respaldo. - Handler de webhook idempotente y con respuesta
2xxrápida. - No reintentes
PENDIENTEen bucle — el reconciliador lo hace con backoff; espera o consulta. - Monitorea la salud con
GET /api/v1/health/gateway(incluye el circuit breaker de SUNAT).
Facta — facturación electrónica SUNAT (SEE - Del Contribuyente). Esta documentación se sirve como texto plano
en /llms.txt y renderizada en /docs.