factsimil es, antes que nada, una API REST. El panel web que ves en todo lo que incluye factsimil es un cliente más de esa misma API — si estás integrando facturación electrónica en un marketplace, un ERP vertical o cualquier plataforma propia, esto es lo que necesitas saber para empezar.
Esta página cubre el flujo real de comprobantes. Para la referencia completa de cada endpoint (parámetros opcionales, códigos de error, catálogos SUNAT completos), consulta la documentación OpenAPI pública.
1. Autenticación
Cada request autenticado necesita un header Authorization: Bearer <token>. Para integrar tu plataforma, ese token es una credencial de integración.
Credenciales de integración — para conectar tu plataforma (marketplace, ERP, sistema propio)
Cada sistema externo que hable con factsimil tiene su propia credencial, generada desde Mi Empresa → Integraciones (no hace falta pedírnosla) — le pones un nombre (ej. "mi ERP de ventas") y se muestra en pantalla exactamente una vez, con un aviso de que no se puede volver a consultar después.
Authorization: Bearer <client_id>.<secret>
El token completo (client_id + secret, unidos por un punto) es lo que usas como Bearer en cada llamada, y da acceso a la empresa que lo generó (facturas, notas, guías, proformas, clientes). El client_id (la parte antes del punto) identifica a esa integración puntual en tu bitácora de auditoría, así que si conectas más de un sistema conviene crear una credencial separada para cada uno.
La credencial no vence por tiempo, pero deja de funcionar si la revocas o si tu cuenta queda sin suscripción: al terminar la prueba gratis de 15 días sin una tarjeta registrada, la API responde 402 con billing.trial_expired hasta que se registre un método de pago (las consultas y descargas de comprobantes ya emitidos siguen respondiendo).
Puedes rotarla o revocarla tú mismo en cualquier momento desde Mi Empresa → Integraciones. Al rotar, la credencial anterior sigue funcionando 48 horas más mientras actualizas la integración — no hay corte inmediato.
Login por sesión — no disponible para integraciones
POST /api/auth/login es el login del panel web y de la app: exige una verificación anti-bots (Cloudflare Turnstile) que solo se resuelve en un navegador, así que no funciona server-to-server y no lo soportamos para integraciones. Usa una credencial de integración.
2. Crear un comprobante
Un comprobante empieza como borrador. customerId y seriesId son los únicos campos que dependen de datos ya creados en tu cuenta (cliente y serie); el resto describe las líneas del comprobante:
POST /api/invoices
Authorization: Bearer <client_id>.<secret>
Content-Type: application/json
{
"customerId": "3fa2...",
"seriesId": "9c1b...",
"moneda": "PEN",
"formaPago": "Contado",
"items": [
{
"descripcion": "Servicio de consultoría",
"cantidad": 1,
"precioUnitario": 250.00,
"tipoAfectacionIgv": "10"
}
]
}
HTTP/1.1 201 Created
{ "id": "6f1a...", "estado": "BORRADOR", "numero": null, "total": 295.00 }moneda acepta PEN, USD, EUR, GBP, CAD, JPY, SEK o CHF. tipoAfectacionIgv sigue el Catálogo 07 de SUNAT (10 Gravado, 20 Exonerado, 30 Inafecto, 40 Exportación) — si no lo mandas, asume Gravado con IGV general.
Si tu integración puede reintentar un POST tras un timeout de red (sin saber si el primero llegó a crear el comprobante o no), manda un header Idempotency-Key con un valor único por operación (ej. el ID de tu propio pedido, o un UUID que generes tú):
POST /api/invoices
Authorization: Bearer <client_id>.<secret>
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ ... }Reintentar con la MISMA key y el MISMO body te devuelve el comprobante que ya se creó, sin duplicarlo. Reusar la key con un body distinto es un 422 (probablemente un bug de tu lado, no un caso válido). Es opcional -- sin el header, el comportamiento es el de siempre.
3. Firmar y enviar a SUNAT
Firmar es async: la firma XML, la numeración correlativa y el envío a SUNAT ocurren en cola, no en el mismo request. Recibirás un 202 con un jobId, no el resultado final:
POST /api/invoices/6f1a.../sign
Authorization: Bearer <client_id>.<secret>
HTTP/1.1 202 Accepted
{ "queued": true, "jobId": "42" }Esto es deliberado: la numeración correlativa tiene que resolverse sin condiciones de carrera aunque dos comprobantes se firmen en el mismo instante, y SUNAT no siempre responde al instante. Por eso el siguiente paso es consultar el estado, no asumir que ya está listo.
4. Consultar el estado
Puedes consultar por polling, o -- mejor -- configurar un webhook y que factsimil te avise. Por polling, el patrón es un GET corto sobre el mismo recurso hasta que estado deja de ser PROCESANDO:
GET /api/invoices/6f1a...
Authorization: Bearer <client_id>.<secret>
HTTP/1.1 200 OK
{
"id": "6f1a...",
"estado": "ACEPTADO",
"numero": 1042,
"hash": "8f3a2c...",
"xmlPath": "invoices/.../signed.xml",
"cdrPath": "invoices/.../cdr.zip"
}Los valores reales de estado que vas a ver son BORRADOR, PROCESANDO, ACEPTADO, OBSERVADO y RECHAZADO —OBSERVADO significa que SUNAT lo aceptó con una observación (el comprobante es válido), no que falló.
Webhooks (recomendado en vez de polling)
Configura una URL desde Mi Empresa → Integraciones (por credencial -- cada integración puede apuntar a un endpoint distinto) y factsimil te va a avisar por POST cada vez que el comprobante cambie de estado, en vez de que tengas que preguntar tú. Al configurarla te mostramos un secret una única vez -- úsalo para verificar que el webhook realmente vino de factsimil:
POST https://tu-servidor.com/webhooks/factsimil
X-Timestamp: 1755000000000
X-Signature: 7a3f9c...
Content-Type: application/json
{
"event": "invoice.estado_changed",
"invoiceId": "6f1a...",
"tenantId": "9c1b...",
"seriesId": "...", "numero": 1042,
"estadoAnterior": "FIRMADO", "estadoNuevo": "ACEPTADO",
"total": 295.00, "moneda": "PEN",
"sunatMensaje": "La Factura numero F001-1042, ha sido aceptada",
"timestamp": 1755000000000
}Se dispara en cada transición real (GENERADO, FIRMADO, ACEPTADO/OBSERVADO/RECHAZADO, ANULADO), no solo en el veredicto final. Verifica la firma igual que lo hacemos nosotros del lado de cualquier partner que nos llama:
const crypto = require('crypto');
function isValid(timestamp, rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Si tu endpoint no responde 2xx (o tarda más de 10s), reintentamos con backoff creciente hasta 5 veces a lo largo de un par de horas antes de darnos por vencidos con esa notificación puntual.
5. XML, PDF y enlaces compartibles
Una vez ACEPTADO u OBSERVADO, el XML firmado y el PDF están disponibles directamente:
GET /api/invoices/6f1a.../xml → application/xml (el XML firmado, el mismo que valida SUNAT) GET /api/invoices/6f1a.../pdf → application/pdf
Si tu plataforma necesita mandarle el comprobante al cliente final sin exponer tu token, genera un enlace compartible de un solo recurso:
POST /api/invoices/6f1a.../share-link
Authorization: Bearer <client_id>.<secret>
HTTP/1.1 201 Created
{ "url": "https://factsimil.com/api/public/invoices/<token>/pdf" }6. Multiempresa: el header x-tenant-id
Si tu cuenta tiene acceso a más de una empresa (por ejemplo, eres un contador con varios RUC), cada request opera sobre tu empresa principal salvo que mandes explícitamente x-tenant-id: <id-de-la-empresa>. Esto te deja resolver, en el mismo token, en qué empresa estás actuando en cada llamada — útil si tu plataforma administra facturación para varios clientes desde una sola integración.
7. Activar producción (salir de beta)
Tu cuenta arranca en modo beta, contra el ambiente de pruebas de SUNAT — todo lo de las secciones anteriores ya funciona ahí, pero los comprobantes no tienen validez real hasta que pasas a modo producción. Ese pasaje no es instantáneo ni autoservicio completo: son 3 pasos que haces tú, seguidos de una aprobación manual de nuestro lado.
Importante para quien esté automatizando esto: los primeros dos pasos (certificado y credenciales SUNAT) se hacen desde el panel (Mi Empresa, en la web o en la app) con la sesión de un usuario — una credencial de integración recibe 401 Unauthorized ahí a propósito: son credenciales sensibles que no delegamos a un token de servidor a servidor. Los endpoints de abajo se muestran como referencia de lo que hace el panel. El paso de pedir producción sí acepta tu credencial de integración.
Paso 1 — Subir el certificado digital (.p12)
POST /api/tenants/<tenantId>/certificates
Authorization: Bearer <JWT de sesión>
Content-Type: multipart/form-data
file: <tu-certificado>.p12
password: "..."
HTTP/1.1 201 Created
{ "id": "...", "filePath": "tenants/.../certificates/....p12.enc", "fechaVigencia": "2027-03-01", "estado": "activo" }Paso 2 — Cargar usuario y clave SOL
PATCH /api/tenants/<tenantId>/sunat-credentials
Authorization: Bearer <JWT de sesión>
Content-Type: application/json
{ "usuarioSol": "...", "claveSol": "..." }
HTTP/1.1 200 OK
{ "ok": true }Si además vas a emitir guías de remisión electrónica, las credenciales GRE se cargan aparte con el mismo patrón: PATCH /api/tenants/<tenantId>/gre-credentials con { "clientId": "...", "clientSecret": "..." } — también exige sesión, no API key.
Paso 3 — Pedir producción
Declaras, por cada serie, el último número que ya usaste emitiendo en producción antes (si nunca facturaste por ese RUC, manda el correlativo actual tal cual, normalmente 0):
POST /api/tenants/<tenantId>/production-request
Authorization: Bearer <client_id>.<secret>
Content-Type: application/json
{ "declaredSeries": [ { "seriesId": "9c1b...", "ultimoNumero": 0 } ] }
HTTP/1.1 201 Created
{
"id": "...",
"estado": "pendiente",
"declaredSeries": [ { "seriesId": "9c1b...", "codigo": "F001", "ultimoNumero": 0 } ],
"motivoRechazo": null,
"createdAt": "...",
"resolvedAt": null
}estado queda en pendiente hasta que alguien de nuestro equipo la revisa y la aprueba (o la rechaza, con un motivo) — no hay ningún camino automático. Mientras tanto sigues operando en beta con normalidad. Para consultar el estado sin volver a mandar el POST:
GET /api/tenants/<tenantId>/production-request
Authorization: Bearer <client_id>.<secret>
HTTP/1.1 200 OK
{ "id": "...", "estado": "aprobado", ... } ← o null si todavía no enviaste ninguna solicitudErrores esperables al pedir producción (todos 400 Bad Request salvo el último): sin certificado activo, sin usuario/clave SOL, sin ninguna serie creada, o un ultimoNumero menor al correlativo que la serie ya tiene — y 409 Conflict si ya hay una solicitud pendiente para ese tenant.
8. Códigos de error
Toda respuesta de error trae, además de statusCode y message, un code estable para tomar decisiones sin parsear el mensaje en español:
HTTP/1.1 409 Conflict
{ "statusCode": 409, "code": "invoice.duplicate", "message": "..." }Facturas y boletas
invoice.not_found(404) — no existe o no pertenece a este tenant.invoice.missing_exchange_rate(400) —monedano es PEN y faltatipoCambio.invoice.invalid_discount(400) — descuento inválido en un ítem.invoice.credit_requires_installments(400) —formaPago: Creditosincuotas, o la suma no coincide con el total.invoice.installments_require_credit(400) — se enviaroncuotassinformaPago: Credito.invoice.detraccion_missing_amount(400) —sujetoDetraccion: truesinmontoDetraccion.invoice.detraccion_missing_code(400) —sujetoDetraccion: truesincodigoBienServicioDetraccion.invoice.invalid_customer_or_series(400) —customerId/seriesIdno existen o no son de este tenant.invoice.wrong_series_type(400) — la serie no es de tipo factura/boleta.invoice.series_inactive(400) — la serie está anulada: ya no se emite en ella.invoice.medio_cobro_requires_contado(400) — se enviómedioCobroconformaPagodistinta deContado.invoice.factura_requires_ruc(400) — se pidió una factura para un cliente sin RUC.invoice.invalid_state_transition(409) — ej. reintentar/signsobre un comprobante ya procesado.invoice.not_signed_yet(404) — XML/PDF pedidos antes de que termine de firmarse.invoice.cdr_not_available(404) — CDR pedido antes de que SUNAT responda.invoice.tipo_operacion_invalido(400) —tipoOperacionno es del Catálogo 51 o no es coherente con la detracción, la percepción o la exportación del comprobante.invoice.detraccion_sin_cuenta(400) — operación sujeta a detracción sin una cuenta de detracciones del Banco de la Nación válida configurada en la empresa.invoice.percepcion_invalida(400) —montoPercepcionsincodigoRegimenPercepcion(Catálogo 22), o con un monto que no es la tasa del régimen sobre el importe total.invoice.icbper_requiere_linea_bolsa(400) — el ICBPER va en la línea de las bolsas (esBolsaPlastica: true, cantidad entera);icbperBolsassolo, sin esa línea, se rechaza.invoice.icbper_tasa_invalida(400) —icbperTasano es la vigente para el año de emisión.invoice.cuota_vencida(400) — una cuota vence en o antes de la fecha de emisión.invoice.boleta_requiere_documento(400) — boleta cuyo importe supera el umbral vigente (S/ 700, en soles) a un cliente sin documento de identidad.invoice.emision_no_habilitada(403) — la empresa es gestionada por su contador o no emite comprobantes por factsimil.invoice.envio_no_permitido(409) —/send-emailsobre un borrador, un comprobante rechazado por SUNAT o uno anulado.invoice.pagos_no_cuadran(400) — las líneas depagosno suman el total del comprobante, o sobra monto en un medio que no es efectivo.invoice.pagos_y_medio_cobro(400) — se enviaronpagosymedioCobroa la vez: son excluyentes.
Notas de crédito/débito
note.not_found(404) — no existe o no pertenece a este tenant.note.invoice_not_accepted(400) — la factura referenciada no está ACEPTADA/OBSERVADA.note.invalid_discount(400) — descuento inválido en un ítem.note.exceeds_invoice_icbper(400) — el ICBPER de la nota supera el de la factura.note.exceeds_invoice_perception(400) — la percepción de la nota supera la de la factura.note.invalid_series(400) — la serie no es del tipo crédito/débito esperado.note.series_inactive(400) — la serie está anulada: ya no se emite en ella.note.invalid_state_transition(409) — ej. reintentar/signsobre una nota ya procesada.note.exceeds_invoice_total(400) — la nota acreditada supera el total de la factura.note.not_signed_yet(404) — XML/PDF pedidos antes de que termine de firmarse.note.cdr_not_available(404) — CDR pedido antes de que SUNAT responda.note.series_documento_no_coincide(400) — la serie de la nota no corresponde al comprobante que modifica (F para facturas, B para boletas).note.tasa_igv_invalida(400) — latasaIgvde una línea no es una de las tasas de la factura original.note.icbper_requiere_linea_bolsa(400) — el ICBPER de la nota va en la línea de las bolsas (esBolsaPlastica: true), con la tasa de la factura.note.emision_no_habilitada(403) — la empresa es gestionada por su contador o no emite comprobantes por factsimil.
Guías de remisión y proformas
guia.not_found(404) — no existe o no pertenece a este tenant.guia.not_signed_yet(404) — XML/PDF pedidos antes de que termine de firmarse.proforma.not_found(404) — no existe o no pertenece a este tenant.proforma.missing_exchange_rate(400) —monedano es PEN y faltatipoCambio.proforma.already_converted(400) — ya fue convertida a factura.proforma.voided(400) — está anulada.
Clientes, series y tenant
customer.duplicate_document(409) — ya existe un cliente con ese número de documento.series.duplicate_code(409) — ya existe una serie con ese código.series.not_found(404) — no existe o no pertenece a este tenant.tenant.not_found(404) — el tenant no existe.
Autenticación, idempotencia y rate limiting
auth.missing_credentials(401) — no se envió cookie de sesión niAuthorization: Bearer.auth.invalid_api_key(401) — elclient_id/secret no es válido o fue revocado.auth.tenant_mismatch(400) — el API key no pertenece al tenant de la URL.idempotency.key_too_long(422) — elIdempotency-Keysupera los 255 caracteres.idempotency.request_in_progress(409) — ya hay un request en curso con esa key.idempotency.key_reused(422) — la key ya se usó con un body diferente.rate_limit.tenant_exceeded(429) — el tenant superó su límite de 300 requests/min.billing.trial_expired(402) — la prueba de 15 días terminó sin un método de pago registrado. Emitir queda bloqueado; consultar y descargar comprobantes (GET) sigue funcionando.billing.payment_failed/billing.subscription_ended(402) — el último cobro fue rechazado o la suscripción terminó.tenant.suspended(403) — la empresa está suspendida; traemotivoypuedePagar.plan.feature_not_included(403) — la funcionalidad no está en el plan de la empresa (traefeatureRequeridayplanMinimo). Emitir comprobantes por API no depende del plan.
Cualquier otro error responde con un code genérico derivado del status HTTP (error.bad_request, error.not_found, error.internal, etc.) — el campo code está siempre presente, aunque no todos los casos tengan todavía un código namespaced específico.
9. Siguientes pasos
- Crear una cuenta (con 15 días de prueba gratis, sin tarjeta) te da acceso inmediato al panel y a las mismas credenciales que usa esta API — no hay un "modo sandbox" separado con datos de prueba distintos.
- ¿Vas a integrar esto para más de un cliente final (marketplace, ERP vertical)? Escríbenos — es exactamente el caso de uso para el que se diseñó factsimil.