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. - 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. |
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",
"sunatMessage": "La Factura numero F001-123 ha sido aceptada",
"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.
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).
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). - 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.
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.
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). No admite NC individual | POST /api/v1/comprobantes/:id/anular |
| 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) | Ya está en curso; consulta el estado. |
| 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).POST /api/v1/emisores/:id/validate-cert— revalida el certificado.{ valid, message, validTo, subject }.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.POST /api/v1/comprobantes/:id/nota-credito—{ codigoMotivo, motivo? }(cat. 09).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": "…" }.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.
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.