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: mientras el emisor esta en BETA, NextFact envia siempre ese usuario aunque se haya guardado la SOL real, que solo viaja a produccion.
  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 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 externalRef rechazado 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ódigo 01xx de acceso (usuario o clave SOL, perfil del usuario secundario) o de envío. sunatMessage empieza con «SUNAT no recibió el comprobante (código X: …)». Facta no lo reintenta solo: corrige la causa y llama a POST /api/v1/comprobantes/:id/retry; sale con el mismo número. Los 01xx de 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 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). Con un código 01xx de 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 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) 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): 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, 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. 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). Nace en BETA: mandar "environment": "PRODUCCION" da 400; para pasar a producción usa /environment. Un RUC que ya tienes da 409, 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 }: rucs son los RUC que figuran en el certificado, rucCoincide dice si el del emisor está entre ellos (null si el certificado no trae RUC) y rucAviso explica 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 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.
  • GET /api/v1/comprobantes/:id/archivo?f=xml|cdr — XML firmado o CDR (R-...zip) con el nombre de SUNAT; sirve aunque xmlUrl/cdrUrl sean null. Sin archivo todavía: 404 archivoNoDisponible.
  • POST /api/v1/comprobantes/:id/nota-credito — { codigoMotivo, motivo?, externalRef?, items?, preciosIncluyenIgv? } (cat. 09). Sin items va por el total (01, 02, 06 siempre); con items, parcial. Varias NC por comprobante mientras la suma no pase su total (si no, 400 sin gastar número). Mismo externalRef = misma nota. Responde id, 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 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": "…" }. 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 a ANULADO.

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_CREDITO o NOTA_DEBITO). Los seis datos tienen que coincidir. Devuelve { emisor, ruc, tipo, numero, fecha, moneda, total, estado, token }. Solo encuentra comprobantes ACEPTADO, OBSERVADO o ANULADO y, en producción, solo de emisores en PRODUCCION. 404 si no hay coincidencia, 422 si falta un dato, 429 (con Retry-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. El token vale 15 minutos (403 vencido o alterado); 400 formato inválido; 404 si ese archivo no existe para el comprobante; 429 pasadas 30 descargas por minuto.

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.