FactaDocumentación de integración

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> (formato nf_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 con emisorId.
  • 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) y estado.
  • 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

  1. Un emisor configurado en el panel (RUC, certificado .p12, usuario/clave SOL y series). En beta el SOL universal es MODDATOS / moddatos.
  2. Una API key de consumidor (créala en /dashboard/consumidores). Se muestra una sola vez; guárdala como secreto en tu backend.
  3. 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:

  1. El usuario pega su API key en tu sistema/CMS.
  2. Tu sistema llama GET /api/v1/emisores y muestra un selector "Empresa emisora" (razonSocial (RUC)).
  3. El usuario elige; guardas ese emisorId (uno por tienda/sucursal si aplica).
  4. En cada venta mandas el emisorId correspondiente.

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 Id del emisor (de GET /api/v1/emisores).
tipo enum 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 DNI | RUC | CE | PASAPORTE | SIN_DOC.
cliente.numDoc string según tipo Requerido salvo SIN_DOC.
cliente.razonSocial string Nombre/razón social.
cliente.direccion string no
cliente.email string no Para el envío por correo.
items[].descripcion string
items[].cantidad number > 0.
items[].precio number 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.
  • OBSERVADOaceptado con observaciones. Es válido; revisa sunatMessage.
  • RECHAZADO — SUNAT lo rechazó (dato inválido). Corrige y reemite: un externalRef rechazado 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 externalRef devuelve 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 2xx rápido (encola el trabajo pesado).
  • Un no-2xx o un timeout se reintenta con backoff hasta 10 veces; luego la entrega queda FALLIDO y visible en /dashboard/webhooks para 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): 01 anulación de la operación, 02 anulación por error en el RUC, 03 corrección por error en la descripción, 04 descuento global, 05 descuento por ítem, 06 devolución total, 07 devolución por ítem…
  • Cat. 10 (nota de débito): 01 intereses por mora, 02 aumento en el valor, 03 penalidad 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. 200 si responde y Greenter cargó; 503 si no.

Emisores

  • GET /api/v1/emisores — lista tus emisores (credenciales enmascaradas, series).
  • POST /api/v1/emisores — crea un emisor (valida el .p12 antes 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 por externalRef).
  • 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). Devuelve application/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 un PENDIENTE/RECHAZADO (mismo serie-número).
  • POST /api/v1/comprobantes/:id/anular — da de baja un aceptado (asíncrono; devuelve un Resumen con ticket). 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 a ANULADO.

14. Catálogos SUNAT usados

  • Tipo de comprobante: 01 factura · 03 boleta · 07 nota de crédito · 08 nota 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 externalRef igual a tu orderId — tu red de seguridad ante reintentos y timeouts.
  • Guarda id + numero + estado junto 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 externalRef como respaldo.
  • Handler de webhook idempotente y con respuesta 2xx rápida.
  • No reintentes PENDIENTE en 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.