FacNow
🦙 Guía pública de integración

FacNow API /v1

Referencia para integradores externos que consumen el API público /v1 de FacNow Core (el motor de facturación electrónica detrás de NORAC). Es la API pensada para que sistemas de terceros (ERPs, gestores de clínicas/gimnasios, etc.) emitan comprobantes SUNAT en nombre de las empresas de sus clientes.

Base URL (producción): https://norac-facturacion.onrender.com
Formato: JSON
Docs interactivas: /docs · /openapi.json
💡 Si buscas la API interna que usa la app web de NORAC (JWT + X-Company-Id), consulta API.md en el repositorio.

1. Introducción

FacNow Core sigue un modelo integrador → empresas:

  • Un integrador es el sistema que consume la API (identificado por su API key). Es dueño de una o varias empresas (RUCs) que factura en su nombre.
  • Cada /v1/* request se autentica con la API key del integrador y todas las consultas se filtran automáticamente por ese integrador — nunca ves ni puedes tocar empresas o comprobantes de otro integrador.
  • Modelo de trabajo típico: creas la empresa (POST /v1/companies), le subes certificado digital y credenciales SOL, y luego emites comprobantes (POST /v1/invoices) para esa empresa. Opcionalmente configuras un webhook para que FacNow te avise cuando SUNAT resuelva cada comprobante.

2. Autenticación

Toda llamada a /v1/* requiere el header:

Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx

(o fn_test_... para pruebas). La key identifica al integrador; no hay concepto de usuario/contraseña en este API.

Cómo pedir una key

Las API keys de integrador no se autoprovisionan por la API pública — las emite el equipo de NORAC (proceso administrativo interno, hoy un script de alta que genera el integrador y su primera key). El secreto se muestra una sola vez en ese momento; en el servidor solo se guarda su hash. Contacta al equipo de NORAC para solicitar tu integrador y tu primera key.

Formato de errores

Todos los errores de /v1/* devuelven el mismo shape:

{ "detail": { "code": "invoice_not_found", "message": "Comprobante no encontrado" } }

Códigos que puedes encontrar: invalid_api_key, expired_api_key, inactive_integrator (401); ruc_already_exists (409); missing_idempotency_key, missing_sunat_credentials, invalid_certificate, invalid_credentials (400); company_not_found, invoice_not_found, cdr_not_available (404).

404 = ajeno o inexistente

Si pides una empresa o un comprobante que no existe o que pertenece a otro integrador, la respuesta es siempre 404 (nunca 403). Es una decisión de diseño: no se revela la existencia de recursos ajenos.

3. Empresas

Crear una empresa

curl -X POST https://norac-facturacion.onrender.com/v1/companies \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "ruc": "20512345678", "razon_social": "COMERCIAL DEMO SAC", "nombre_comercial": "Comercial Demo", "ubigeo": "150101", "direccion": "AV. EJEMPLO 123", "distrito": "SAN ISIDRO", "provincia": "LIMA", "departamento": "LIMA" }'

Campos de CompanyIn (todos excepto ruc y razon_social son opcionales, con los defaults mostrados):

Campo Tipo Default Notas
rucstringexactamente 11 dígitos
razon_socialstring1–255 caracteres
nombre_comercialstring""
ubigeostring"150101"
direccionstring"-"
distritostring""
provinciastring""
departamentostring""

Respuesta 201 (CompanyOut):

{ "id": 7, "ruc": "20512345678", "razon_social": "COMERCIAL DEMO SAC", "nombre_comercial": "Comercial Demo", "sunat_mode": "beta", "has_certificate": false }

409 ruc_already_exists si ya existe una empresa con ese RUC.

Listar y ver empresas

GET /v1/companies → { "data": [ CompanyOut, ... ] } # solo las tuyas, activas GET /v1/companies/{company_id} → CompanyOut # 404 si no es tuya

Subir certificado digital

curl -X POST https://norac-facturacion.onrender.com/v1/companies/7/certificate \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "pfx_base64": "<archivo .pfx en base64>", "password": "clave-del-pfx", "filename": "certificado.pfx" }'

Body: pfx_base64 (obligatorio, el .pfx/.p12 codificado en base64), password (obligatorio), filename (opcional, default "cert.pfx"). Respuesta (CertificateOut): {"id", "filename", "subject", "not_after"}. 400 invalid_certificate si el PFX o la contraseña son inválidos.

Credenciales SOL

curl -X PATCH https://norac-facturacion.onrender.com/v1/companies/7/sunat-credentials \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"sol_user": "20512345678MODDATOS", "sol_pass": "moddatos", "sunat_mode": "beta"}'

sunat_mode es "beta" (homologación, default) o "production". Devuelve CompanyOut actualizado. 400 invalid_credentials si SUNAT las rechaza.

4. Emisión

Emitir un comprobante

POST /v1/invoices?company_id=7

Header obligatorio: Idempotency-Key: <clave única por operación>. Sin él, 400 missing_idempotency_key.

Comportamiento de replay: si repites la misma Idempotency-Key (para el mismo integrador), la API no vuelve a emitir — devuelve 200 con el comprobante original ya creado (en la primera emisión exitosa la respuesta es 201). Esto hace seguro reintentar una request que no sabes si llegó.

Ejemplo — boleta (tipo 03):

curl -X POST "https://norac-facturacion.onrender.com/v1/invoices?company_id=7" \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Idempotency-Key: pedido-8842-emision" \ -H "Content-Type: application/json" \ -d '{ "tipo": "03", "serie": "B001", "fecha_emision": "2026-09-03", "moneda": "PEN", "forma_pago": "Contado", "receptor": { "tipo_doc": "1", "num_doc": "44247191", "razon_social": "JUAN PEREZ", "email": "juan@example.com" }, "lineas": [ { "descripcion": "Membresia mensual", "unidad": "NIU", "cantidad": "1", "valor_unitario": "100.00", "afectacion_igv": "10" } ] }'

Ejemplo — factura (tipo 01), con dirección del receptor:

curl -X POST "https://norac-facturacion.onrender.com/v1/invoices?company_id=7" \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Idempotency-Key: pedido-8843-emision" \ -H "Content-Type: application/json" \ -d '{ "tipo": "01", "serie": "F001", "correlativo": null, "fecha_emision": "2026-09-03", "moneda": "PEN", "forma_pago": "Contado", "receptor": { "tipo_doc": "6", "num_doc": "20512345678", "razon_social": "CLIENTE SAC", "direccion": { "ubigeo": "150101", "direccion": "AV. EJEMPLO 456", "distrito": "MIRAFLORES", "provincia": "LIMA", "departamento": "LIMA" }, "email": "compras@cliente.pe" }, "lineas": [ { "codigo": "SKU-001", "descripcion": "Producto A", "unidad": "NIU", "cantidad": "2", "valor_unitario": "50.00", "afectacion_igv": "10", "descuento": "0" } ], "observaciones": "" }'

Campos de EmisionIn (cuerpo del POST):

Campo Tipo Default Notas
tipostring"01" Factura · "03" Boleta · "07" NC · "08" ND
seriestring
correlativoint | nullnullsi se omite, se autoincrementa la serie
fecha_emisiondateYYYY-MM-DD
hora_emisiontime00:00:00
monedastring"PEN"
forma_pagostring"Contado"
receptorReceptorInver abajo
lineasLineaIn[]al menos una
observacionesstring""
motivo_codigostring""solo NC/ND
motivo_descripcionstring""solo NC/ND
referenciaReferenciaIn | nullnullsolo NC/ND: {"tipo_doc", "serie_numero"} del documento afectado

ReceptorIn: tipo_doc (default "6" = RUC; "1" = DNI), num_doc, razon_social, direccion (opcional, DireccionIn), email (default "").

LineaIn: codigo (default ""), descripcion, unidad (default "NIU"), cantidad, valor_unitario (sin IGV), afectacion_igv (default "10" = gravado; catálogo SUNAT 07: 20 exonerado, 30 inafecto, 40 exportación), descuento (default 0).

Notas de crédito/débito (tipo "07"/"08"): mismo endpoint, agregando motivo_codigo, motivo_descripcion y referencia (documento que afectan).

Respuesta (InvoiceOut):

{ "id": 12, "numero": "B001-00000012", "tipo": "03", "serie": "B001", "correlativo": 12, "status": "accepted", "response_code": "0", "cdr_description": "La Boleta numero B001-00000012, ha sido aceptada", "importe_total": 118.0 }

Estados del comprobante (status)

Estado Significado
queueden cola de envío a SUNAT (p. ej. SUNAT caído; el worker reintenta)
sentenviado, esperando CDR
acceptedCDR aceptado — terminal
observedCDR con observaciones — terminal
rejectedCDR rechazado — terminal
errorerror de transporte / SUNAT — puede reintentar

Los estados terminales (accepted, observed, rejected) son los que disparan el webhook de notificación (ver sección 5).

Consultar un comprobante

GET /v1/invoices/{id} → InvoiceOut (404 invoice_not_found si no es tuyo) GET /v1/invoices/{id}/xml → application/xml (XML firmado) GET /v1/invoices/{id}/cdr → application/xml (CDR; 404 cdr_not_available si aún no llegó) GET /v1/invoices/{id}/pdf → application/pdf

5. Webhooks

Configurar la URL de notificación

curl -X PATCH https://norac-facturacion.onrender.com/v1/webhook \ -H "Authorization: Bearer fn_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"url": "https://tu-sistema.example.com/webhooks/facnow"}'

Respuesta:

{ "url": "https://tu-sistema.example.com/webhooks/facnow", "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }

El secret se muestra una sola vez, en esta respuesta — guárdalo. En el servidor se guarda cifrado, no en claro. Enviar {"url": ""} desactiva el webhook (y borra el secret). Cada llamada a PATCH /v1/webhook con una URL rota el secret (genera uno nuevo).

Payload

Cuando un comprobante llega a un estado terminal (accepted, observed o rejected), FacNow hace POST a tu URL con:

{ "event": "invoice.terminal", "data": { "id": 12, "numero": "B001-00000012", "tipo": "03", "serie": "B001", "correlativo": 12, "status": "accepted", "response_code": "0", "cdr_description": "La Boleta numero B001-00000012, ha sido aceptada", "importe_total": 118.0 } }

data tiene exactamente el shape de InvoiceOut (la misma respuesta que GET /v1/invoices/{id}).

Verificación de firma

Cada entrega incluye el header:

X-FacNow-Signature: t=1735689600,v1=<hmac_sha256_hex>

Donde v1 es HMAC-SHA256(secret, f"{t}.{body}") sobre el body crudo de la request (bytes exactos, antes de parsear JSON). Verifica así:

import hashlib import hmac import time def verify_facnow_signature(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts.get("t"), parts.get("v1") if t is None or v1 is None: return False if abs(time.time() - int(t)) > tolerance: return False # timestamp fuera de ventana (posible replay) expected = hmac.new( secret.encode(), f"{t}.".encode() + body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1)

Puntos clave:

  • Usa hmac.compare_digest (comparación en tiempo constante), nunca ==.
  • Rechaza si |now - t| > 300 segundos (ventana anti-replay de 5 minutos).
  • Calcula el HMAC sobre el body tal cual llega (bytes), no sobre un JSON re-serializado por tu framework — puede diferir en espacios/orden.

Política de reintentos

Si tu endpoint no responde 2xx, FacNow reintenta con backoff exponencial hasta 5 intentos en total; al agotarlos, la entrega queda marcada como fallida definitivamente (no se reintenta más para ese evento). Por eso:

  • Responde 2xx rápido (idealmente <10s — es el timeout del lado de FacNow) y procesa el evento de forma asíncrona en tu sistema si toma más.
  • Trata cada entrega como at-least-once: puedes recibir el mismo evento más de una vez (usa data.id + data.status para deduplicar).

6. Buenas prácticas

  • Idempotency-Key por operación de negocio, no por request HTTP: usa un identificador estable de tu lado (p. ej. "pedido-8842-emision") para que un reintento de red no genere un comprobante duplicado.
  • Usuario secundario de Clave SOL: no uses el usuario SOL maestro del RUC para sol_user/sol_pass; crea un usuario secundario en SUNAT con los permisos mínimos necesarios (emisión de comprobantes) para las credenciales que le das a FacNow.
  • Empieza en modo beta (sunat_mode) para probar tu integración contra el ambiente de homologación de SUNAT, y pasa a production recién cuando el flujo esté validado extremo a extremo (certificado real + credenciales SOL de producción).

¿Listo para integrar FacNow?

Crea tu cuenta, activa tu certificado digital gratuito y empieza a emitir comprobantes SUNAT en minutos.

¡Probar 14 Días Gratis! 🦙