# Sistema Facturación Perú: portal para desarrolladores y artículos del blog > Sistema Facturación Perú es un sistema de facturación electrónica SUNAT, punto de venta, inventario y contabilidad para negocios del Perú. Este archivo resume el sitio y lista los artículos del blog para asistentes de IA. --- # Portal para desarrolladores URL: https://www.sistemafacturacionperu.com/desarrolladores/ --- # Inicio rápido: tu primera boleta de prueba en 5 minutos URL: https://www.sistemafacturacionperu.com/desarrolladores/inicio-rapido/ Crea una clave de pruebas, prueba la conexión, emite tu primera boleta electrónica contra la beta de SUNAT y descarga su PDF con cURL, PHP, Node.js o Python. En cinco minutos vas a emitir una boleta electrónica de prueba con la API v1 y descargar su PDF. Todo pasa en el **modo pruebas**: una empresa aparte, conectada a la beta de SUNAT, que no toca tus comprobantes, tu stock ni tus correlativos. Los ejemplos están en cURL, PHP, Node.js y Python: elige tu lenguaje en las pestañas y se queda elegido en toda la página. > **Qué necesitas:** una cuenta de Sistema Facturación Perú en el Plan PRO o Empresa (la API viene desde PRO) y un > usuario administrador o con el permiso «Gestionar tokens de API». Si todavía no tienes cuenta, > [revisa los planes](https://www.sistemafacturacionperu.com/precios/). ## 1. Crea tu clave de pruebas 1. Entra a tu sistema y abre **Configuración › Sistema › Integraciones › API**. 2. En **Modo pruebas**, pulsa **Crear clave de pruebas**. 3. Copia la clave `sfp_test_…`. Se muestra **una sola vez**: guárdala en una variable de entorno, nunca en el código ni en el navegador. ```bash export SFP_API_KEY="sfp_test_TU_CLAVE" ``` Todas las llamadas de esta guía van a `https://pruebas.sistemafacturacionperu.com/api/v1` con la cabecera `Authorization: Bearer `. La clave nunca va en la URL: si la pones ahí, la API responde 401 `clave_en_url`. ## 2. Prueba la conexión `GET /yo` te dice con qué clave, usuario, local y permisos estás llamando. Es la mejor forma de saber que todo está bien conectado. ```bash curl https://pruebas.sistemafacturacionperu.com/api/v1/yo \ -H "Authorization: Bearer $SFP_API_KEY" ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Accept: application/json'], ]); $yo = json_decode(curl_exec($ch), true); echo $yo['data']['empresa']['razon_social'], ' · modo ', $yo['data']['modo'], PHP_EOL; ``` ```js // Node.js 18 o más (fetch viene incluido). Guárdalo como yo.mjs y corre: node yo.mjs const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/yo', { headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, Accept: 'application/json' }, }); const { data } = await res.json(); console.log(data.empresa.razon_social, '· modo', data.modo); ``` ```python # pip install requests import os, requests r = requests.get( "https://pruebas.sistemafacturacionperu.com/api/v1/yo", headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}"}, timeout=30, ) data = r.json()["data"] print(data["empresa"]["razon_social"], "· modo", data["modo"]) ``` Si todo va bien, la respuesta dice `"modo": "pruebas"` y `"sunat": "beta"`: ```json { "success": true, "data": { "empresa": { "ruc": "20000000001", "razon_social": "EMPRESA DE PRUEBAS API S.A.C.", "nombre_comercial": null }, "modo": "pruebas", "sunat": "beta", "clave": { "id": 7, "nombre": "Mi tienda · pruebas", "prefijo": "sfp_test_Xy12Ab34", "vence": null }, "legacy": false, "alcances": ["comprobantes.emitir", "comprobantes.leer", "consultas"], "usuario": { "id": 1, "nombre": "Ana Torres", "tipo": "admin" }, "establecimiento": { "id": 1, "codigo": "0000", "nombre": "Tienda principal" } } } ``` ¿Te respondió 401 o 403? Mira el `code` de la respuesta en el [catálogo de errores](https://www.sistemafacturacionperu.com/desarrolladores/errores/). ## 3. Emite tu primera boleta Una boleta de S/ 60 (dos polos de S/ 30 con IGV) a una persona con DNI, pagada en efectivo. Fíjate en tres cosas: - `"numero_documento": "#"` pide el siguiente correlativo de la serie `B001`. - La fecha va en la hora de Lima (`AAAA-MM-DD`). Los ejemplos la calculan solos. - La cabecera `Idempotency-Key` hace que, si la red falla y repites la llamada con la misma clave, la boleta **no se emita dos veces**. Usa un UUID nuevo por cada comprobante. ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "serie_documento": "B001", "numero_documento": "#", "fecha_de_emision": "'"$(TZ=America/Lima date +%F)"'", "hora_de_emision": "'"$(TZ=America/Lima date +%T)"'", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "03", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "1", "numero_documento": "70123456", "apellidos_y_nombres_o_razon_social": "Ana Pérez", "codigo_pais": "PE" }, "totales": { "total_operaciones_gravadas": 50.85, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor": 50.85, "total_venta": 60 }, "items": [{ "codigo_interno": "POLO-01", "descripcion": "Polo azul", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 25.42, "codigo_tipo_precio": "01", "precio_unitario": 30, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 50.85, "porcentaje_igv": 18, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor_item": 50.85, "total_item": 60 }], "pagos": [{ "codigo_metodo_pago": "01", "codigo_destino_pago": "cash", "monto": 60 }] }' ``` ```php 'B001', 'numero_documento' => '#', 'fecha_de_emision' => $lima->format('Y-m-d'), 'hora_de_emision' => $lima->format('H:i:s'), 'codigo_tipo_operacion' => '0101', 'codigo_tipo_documento' => '03', 'codigo_tipo_moneda' => 'PEN', 'datos_del_cliente_o_receptor' => [ 'codigo_tipo_documento_identidad' => '1', 'numero_documento' => '70123456', 'apellidos_y_nombres_o_razon_social' => 'Ana Pérez', 'codigo_pais' => 'PE', ], 'totales' => [ 'total_operaciones_gravadas' => 50.85, 'total_igv' => 9.15, 'total_impuestos' => 9.15, 'total_valor' => 50.85, 'total_venta' => 60, ], 'items' => [[ 'codigo_interno' => 'POLO-01', 'descripcion' => 'Polo azul', 'unidad_de_medida' => 'NIU', 'cantidad' => 2, 'valor_unitario' => 25.42, 'codigo_tipo_precio' => '01', 'precio_unitario' => 30, 'codigo_tipo_afectacion_igv' => '10', 'total_base_igv' => 50.85, 'porcentaje_igv' => 18, 'total_igv' => 9.15, 'total_impuestos' => 9.15, 'total_valor_item' => 50.85, 'total_item' => 60, ]], 'pagos' => [['codigo_metodo_pago' => '01', 'codigo_destino_pago' => 'cash', 'monto' => 60]], ]; $idempotencia = bin2hex(random_bytes(16)); // guárdala junto a tu venta para reintentar con la misma $ch = curl_init('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Accept: application/json', 'Idempotency-Key: ' . $idempotencia, ], CURLOPT_POSTFIELDS => json_encode($boleta), ]); $respuesta = json_decode(curl_exec($ch), true); $estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($estado !== 201) { throw new RuntimeException($respuesta['code'] . ': ' . $respuesta['message']); } echo 'Emitida ', $respuesta['data']['numero_completo'], ' (id ', $respuesta['data']['id'], ')', PHP_EOL; ``` ```js import { randomUUID } from 'node:crypto'; const lima = new Date().toLocaleString('sv-SE', { timeZone: 'America/Lima' }); // "2026-11-04 10:15:00" const [fecha, hora] = lima.split(' '); const boleta = { serie_documento: 'B001', numero_documento: '#', fecha_de_emision: fecha, hora_de_emision: hora, codigo_tipo_operacion: '0101', codigo_tipo_documento: '03', codigo_tipo_moneda: 'PEN', datos_del_cliente_o_receptor: { codigo_tipo_documento_identidad: '1', numero_documento: '70123456', apellidos_y_nombres_o_razon_social: 'Ana Pérez', codigo_pais: 'PE', }, totales: { total_operaciones_gravadas: 50.85, total_igv: 9.15, total_impuestos: 9.15, total_valor: 50.85, total_venta: 60 }, items: [{ codigo_interno: 'POLO-01', descripcion: 'Polo azul', unidad_de_medida: 'NIU', cantidad: 2, valor_unitario: 25.42, codigo_tipo_precio: '01', precio_unitario: 30, codigo_tipo_afectacion_igv: '10', total_base_igv: 50.85, porcentaje_igv: 18, total_igv: 9.15, total_impuestos: 9.15, total_valor_item: 50.85, total_item: 60, }], pagos: [{ codigo_metodo_pago: '01', codigo_destino_pago: 'cash', monto: 60 }], }; const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': randomUUID(), // guárdala junto a tu venta para reintentar con la misma }, body: JSON.stringify(boleta), }); const json = await res.json(); if (res.status !== 201) throw new Error(`${json.code}: ${json.message}`); console.log('Emitida', json.data.numero_completo, `(id ${json.data.id})`); ``` ```python import os, uuid, requests from datetime import datetime from zoneinfo import ZoneInfo lima = datetime.now(ZoneInfo("America/Lima")) boleta = { "serie_documento": "B001", "numero_documento": "#", "fecha_de_emision": lima.strftime("%Y-%m-%d"), "hora_de_emision": lima.strftime("%H:%M:%S"), "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "03", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "1", "numero_documento": "70123456", "apellidos_y_nombres_o_razon_social": "Ana Pérez", "codigo_pais": "PE", }, "totales": {"total_operaciones_gravadas": 50.85, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor": 50.85, "total_venta": 60}, "items": [{ "codigo_interno": "POLO-01", "descripcion": "Polo azul", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 25.42, "codigo_tipo_precio": "01", "precio_unitario": 30, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 50.85, "porcentaje_igv": 18, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor_item": 50.85, "total_item": 60, }], "pagos": [{"codigo_metodo_pago": "01", "codigo_destino_pago": "cash", "monto": 60}], } r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes", json=boleta, headers={ "Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), # guárdala junto a tu venta para reintentar con la misma }, timeout=90, ) respuesta = r.json() if r.status_code != 201: raise RuntimeError(f"{respuesta['code']}: {respuesta['message']}") print("Emitida", respuesta["data"]["numero_completo"], f"(id {respuesta['data']['id']})") ``` La API responde **201** con el comprobante: ```json { "success": true, "data": { "id": 1520, "external_id": "6f1c7d0e-8f7e-4a1c-9d5b-2a2f8e1c9b10", "tipo": "03", "serie": "B001", "numero": 845, "numero_completo": "B001-845", "fecha_emision": "2026-11-04", "moneda": "PEN", "total": 60, "estado": { "codigo": "01", "clave": "registrado", "descripcion": "Registrado" }, "sunat": null, "enlaces": { "pdf": "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/pdf", "pdf_ticket": "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/pdf?formato=ticket", "xml": "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/xml", "cdr": null } } } ``` El estado es `registrado` porque las boletas no viajan solas a SUNAT: van en el **resumen diario**, que el sistema arma y envía por ti. Cuando SUNAT lo acepte, la boleta pasa a `aceptado` y, si tienes un [webhook](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/), te avisamos con `comprobante.aceptado`. ## 4. Descarga el PDF Con el `id` (o el `external_id`) descarga el PDF en A4, A5 o en formato ticket (`?formato=ticket`, `ticket_58` o `ticket_50`). ```bash curl https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/pdf \ -H "Authorization: Bearer $SFP_API_KEY" \ -o B001-845.pdf ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SFP_API_KEY')], ]); file_put_contents('B001-845.pdf', curl_exec($ch)); ``` ```js import { writeFile } from 'node:fs/promises'; const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/pdf?formato=ticket', { headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}` }, }); await writeFile('B001-845.pdf', Buffer.from(await res.arrayBuffer())); ``` ```python r = requests.get( "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1520/pdf", params={"formato": "ticket"}, headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}"}, timeout=60, ) open("B001-845.pdf", "wb").write(r.content) ``` El XML firmado sale de `/comprobantes/{id}/xml` y la constancia de SUNAT (CDR) de `/comprobantes/{id}/cdr` cuando ya existe. Para tu cliente, la respuesta trae también `enlaces_publicos`: direcciones de descarga que no piden clave. ## 5. ¿Y ahora qué? - Entiende el [camino de un comprobante](https://www.sistemafacturacionperu.com/desarrolladores/conceptos/): series, locales, pruebas y producción. - Emite una [factura](https://www.sistemafacturacionperu.com/desarrolladores/guias/factura/) o una [guía de remisión](https://www.sistemafacturacionperu.com/desarrolladores/guias/guias-de-remision/). - Recibe los cambios de estado con [webhooks](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/) en vez de preguntar. - Prueba cada endpoint desde la [referencia interactiva](https://www.sistemafacturacionperu.com/desarrolladores/referencia/) o importa la colección de Postman. - Cuando todo funcione, sigue la lista de [pase a producción](https://www.sistemafacturacionperu.com/desarrolladores/pase-a-produccion/): creas una clave `sfp_live_` y cambias la dirección a `https://.sistemafacturacionperu.com/api/v1`. > **Lo emitido en pruebas se borra a los 7 días.** Cada clave de pruebas ve solo lo que ella emitió. Los clientes y > productos de la empresa de pruebas son compartidos: no pongas datos reales. --- # Conceptos de la API URL: https://www.sistemafacturacionperu.com/desarrolladores/conceptos/ Empresa y subdominio, locales y series, modo pruebas y producción, el camino de un comprobante ante SUNAT y la diferencia entre resumen diario y envío directo. Antes de integrar, conviene tener claras seis ideas. Con ellas entiendes por qué una boleta queda «registrada» y una factura «aceptada» al instante, o por qué tu clave emite en un local y no en otro. ## Empresa y dirección de la API Cada empresa de Sistema Facturación Perú tiene su propio subdominio y su propia API: | Entorno | Dirección | Clave | |---|---|---| | Producción | `https://.sistemafacturacionperu.com/api/v1` | `sfp_live_…` | | Pruebas | `https://pruebas.sistemafacturacionperu.com/api/v1` | `sfp_test_…` | Todo lo que emites por la API pasa por **las mismas reglas que la web**: series, régimen tributario, Ficha RUC, detracción, anticipos, deuda vencida del cliente, control de stock y límite del plan. Lo que la web no deja hacer, la API tampoco. Las respuestas siempre tienen la misma forma: ```json { "success": true, "data": { "…": "…" }, "meta": { "page": 1, "per_page": 20, "total": 87, "last_page": 5 } } ``` ```json { "success": false, "code": "serie_invalida", "message": "La serie ingresada es incorrecta…", "errors": { "serie_documento": ["…"] } } ``` Decide por el `code` (es estable) y muestra el `message` (está en español). La lista completa está en el [catálogo de errores](https://www.sistemafacturacionperu.com/desarrolladores/errores/). ## Locales (sedes) y series Una clave actúa como el **usuario** que elegiste al crearla: sus permisos, su local y sus series. Para emitir en otro local, manda el código SUNAT del local en `establecimiento`: ```json { "establecimiento": "0001", "serie_documento": "F002", "…": "…" } ``` `GET /establecimientos` te da cada local con sus series y almacenes: ```json { "success": true, "data": [{ "id": 1, "codigo": "0000", "nombre": "Tienda principal", "direccion": "Av. Lima 123", "ubigeo": "150101", "series": [{ "tipo": "01", "serie": "F001" }, { "tipo": "03", "serie": "B001" }, { "tipo": "09", "serie": "T001" }], "almacenes": [{ "id": 1, "nombre": "Almacén Lima", "principal": true }] }] } ``` - Una clave creada **con local fijo** solo emite en ese local (403 `establecimiento_no_permitido` en otro). - Una serie que no es del local o del tipo de comprobante responde 422 `serie_invalida`. - `"numero_documento": "#"` toma el siguiente correlativo de la serie. Es lo recomendado. ## Modo pruebas y producción | | Pruebas | Producción | |---|---|---| | Empresa | Una empresa de pruebas aparte, compartida | La tuya | | SUNAT | Beta de SUNAT: nada tiene validez tributaria | Producción: comprobantes válidos | | Lo que ves | Solo lo que emitió tu clave | Todo lo de tu empresa que tu usuario puede ver | | Clientes y productos | Compartidos entre todas las claves de pruebas: no pongas datos reales | Los tuyos | | Duración | Se borra a los 7 días | Se guarda los años que pide SUNAT | | Plan | Las claves de pruebas se crean desde el Plan PRO | Desde el Plan PRO | El modo pruebas **no cambia nada de tu empresa**: no consume tus correlativos ni tu stock y no cuenta para el límite de tu plan. Una clave `sfp_test_` contra la dirección de producción responde 401 `clave_de_pruebas`. ## El camino de un comprobante Cada comprobante tiene un `estado` con un código SUNAT, una clave legible y una descripción: ```json { "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" } } ``` | `clave` | `codigo` | Qué significa | |---|---|---| | `registrado` | 01 | Creado y firmado. Aún no está en SUNAT (o va en el resumen diario). | | `enviado` | 03 | Enviado; SUNAT todavía no responde (pasa con resúmenes y bajas, que van por ticket). | | `aceptado` | 05 | SUNAT lo aceptó. Ya tiene CDR. | | `observado` | 07 | Aceptado con observaciones: es válido, pero revisa la nota de SUNAT. | | `rechazado` | 09 | SUNAT lo rechazó: no tiene validez. Corrige y emite uno nuevo. | | `por_anular` | 13 | Pediste la anulación y SUNAT todavía no responde. | | `anulado` | 11 | Anulado con una comunicación de baja o un resumen de anulación. | El camino normal es `registrado` → `enviado` → `aceptado` / `observado` / `rechazado`, y después, si lo anulas, `por_anular` → `anulado`. La respuesta de SUNAT queda en `sunat`: ```json { "sunat": { "codigo": "0", "descripcion": "La Factura numero F001-120, ha sido aceptada", "notas": [] } } ``` No hace falta preguntar el estado cada minuto: registra un [webhook](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/) y te avisamos con `comprobante.aceptado`, `comprobante.observado`, `comprobante.rechazado` o `comprobante.anulado`. ## Resumen diario frente a envío directo SUNAT recibe los comprobantes de dos formas, y el sistema elige la correcta por ti: | | Facturas y sus notas | Boletas y sus notas | |---|---|---| | Cómo llegan a SUNAT | **Envío directo**: uno por uno, al emitir (según la configuración de la empresa) | Dentro del **resumen diario** | | Estado al emitir | Normalmente `aceptado` u `observado` en la misma respuesta | `registrado` | | Quién arma el envío | La API, en la misma llamada | El sistema, solo: dos veces al día envía las boletas de los días anteriores y consulta los resúmenes enviados | | Para anular | Comunicación de baja | Resumen de anulación | Por eso `POST /comprobantes/{id}/reenviar-sunat` solo sirve para facturas y sus notas: una boleta responde 422 `va_en_resumen`. Lo explicamos en detalle en la guía del [resumen diario](https://www.sistemafacturacionperu.com/desarrolladores/guias/resumen-diario/). ## Orígenes: facturar lo que ya existe Para facturar una cotización, un pedido, una nota de venta o una guía, manda `origen`. Queda enlazado como en la web y la mercadería no sale dos veces del stock: ```json { "origen": { "tipo": "cotizacion", "id": 15 }, "serie_documento": "F001", "…": "…" } ``` `tipo` puede ser `cotizacion`, `pedido`, `nota_venta` o `guia`. Si el origen no existe o no se puede facturar responde 422 `origen_no_existe`, `origen_no_permitido` o `nota_venta_no_facturable`. ## Productos, lotes, series y almacenes Las líneas usan tu `codigo_interno`. Si la empresa tiene **control de stock**, un código que no existe responde 422 `producto_no_existe`: créalo antes con `POST /productos` (nace sin stock). Lotes, números de serie y almacén van en cada línea: ```json { "codigo_interno": "CEL-A15", "almacen_id": 2, "series": ["356938035643809", "356938035643817"], "lotes": [{ "codigo": "L-01", "cantidad": 2 }] } ``` El stock por almacén, lote y serie lo consultas con `GET /stock` (alcance `stock.leer`). ## Cobros Un comprobante al contado lleva sus `pagos` al emitirse. Para los cobros posteriores (una factura a crédito, por ejemplo) usa `POST /comprobantes/{id}/cobros` con la cuenta de cobro y el número de operación si no es efectivo, y `GET /comprobantes/{id}/cobros` para saber cuánto falta cobrar. Es el mismo registro que **Por cobrar › Registrar cobro** de la web. ## Sigue con - [Autenticación](https://www.sistemafacturacionperu.com/desarrolladores/autenticacion/): claves de empresa, de socio y la conexión con un botón. - [Guías por comprobante](https://www.sistemafacturacionperu.com/desarrolladores/guias/): el JSON mínimo de cada uno. - [Límites, idempotencia, paginación y versiones](https://www.sistemafacturacionperu.com/desarrolladores/limites-y-versiones/). --- # Autenticación URL: https://www.sistemafacturacionperu.com/desarrolladores/autenticacion/ Las tres formas de llamar a la API: claves de empresa con alcances, token de socio con client_credentials y la conexión con un botón (OAuth 2.0 con PKCE). Hay tres formas de autenticarte. Elige según **de quién es la cuenta** que vas a usar: | Si eres… | Usa | Token | |---|---|---| | La empresa (o su programador) y emites con tu propia cuenta | **Clave de empresa** | `sfp_live_…` / `sfp_test_…` | | Un socio que da de alta a sus clientes como empresas | **Clave de socio** (OAuth 2 *client_credentials*) | `sfp_pat_…` (1 hora) | | Un sistema cuyos clientes **ya tienen** cuenta en Sistema Facturación Perú | **Conectar con Sistema Facturación Perú** (OAuth 2 + PKCE) | `sfp_oat_…` (1 hora) | En los tres casos el token va **solo** en la cabecera `Authorization: Bearer `. En la URL o en el cuerpo responde 401 (`clave_en_url` en la API v1, `token_en_url` en la de socios), porque quedaría guardado en los registros de los servidores. ## Claves de empresa Se crean en **Configuración › Sistema › Integraciones › API** (desde el Plan PRO). Cada empresa puede tener varias, cada una con: - un **nombre** (por ejemplo, «Tienda en línea»), para reconocerla en el registro de llamadas; - un **usuario**: la clave actúa como él, con sus funciones, su local y sus series; - **alcances**: lo que la clave puede hacer, aunque el usuario pueda más; - un **local** fijo, si quieres que solo emita en uno; - una fecha de **vencimiento**, si quieres. Formato: `sfp_live_` + 40 caracteres en producción y `sfp_test_` + 40 en pruebas. Guardamos solo su huella: la ves **una sola vez** al crearla. Si la pierdes, revócala y crea otra. La pantalla muestra cuándo se usó por última vez. ### Alcances | Alcance | Permite | |---|---| | `comprobantes.emitir` | Emitir comprobantes, notas de venta, cotizaciones, retenciones, percepciones y liquidaciones; reenviar a SUNAT | | `comprobantes.leer` | Ver comprobantes, descargar PDF, XML y CDR, enviarlos por correo o WhatsApp | | `comprobantes.anular` | Anular (baja o resumen) | | `guias.emitir` / `guias.leer` | Emitir y ver guías de remisión | | `cobros.registrar` | Registrar cobros | | `catalogo.leer` / `catalogo.escribir` | Ver y cambiar clientes, productos y locales | | `stock.leer` | Ver stock por almacén, lote y serie | | `consultas` | RUC, DNI y tipo de cambio | | `webhooks.gestionar` | Crear y borrar webhooks | Cada llamada revisa dos cosas: que la **clave** tenga el alcance y que su **usuario** tenga la función en el sistema. - Falta el alcance → 403 `scope_faltante`, con la cabecera `X-SFP-Scope-Required` que dice cuál. - Falta la función del usuario → 403 `sin_permiso`. - El plan no incluye la API → 403 `plan_sin_api`. - Pago vencido o cuenta suspendida → 423 `cuenta_bloqueada`. > **Da a cada clave solo lo que necesita.** Una tienda en línea que emite boletas no necesita `comprobantes.anular` ni > `catalogo.escribir`. Si la clave se filtra, el daño queda acotado. ### El token antiguo El token del usuario que usa la app móvil también vale en la v1, con todos los alcances; en el registro de llamadas sale como «legacy». Funciona, pero te recomendamos pasar a una clave v1: tiene alcances, vencimiento y se revoca sin tocar al usuario. ```bash curl https://miempresa.sistemafacturacionperu.com/api/v1/yo \ -H "Authorization: Bearer $SFP_API_KEY" ``` ```php true, CURLOPT_USERPWD => getenv('SFP_CLIENT_ID') . ':' . getenv('SFP_CLIENT_SECRET'), CURLOPT_POSTFIELDS => http_build_query(['grant_type' => 'client_credentials']), ]); $token = json_decode(curl_exec($ch), true)['access_token']; // sfp_pat_…, vale 1 hora ``` ```js const basic = Buffer.from(`${process.env.SFP_CLIENT_ID}:${process.env.SFP_CLIENT_SECRET}`).toString('base64'); const res = await fetch('https://api.sistemafacturacionperu.com/socios/v1/token', { method: 'POST', headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials' }), }); const { access_token } = await res.json(); // sfp_pat_…, vale 1 hora ``` ```python r = requests.post( "https://api.sistemafacturacionperu.com/socios/v1/token", auth=(os.environ["SFP_CLIENT_ID"], os.environ["SFP_CLIENT_SECRET"]), data={"grant_type": "client_credentials"}, timeout=30, ) token = r.json()["access_token"] # sfp_pat_…, vale 1 hora ``` Con ese token das de alta empresas y creas, para cada una, una **clave de empresa** `sfp_live_` con la que emites en su API v1. Todo el detalle, los precios y las bolsas están en la [API de socios](https://www.sistemafacturacionperu.com/desarrolladores/socios/). ## Conectar con Sistema Facturación Perú Si tu sistema tiene clientes que **ya usan** Sistema Facturación Perú, no les pidas que copien una clave: ponles un botón «Conectar con Sistema Facturación Perú». Es OAuth 2.0 con *authorization code* y **PKCE S256 obligatorio**, como Google, Mercado Pago o Stripe. 1. **Tu botón** abre una ventana emergente en `https://api.sistemafacturacionperu.com/conectar` con tu `client_id`, tu `redirect_uri`, un `state` y el `code_challenge` de PKCE. 2. **El usuario escribe su RUC** y entra con su usuario y contraseña de siempre, en el subdominio de su empresa. Su contraseña nunca pasa por tu sistema. 3. **Pulsa «Permitir»** en la pantalla que le muestra tu aplicación y los permisos que pides. 4. **Vuelve a tu `redirect_uri`** con un `code` de un solo uso (5 minutos) y tu `state`. 5. **Tu servidor canjea el código** en `POST /oauth/token` con tu secreto y el `code_verifier`, y recibe un token de 1 hora, uno de renovación y la dirección de la API de esa empresa. 6. **Emites** en `empresa.api_url` con `Authorization: Bearer sfp_oat_…`. Así se genera el par PKCE: ```js import { randomBytes, createHash } from 'node:crypto'; const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const codeVerifier = b64url(randomBytes(48)); // 64 caracteres: guárdalo en la sesión const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest()); const state = b64url(randomBytes(24)); ``` ```php rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); $codeVerifier = $b64url(random_bytes(48)); $codeChallenge = $b64url(hash('sha256', $codeVerifier, true)); $state = $b64url(random_bytes(24)); $_SESSION['sfp'] = ['verifier' => $codeVerifier, 'state' => $state]; ``` ```python import base64, hashlib, secrets b64url = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode() code_verifier = b64url(secrets.token_bytes(48)) code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest()) state = b64url(secrets.token_bytes(24)) ``` En el Plan Básico la conexión sirve **solo para emitir**; en PRO y Empresa, para todo. El botón completo, el canje, la renovación y la desconexión están en [Conectar con Sistema Facturación Perú](https://www.sistemafacturacionperu.com/desarrolladores/conectar/). ## Buenas prácticas - Guarda claves, secretos y tokens en variables de entorno o en un gestor de secretos. Nunca en el repositorio, en una app móvil ni en el navegador. - Una clave por sistema o integración: si un sistema se da de baja, revocas su clave y los demás siguen. - Pon fecha de vencimiento a las claves de integraciones temporales. - Revisa el **Registro de llamadas** (Configuración › Sistema › Integraciones › API › Registro): guarda 30 días de llamadas con fecha, clave, ruta, resultado, tiempo e IP. --- # Guías por comprobante URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/ Qué endpoint usar para cada comprobante electrónico (factura, boleta, notas, guías, retención, percepción, liquidación) y cómo llega cada uno a SUNAT. Cada guía trae el **JSON mínimo** que funciona, los campos que más se equivocan y lo que pasa con SUNAT después. Los ejemplos van contra el modo pruebas; en producción solo cambia la dirección y la clave. | Comprobante | Código SUNAT | Endpoint | Llega a SUNAT | Guía | |---|---|---|---|---| | Factura | 01 | `POST /comprobantes` | Envío directo | [Factura](https://www.sistemafacturacionperu.com/desarrolladores/guias/factura/) | | Boleta de venta | 03 | `POST /comprobantes` | Resumen diario | [Boleta](https://www.sistemafacturacionperu.com/desarrolladores/guias/boleta/) | | Nota de crédito | 07 | `POST /comprobantes` | Como su comprobante | [Notas de crédito y débito](https://www.sistemafacturacionperu.com/desarrolladores/guias/notas-de-credito-y-debito/) | | Nota de débito | 08 | `POST /comprobantes` | Como su comprobante | [Notas de crédito y débito](https://www.sistemafacturacionperu.com/desarrolladores/guias/notas-de-credito-y-debito/) | | Comunicación de baja / resumen de anulación | RA / RC | `POST /comprobantes/{id}/anular` | Por ticket | [Anular](https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/) | | Resumen diario | RC | Automático | Por ticket | [Resumen diario](https://www.sistemafacturacionperu.com/desarrolladores/guias/resumen-diario/) | | Guía de remisión remitente | 09 | `POST /guias` | API de guías de SUNAT, por ticket | [Guías de remisión](https://www.sistemafacturacionperu.com/desarrolladores/guias/guias-de-remision/) | | Guía de remisión transportista | 31 | `POST /guias` | API de guías de SUNAT, por ticket | [Guías de remisión](https://www.sistemafacturacionperu.com/desarrolladores/guias/guias-de-remision/) | | Comprobante de retención | 20 | `POST /retenciones` | Envío directo | [Retención](https://www.sistemafacturacionperu.com/desarrolladores/guias/retenciones/) | | Comprobante de percepción | 40 | `POST /percepciones` | Envío directo | [Percepción](https://www.sistemafacturacionperu.com/desarrolladores/guias/percepciones/) | | Liquidación de compra | 04 | `POST /liquidaciones-compra` | Envío directo | [Liquidación de compra](https://www.sistemafacturacionperu.com/desarrolladores/guias/liquidaciones-de-compra/) | Detracción, anticipos, exportación, operaciones gratuitas, impuesto a las bolsas (ICBPER) y ventas a crédito están en [Casos especiales](https://www.sistemafacturacionperu.com/desarrolladores/guias/casos-especiales/). ## Antes de emitir - **Plan.** Retenciones, percepciones y liquidaciones de compra vienen desde el Plan PRO, igual que en la web. - **Régimen y Ficha RUC.** SUNAT no permite facturas a un contribuyente del Nuevo RUS (422 `regimen_no_permite`) y una empresa nueva no emite hasta verificar su Ficha RUC (422 `ficha_ruc_pendiente`). - **Series.** Usa las de tu local (`GET /establecimientos`). Las series de factura empiezan con F, las de boleta con B y las de guía de remitente con T. - **Totales.** Tú mandas los importes de cada línea y los totales: calcula con dos decimales y redondea al final de cada línea. Si no cuadran, SUNAT observa o rechaza el comprobante (por ejemplo, la observación 4287 por un precio unitario que no coincide con el cálculo). - **Idempotency-Key.** Mándala en cada emisión: si reintentas con la misma, no se emite dos veces ([cómo funciona](https://www.sistemafacturacionperu.com/desarrolladores/limites-y-versiones/)). ## Notas de venta y cotizaciones No son comprobantes electrónicos (no van a SUNAT), pero se registran con el mismo JSON: - `POST /notas-venta`: el JSON de los comprobantes sin `codigo_tipo_documento`, con una serie de notas de venta (NV01…) y, si quieres, `origen` de una cotización o un pedido. Para facturarla después: `POST /comprobantes` con `"origen": {"tipo": "nota_venta", "id": 88}`. - `POST /cotizaciones`: el JSON de los comprobantes sin serie, con `fecha_de_vencimiento`, `fecha_de_entrega` y `observaciones` opcionales. Las dos vienen desde el Plan PRO. --- # Factura electrónica por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/factura/ JSON mínimo para emitir una factura electrónica SUNAT por API, al contado o a crédito, desde una cotización, y cómo enviarla por correo o WhatsApp. La factura (tipo `01`) es para vender a empresas y negocios con RUC. Se envía a SUNAT **en la misma llamada** (según la configuración de la empresa), así que la respuesta ya suele traer `aceptado` u `observado` y la respuesta de SUNAT. - Endpoint: `POST /comprobantes` · alcance `comprobantes.emitir`. - Serie: una de factura de tu local (`F001`…). `"numero_documento": "#"` toma el siguiente correlativo. ## JSON mínimo Una factura de S/ 1.180 (40 bolsas de cemento a S/ 29,50 con IGV) pagada en efectivo: ```json { "serie_documento": "F001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "10:15:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "01", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20601234567", "apellidos_y_nombres_o_razon_social": "CONSTRUCTORA ANDINA S.A.C.", "codigo_pais": "PE", "ubigeo": "150101", "direccion": "Av. Lima 123", "correo_electronico": "compras@andina.pe" }, "totales": { "total_operaciones_gravadas": 1000, "total_igv": 180, "total_impuestos": 180, "total_valor": 1000, "total_venta": 1180 }, "items": [{ "codigo_interno": "CEM-42", "descripcion": "Cemento 42.5 kg", "unidad_de_medida": "NIU", "cantidad": 40, "valor_unitario": 25, "codigo_tipo_precio": "01", "precio_unitario": 29.5, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 1000, "porcentaje_igv": 18, "total_igv": 180, "total_impuestos": 180, "total_valor_item": 1000, "total_item": 1180 }], "pagos": [{ "codigo_metodo_pago": "01", "codigo_destino_pago": "cash", "monto": 1180 }] } ``` Guárdalo como `factura.json` y envíalo: ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: factura-$(uuidgen)" \ -d @factura.json ``` ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => file_get_contents('factura.json'), ]); $factura = json_decode(curl_exec($ch), true); ``` ```js import { readFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: await readFile('factura.json', 'utf8'), }); const factura = await res.json(); ``` ```python import json, os, uuid, requests r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes", json=json.load(open("factura.json", encoding="utf-8")), headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, timeout=90, ) factura = r.json() ``` Respuesta (201): ```json { "success": true, "data": { "id": 1521, "external_id": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "tipo": "01", "numero_completo": "F001-120", "total": 1180, "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" }, "sunat": { "codigo": "0", "descripcion": "La Factura numero F001-120, ha sido aceptada", "notas": [] }, "enlaces": { "pdf": "…/comprobantes/1521/pdf", "xml": "…/comprobantes/1521/xml", "cdr": "…/comprobantes/1521/cdr" } } } ``` ## Los campos que más se equivocan | Campo | Qué poner | |---|---| | `datos_del_cliente_o_receptor.codigo_tipo_documento_identidad` | `6` (RUC). Una factura a un DNI la rechaza SUNAT (2017: el receptor debe tener RUC). | | `codigo_tipo_operacion` | `0101` venta interna; `1001` si está sujeta a detracción; `0200` exportación. | | `codigo_tipo_afectacion_igv` | `10` gravado, `20` exonerado, `30` inafecto, `40` exportación. | | `valor_unitario` / `precio_unitario` | Sin IGV / con IGV. Con `codigo_tipo_precio: "01"` (precio unitario). | | `unidad_de_medida` | Catálogo SUNAT: `NIU` (unidad, bienes), `ZZ` (servicios), `KGM`, `MTR`, `LTR`… | | `pagos[].codigo_metodo_pago` | `01` efectivo; los demás son los medios de pago de tu empresa. | | `pagos[].codigo_destino_pago` | `cash` (caja) o el id de una cuenta de cobro. | ## Factura a crédito Con `"codigo_condicion_de_pago": "02"` y las `cuotas`. SUNAT pide al menos una cuota en una venta a crédito (3249): ```json { "codigo_condicion_de_pago": "02", "cuotas": [ { "fecha": "2026-12-04", "codigo_tipo_moneda": "PEN", "monto": 590 }, { "fecha": "2027-01-04", "codigo_tipo_moneda": "PEN", "monto": 590 } ] } ``` Sin `pagos`, la factura queda en **Por cobrar**. Registra cada pago después con `POST /comprobantes/{id}/cobros`: ```json { "fecha": "2026-12-04", "monto": 590, "medio": "005", "cuenta_cobro_id": 2, "referencia": "OP-778812" } ``` `medio` es el código del medio de pago de tu empresa; si no es efectivo, va con la cuenta de cobro (`cuenta_cobro_id`, un banco o billetera de ese tipo) y el número de operación. Sin cuenta, el cobro entra a la caja. Un cobro mayor que el saldo responde 422 `cobro_excede_saldo`. `GET /comprobantes/{id}/cobros` dice cuánto falta. ## Facturar una cotización, un pedido o una nota de venta Agrega `origen` y el comprobante queda enlazado, como en la web: ```json { "origen": { "tipo": "cotizacion", "id": 15 } } ``` ## Enviarla a tu cliente `POST /comprobantes/{id}/enviar` la manda por correo o por el **WhatsApp de tu empresa** (la línea que conectaste en Canales › WhatsApp; nunca por una línea de la plataforma): ```json { "canal": "whatsapp", "destino": "987654321" } ``` ```json { "canal": "correo", "destino": "compras@andina.pe, contabilidad@andina.pe" } ``` Si el proveedor de correo o de WhatsApp falla, responde 502 `envio_fallido`. ## Si SUNAT no responde Si al emitir SUNAT no contestó, la factura queda `registrado`. Reenvíala con `POST /comprobantes/{id}/reenviar-sunat` (con su propia `Idempotency-Key`). Una que ya está aceptada responde 409 `no_reenviable`. ## Errores frecuentes | `code` | Qué pasó | |---|---| | `serie_invalida` | La serie no es de factura o no es de tu local. | | `regimen_no_permite` | Tu régimen (Nuevo RUS) no emite facturas. | | `deuda_vencida` | El cliente tiene deuda vencida y tu empresa restringe la venta en ese caso. | | `producto_no_existe` | Con control de stock, el `codigo_interno` no existe: créalo con `POST /productos`. | | `detraccion_minimo` | La detracción no aplica a ese monto ([casos especiales](https://www.sistemafacturacionperu.com/desarrolladores/guias/casos-especiales/)). | | `limite_plan` | Tu plan ya no permite emitir más este ciclo. | El resto está en el [catálogo de errores](https://www.sistemafacturacionperu.com/desarrolladores/errores/). --- # Boleta de venta electrónica por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/boleta/ JSON mínimo para emitir una boleta de venta electrónica por API, cuándo pedir el DNI del comprador y cómo la informa el resumen diario a SUNAT. La boleta (tipo `03`) es para vender al consumidor final. A diferencia de la factura, **no viaja sola a SUNAT**: el sistema la informa en el [resumen diario](https://www.sistemafacturacionperu.com/desarrolladores/guias/resumen-diario/). Por eso la respuesta trae `registrado` y el estado cambia a `aceptado` cuando SUNAT procesa el resumen. - Endpoint: `POST /comprobantes` · alcance `comprobantes.emitir`. - Serie: una de boleta de tu local (`B001`…). ## JSON mínimo ```json { "serie_documento": "B001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "10:15:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "03", "codigo_tipo_moneda": "PEN", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "1", "numero_documento": "70123456", "apellidos_y_nombres_o_razon_social": "Ana Pérez", "codigo_pais": "PE" }, "totales": { "total_operaciones_gravadas": 50.85, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor": 50.85, "total_venta": 60 }, "items": [{ "codigo_interno": "POLO-01", "descripcion": "Polo azul talla M", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 25.42, "codigo_tipo_precio": "01", "precio_unitario": 30, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 50.85, "porcentaje_igv": 18, "total_igv": 9.15, "total_impuestos": 9.15, "total_valor_item": 50.85, "total_item": 60 }], "pagos": [{ "codigo_metodo_pago": "01", "codigo_destino_pago": "cash", "monto": 60 }] } ``` El paso a paso con cURL, PHP, Node.js y Python está en el [inicio rápido](https://www.sistemafacturacionperu.com/desarrolladores/inicio-rapido/). ## El comprador | Documento | `codigo_tipo_documento_identidad` | |---|---| | DNI | `1` | | Carné de extranjería | `4` | | RUC | `6` | | Pasaporte | `7` | | Otros (sin documento) | `0` | - Si el total pasa de **S/ 700**, SUNAT exige identificar al comprador con su documento. - Para una venta menor sin datos del comprador, usa el tipo `0` y el mismo cliente genérico que usa tu empresa en la web. - Un DNI que no tiene 8 dígitos lo rechaza SUNAT (2801). Puedes validarlo antes con [`GET /consultas/dni/{numero}`](https://www.sistemafacturacionperu.com/desarrolladores/consultas/). ## Ticket o A4 El PDF sale en el formato que pidas: `GET /comprobantes/{id}/pdf?formato=ticket`, `ticket_58`, `ticket_50`, `a5` o `a4` (el de siempre). La respuesta de la emisión ya trae `enlaces.pdf_ticket`. ## Cuándo queda aceptada 1. Emites: `registrado`. 2. El sistema envía el resumen diario con las boletas de los días anteriores y consulta su ticket: el resumen queda `enviado`. 3. SUNAT lo procesa: cada boleta pasa a `aceptado` (o `rechazado`) y te llegan los webhooks `resumen.procesado` y `comprobante.aceptado`. No necesitas llamar a nada para que esto pase. Si consultas una boleta con `GET /comprobantes/{id}` verás su estado al momento. ## Anular una boleta `POST /comprobantes/{id}/anular` con un `motivo`: el sistema arma el **resumen de anulación** (no la comunicación de baja, que es para facturas). Solo se anulan boletas aceptadas u observadas. Detalle en [Anular](https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/). --- # Notas de crédito y de débito por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/notas-de-credito-y-debito/ Cómo emitir notas de crédito y de débito electrónicas por API: el comprobante que modifican, el tipo de nota, el tope de la nota de crédito y su envío a SUNAT. Una nota de crédito (tipo `07`) baja el importe de un comprobante ya emitido: una devolución, un descuento o la anulación de la operación. Una nota de débito (tipo `08`) lo sube: intereses por mora o un aumento en el valor. Las dos van con el mismo endpoint que la factura y la boleta. - Endpoint: `POST /comprobantes` · alcance `comprobantes.emitir`. - Serie: una de notas del tipo que corresponde (las de una factura empiezan con F y las de una boleta con B). Revisa las de tu local en `GET /establecimientos`. - Llegan a SUNAT como su comprobante: la nota de una factura se envía directo y la de una boleta va en el resumen diario. ## Nota de crédito: anulación de la operación ```json { "serie_documento": "FC01", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "16:40:00", "codigo_tipo_documento": "07", "codigo_tipo_moneda": "PEN", "codigo_tipo_nota": "01", "motivo_o_sustento_de_nota": "Anulación de la operación", "documento_afectado": { "external_id": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d" }, "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20601234567", "apellidos_y_nombres_o_razon_social": "CONSTRUCTORA ANDINA S.A.C.", "codigo_pais": "PE" }, "totales": { "total_operaciones_gravadas": 1000, "total_igv": 180, "total_impuestos": 180, "total_valor": 1000, "total_venta": 1180 }, "items": [{ "codigo_interno": "CEM-42", "descripcion": "Cemento 42.5 kg", "unidad_de_medida": "NIU", "cantidad": 40, "valor_unitario": 25, "codigo_tipo_precio": "01", "precio_unitario": 29.5, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 1000, "porcentaje_igv": 18, "total_igv": 180, "total_impuestos": 180, "total_valor_item": 1000, "total_item": 1180 }] } ``` - `documento_afectado` acepta el `external_id` del comprobante **o** su serie, número y tipo: `{"serie_documento": "F001", "numero_documento": "120", "codigo_tipo_documento": "01"}`. Si no existe, responde 422 `documento_afectado_no_existe`. - La nota no lleva `codigo_tipo_operacion` ni `pagos`. - Para una devolución parcial, manda solo las líneas y cantidades que devuelves, con sus totales. ## Tipos de nota más usados | Nota | `codigo_tipo_nota` | Para qué | |---|---|---| | Crédito | `01` | Anulación de la operación | | Crédito | `06` | Devolución total | | Crédito | `07` | Devolución por ítem | | Crédito | `13` | Ajustes de montos o fechas de pago (cuotas de una venta a crédito) | | Débito | `01` | Intereses por mora | | Débito | `02` | Aumento en el valor | Son los catálogos 09 (crédito) y 10 (débito) de SUNAT. ## El tope de la nota de crédito La suma de las notas de crédito de un comprobante no puede pasar de lo que queda de él. Si pasa, responde 422 `nota_credito_excede` con lo que aún se puede acreditar. Es la misma regla de la web. ## Nota de crédito o anulación Si el comprobante está aceptado y quieres dejarlo sin efecto, tienes dos caminos: - **Anularlo** con `POST /comprobantes/{id}/anular`: comunicación de baja (factura) o resumen de anulación (boleta). SUNAT pone plazos para la baja; si ya pasaron, usa la nota de crédito. Ver [Anular](https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/). - **Emitir una nota de crédito** de tipo `01`: el comprobante sigue aceptado y la nota lo compensa. Si dudas, la nota de crédito es el camino que siempre está disponible. --- # Anular comprobantes: comunicación de baja y resumen de anulación URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/ Anula facturas, boletas y sus notas por API con una sola llamada: el sistema elige la comunicación de baja o el resumen de anulación y te da el ticket de SUNAT. Con una sola llamada anulas cualquier comprobante aceptado. **El sistema elige el camino** que pide SUNAT: | Comprobante | Cómo se anula ante SUNAT | |---|---| | Factura y sus notas | Comunicación de baja | | Boleta y sus notas | Resumen diario de anulación | - Endpoint: `POST /comprobantes/{id}/anular` · alcance `comprobantes.anular`. - Solo comprobantes `aceptado` u `observado`. Uno que aún no está en SUNAT, que ya está anulado o que no se puede anular responde 409 `ya_anulado` o 422 `no_anulable`. ## Pide la anulación ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1521/anular \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: anular-1521" \ -d '{"motivo": "Error en el precio"}' ``` ```php true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: anular-1521', ], CURLOPT_POSTFIELDS => json_encode(['motivo' => 'Error en el precio']), ]); $anulacion = json_decode(curl_exec($ch), true)['data']; // ['tipo' => 'baja', 'ticket' => '…', …] ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1521/anular', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'anular-1521', }, body: JSON.stringify({ motivo: 'Error en el precio' }), }); const { data: anulacion } = await res.json(); // { tipo: 'baja', ticket: '…', … } ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes/1521/anular", json={"motivo": "Error en el precio"}, headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": "anular-1521"}, timeout=90, ) anulacion = r.json()["data"] ``` El `motivo` va de 3 a 100 caracteres. La respuesta es **202**: la anulación se envió y SUNAT la procesa por ticket. ```json { "success": true, "data": { "tipo": "baja", "ticket": "1730646000123", "external_id": "a1b2c3d4-…", "estado": { "codigo": "03", "clave": "enviado", "descripcion": "Enviado" }, "consultar": "https://pruebas.sistemafacturacionperu.com/api/v1/anulaciones/1730646000123" } } ``` Usar como `Idempotency-Key` algo como `anular-` es buena idea: si repites la llamada por un corte de red, no se pide dos veces. ## Consulta el resultado `GET /anulaciones/{ticket}` consulta el ticket en SUNAT en ese momento y devuelve el estado de la baja o del resumen: ```json { "success": true, "data": { "tipo": "baja", "ticket": "1730646000123", "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" }, "sunat": { "codigo": "0", "descripcion": "La Comunicacion de baja RA-20261104-1, ha sido aceptada", "notas": [] } } } ``` Después, `GET /comprobantes/{id}` muestra el comprobante `anulado`. Mejor todavía: con un [webhook](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/) te llegan `resumen.procesado` (SUNAT procesó la baja o el resumen) y `comprobante.anulado` (el comprobante quedó anulado), sin preguntar. El sistema también consulta solo las bajas que quedaron sin respuesta. ## Mientras tanto El comprobante queda `por_anular` hasta que SUNAT responde. Si SUNAT rechaza la baja (por ejemplo, porque pasó su plazo), el comprobante no queda anulado: en ese caso emite una [nota de crédito](https://www.sistemafacturacionperu.com/desarrolladores/guias/notas-de-credito-y-debito/). --- # Resumen diario de boletas URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/resumen-diario/ Cómo informa el sistema tus boletas a SUNAT con el resumen diario automático, cuándo quedan aceptadas y qué avisos te llegan, sin una sola llamada extra. Las boletas y sus notas no se envían una por una: SUNAT las recibe agrupadas en el **resumen diario**. Con la API v1 **no tienes que armarlo ni enviarlo**: el sistema lo hace por ti, igual que para las boletas que emites desde la web. ## Cómo funciona 1. Emites boletas con `POST /comprobantes` durante el día. Quedan `registrado`. 2. Dos veces al día, el sistema arma el resumen con las boletas **de los días anteriores** (nunca las del día en curso, que todavía puede tener ventas) y lo envía a SUNAT. Las boletas pasan a `enviado`. 3. SUNAT responde por ticket. El sistema consulta los resúmenes enviados y, cuando SUNAT los procesa, cada boleta pasa a `aceptado` o `rechazado`. Viene encendido en todas las empresas y no depende de que tengas la web abierta. Las boletas que no se pudieron enviar siguen `registrado` y entran en la siguiente corrida. ## Qué te avisa Con un [webhook](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/) suscrito recibes: | Evento | Cuándo | |---|---| | `resumen.procesado` | SUNAT procesó un resumen diario (o una comunicación de baja) | | `comprobante.aceptado` | Cada boleta (o nota) del resumen quedó aceptada | | `comprobante.rechazado` | Una boleta del resumen fue rechazada | Si no usas webhooks, consulta el estado con `GET /comprobantes/{id}` o lista las aceptadas del día con `GET /comprobantes?tipo=03&desde=2026-11-03&hasta=2026-11-03&estado=aceptado`. ## Lo que no hace falta - **No** existe un endpoint de la v1 para crear resúmenes: el sistema los crea solo. - **No** reenvíes boletas con `POST /comprobantes/{id}/reenviar-sunat`: responde 422 `va_en_resumen`. - Para **anular** una boleta sí hay una llamada: `POST /comprobantes/{id}/anular` arma el resumen de anulación ([Anular](https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/)). ## En la web La empresa ve cada resumen en **Ventas › Comprobantes › Estado SUNAT › Resúmenes diarios**, con su ticket y la respuesta de SUNAT. > **Para socios:** cada resumen diario que sale por nuestro PSE descuenta 1 de tu bolsa, y se envía aunque tu saldo > esté en cero (dejar boletas sin informar sería peor). Ver [API de socios](https://www.sistemafacturacionperu.com/desarrolladores/socios/). --- # Guías de remisión electrónicas por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/guias-de-remision/ Emite guías de remisión remitente (09) y transportista (31) con la API nueva de SUNAT en una sola llamada: crear, enviar y consultar el ticket. La guía de remisión respalda el traslado de mercadería. La API v1 la emite **en una sola llamada**: la crea, la envía a la API de guías de SUNAT y consulta el ticket una vez. Si SUNAT todavía no responde, queda `pendiente` y la vuelves a consultar con `GET /guias/{id}`. | Guía | `codigo_tipo_documento` | Quién la emite | |---|---|---| | Remitente | `09` | El dueño de la mercadería que la traslada (venta, traslado entre locales, compra…) | | Transportista | `31` | La empresa de transporte que lleva carga ajena y cobra el flete | - Endpoint: `POST /guias` · alcance `guias.emitir`. - Series: las de guía de tu local (`GET /establecimientos`); por norma de SUNAT las de remitente empiezan con T y las de transportista con V. - En producción la empresa necesita sus **credenciales de guías** (usuario secundario SOL, Client ID y Client secret) o enviar por un PSE. Ver [pase a producción](https://www.sistemafacturacionperu.com/desarrolladores/pase-a-produccion/). ## Guía de remitente: JSON mínimo Traslado de 40 bolsas de cemento por venta, en transporte privado (con chofer y vehículo propios): ```json { "serie_documento": "T001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "09:00:00", "codigo_tipo_documento": "09", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20601234567", "apellidos_y_nombres_o_razon_social": "CONSTRUCTORA ANDINA S.A.C." }, "codigo_modo_transporte": "02", "codigo_motivo_traslado": "01", "fecha_de_traslado": "2026-11-04", "peso_total": 1000, "unidad_peso_total": "KGM", "direccion_partida": { "ubigeo": "150101", "direccion": "Av. Lima 123", "codigo_del_domicilio_fiscal": "0000" }, "direccion_llegada": { "ubigeo": "150132", "direccion": "Jr. Las Obras 456", "codigo_del_domicilio_fiscal": "0000" }, "chofer": { "codigo_tipo_documento_identidad": "1", "numero_documento": "40123456", "nombres": "Luis Quispe", "numero_licencia": "Q40123456" }, "vehiculo": { "numero_de_placa": "ABC123" }, "items": [ { "codigo_interno": "CEM-42", "descripcion": "Cemento 42.5 kg", "unidad_de_medida": "NIU", "cantidad": 40 } ] } ``` ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/guias \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: guia-$(uuidgen)" \ -d @guia.json ``` ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => file_get_contents('guia.json'), ]); $guia = json_decode(curl_exec($ch), true)['data']; ``` ```js import { readFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/guias', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: await readFile('guia.json', 'utf8'), }); const { data: guia } = await res.json(); ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/guias", json=json.load(open("guia.json", encoding="utf-8")), headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, timeout=90, ) guia = r.json()["data"] ``` Respuesta (201), aceptada o pendiente: ```json { "success": true, "data": { "id": 310, "external_id": "4c5d6e7f-…", "tipo": "09", "numero_completo": "T001-58", "fecha_emision": "2026-11-04", "fecha_traslado": "2026-11-04", "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" }, "ticket": "a9b8c7d6-…", "sunat": { "codigo": "0", "descripcion": "…", "notas": [] }, "enlaces": { "pdf": "…/guias/310/pdf", "xml": "…/guias/310/xml", "cdr": "…/guias/310/cdr" }, "avisos": [] } } ``` Si el envío falló, la guía queda creada y `avisos` dice qué pasó y cómo reintentar: `POST /guias/{id}/enviar`. ## Campos clave | Campo | Qué poner | |---|---| | `codigo_modo_transporte` | `01` transporte público (lo lleva una empresa de transporte) · `02` transporte privado (tu chofer y tu vehículo) | | `codigo_motivo_traslado` | `01` venta · `02` compra · `04` traslado entre locales de la misma empresa · `13` otros (con `descripcion_motivo_traslado`) | | `peso_total` / `unidad_peso_total` | Peso bruto total; `KGM` (kilos) o `TNE` (toneladas) | | `direccion_partida` / `direccion_llegada` | Ubigeo de 6 dígitos y dirección de hasta 100 caracteres; `codigo_del_domicilio_fiscal` es el código SUNAT del local | | `transportista` | Solo con transporte público: `codigo_tipo_documento_identidad`, `numero_documento` (RUC), `apellidos_y_nombres_o_razon_social` y `numero_mtc` | | `chofer` / `vehiculo` | Con transporte privado: documento, nombres y licencia del chofer; placa del vehículo | | `items[].codigo_interno` | Obligatorio en cada línea (con `descripcion` y `cantidad` mayor que 0) | Con **transporte público**, cambia `chofer` y `vehiculo` por la empresa de transporte: ```json { "codigo_modo_transporte": "01", "transportista": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20512345678", "apellidos_y_nombres_o_razon_social": "TRANSPORTES RAPIDOS S.A.C.", "numero_mtc": "1234567CNG" } } ``` ## La guía de una venta (y al revés) - Para emitir la guía de una cotización o un pedido, manda `"origen": {"tipo": "pedido", "id": 33}`. - Para facturar lo que ya salió con una guía, emite la factura con `"origen": {"tipo": "guia", "id": 310}`: queda amarrada y la mercadería no se descuenta dos veces. Si el amarre no se puede hacer, la factura igual se emite y `avisos` lo dice. ## Guía de transportista (31) La emite la empresa de transporte. En vez del cliente lleva al **remitente** y al **destinatario** de la carga: ```json { "codigo_tipo_documento": "31", "datos_remitente": { "codigo_tipo_documento_identidad": "6", "descripcion_tipo_documento_identidad": "RUC", "numero_documento": "20601234567", "apellidos_y_nombres_o_razon_social": "FERRETERIA LIMA S.A.C." }, "datos_destinatario": { "codigo_tipo_documento_identidad": "6", "descripcion_tipo_documento_identidad": "RUC", "numero_documento": "20601234568", "apellidos_y_nombres_o_razon_social": "CONSTRUCTORA ANDINA S.A.C." } } ``` Lo demás (fechas, peso, partida, llegada, chofer, vehículo e ítems) va como en la de remitente. Te recomendamos probar tu primera guía de transportista en el modo pruebas antes de pasar a producción. ## Estados y avisos - `aceptado` o `rechazado`: SUNAT respondió el ticket en la misma llamada. - `pendiente`: SUNAT aún no responde. `GET /guias/{id}` vuelve a consultar el ticket. - Webhooks: `guia.aceptada` y `guia.rechazada`. - Archivos: `GET /guias/{id}/pdf`, `/xml` y `/cdr`. > Si tu plan ya llegó a su límite de comprobantes del ciclo, la guía responde 422 `limite_plan` antes de crear nada, > como en la web. --- # Comprobante de retención electrónico por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/retenciones/ JSON mínimo para emitir el comprobante de retención (20) por API cuando eres agente de retención del IGV: proveedor, facturas pagadas e importes retenidos. El comprobante de retención (tipo `20`) lo emite el **agente de retención** designado por SUNAT cuando paga facturas a sus proveedores y retiene un porcentaje del IGV. Casi ninguna MYPE lo es: si SUNAT no te lo comunicó, no lo necesitas. - Endpoint: `POST /retenciones` · alcance `comprobantes.emitir` · desde el Plan PRO. - En la empresa: **Configuración › Empresa › Datos y facturación SUNAT › «Soy agente de retención»**. - Usa el mismo JSON de la API antigua (`/api/retentions`); el local va en `establecimiento` (código SUNAT) o sale de tu clave. ## JSON mínimo Pagas una factura de S/ 1.000 a un proveedor y retienes el 3 % (S/ 30): ```json { "serie_documento": "R001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "11:00:00", "codigo_tipo_documento": "20", "datos_del_proveedor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20512345678", "apellidos_y_nombres_o_razon_social": "DISTRIBUIDORA NORTE S.A.C.", "codigo_pais": "PE", "ubigeo": "150101", "direccion": "Av. Industrial 450" }, "codigo_tipo_retencion": "01", "observaciones": "Retención del pago de la factura F001-120", "totales": { "total_retenido": 30, "total_pagado": 970 }, "documentos": [{ "codigo_tipo_documento": "01", "serie_documento": "F001", "numero_documento": "120", "fecha_de_emision": "2026-11-01", "codigo_tipo_moneda": "PEN", "total_documento": 1000, "pagos": [{ "fecha_de_pago": "2026-11-04", "total_pago": 1000, "codigo_tipo_moneda": "PEN" }], "fecha_de_retencion": "2026-11-04", "total_retenido": 30, "total_a_pagar": 970, "total_pagado": 970 }] } ``` ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/retenciones \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: retencion-$(uuidgen)" \ -d @retencion.json ``` ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => file_get_contents('retencion.json'), ]); $retencion = json_decode(curl_exec($ch), true)['data']; ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/retenciones', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: await readFile('retencion.json', 'utf8'), }); const { data: retencion } = await res.json(); ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/retenciones", json=json.load(open("retencion.json", encoding="utf-8")), headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, timeout=90, ) retencion = r.json()["data"] ``` Respuesta (201): ```json { "success": true, "data": { "id": 33, "tipo": "20", "numero_completo": "R001-33", "fecha_emision": "2026-11-04", "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" }, "sunat": { "codigo": "0", "descripcion": "El Comprobante numero R001-33, ha sido aceptado", "notas": [] }, "enlaces_publicos": { "pdf": "…", "xml": "…", "cdr": "…" } } } ``` ## Notas - `codigo_tipo_retencion` `01` es la tasa de 3 % del régimen de retenciones del IGV. - El proveedor (`datos_del_proveedor`) se crea o actualiza en tus proveedores con esos datos. - Cada línea de `documentos` es un comprobante del proveedor que estás pagando. Puedes poner varios. - Si pagas en dólares, agrega en el documento `tipo_de_cambio`: `{"codigo_tipo_moneda_referencia": "USD", "codigo_tipo_moneda_objetivo": "PEN", "factor": 3.741, "fecha_tipo_de_cambio": "2026-11-04"}`. El tipo de cambio de SUNAT del día sale de [`GET /consultas/tipo-cambio`](https://www.sistemafacturacionperu.com/desarrolladores/consultas/). - No lo confundas con el campo `retencion` de una factura de venta (cuando tu cliente es agente de retención y te retiene a ti): ese va dentro de `POST /comprobantes` y no se combina con una detracción (422 `detraccion_con_retencion`, ver [casos especiales](https://www.sistemafacturacionperu.com/desarrolladores/guias/casos-especiales/)). --- # Comprobante de percepción electrónico por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/percepciones/ JSON mínimo para emitir el comprobante de percepción (40) por API cuando eres agente de percepción: cliente, comprobantes cobrados e importes percibidos. El comprobante de percepción (tipo `40`) lo emite el **agente de percepción** designado por SUNAT cuando cobra a sus clientes un porcentaje adicional al venderles ciertos bienes (por ejemplo, combustible o harina). Si SUNAT no te lo comunicó, no lo necesitas. - Endpoint: `POST /percepciones` · alcance `comprobantes.emitir` · desde el Plan PRO. - En la empresa: **Configuración › Empresa › Datos y facturación SUNAT › «Soy agente de percepción»**. - Usa el mismo JSON de la API antigua (`/api/perceptions`); el local sale de tu clave o de `establecimiento`. ## JSON mínimo Cobras una factura de S/ 1.000 y percibes el 2 % (S/ 20): ```json { "serie_documento": "P001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "11:30:00", "codigo_tipo_documento": "40", "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "6", "numero_documento": "20601234567", "apellidos_y_nombres_o_razon_social": "BODEGA LIMA S.A.C.", "codigo_pais": "PE", "ubigeo": "150101", "direccion": "Av. Lima 1" }, "codigo_tipo_percepcion": "01", "observaciones": "Percepción de la factura F001-121", "totales": { "total_percibido": 20, "total_pagado": 1020 }, "documentos": [{ "codigo_tipo_documento": "01", "serie_documento": "F001", "numero_documento": "121", "fecha_de_emision": "2026-11-02", "codigo_tipo_moneda": "PEN", "total_documento": 1000, "pagos": [{ "fecha_de_pago": "2026-11-04", "total_pago": 1000, "codigo_tipo_moneda": "PEN" }], "fecha_de_percepcion": "2026-11-04", "total_percibido": 20, "total_a_pagar": 1020, "total_pagado": 1020 }] } ``` Se envía igual que la [retención](https://www.sistemafacturacionperu.com/desarrolladores/guias/retenciones/), a `POST /percepciones`: ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/percepciones \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: percepcion-$(uuidgen)" \ -d @percepcion.json ``` ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => file_get_contents('percepcion.json'), ]); $percepcion = json_decode(curl_exec($ch), true)['data']; ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/percepciones', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: await readFile('percepcion.json', 'utf8'), }); const { data: percepcion } = await res.json(); ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/percepciones", json=json.load(open("percepcion.json", encoding="utf-8")), headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, timeout=90, ) percepcion = r.json()["data"] ``` ## Tipos de percepción | `codigo_tipo_percepcion` | Tasa | Para qué | |---|---|---| | `01` | 2 % | Percepción venta interna | | `02` | 1 % | Adquisición de combustible | | `03` | 0,5 % | Agente de percepción con tasa especial | Son el catálogo 22 de SUNAT. La percepción siempre es en soles: si el comprobante cobrado es en dólares, agrega su `tipo_de_cambio` como en la retención. --- # Liquidación de compra electrónica por API URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/liquidaciones-de-compra/ JSON mínimo para emitir la liquidación de compra electrónica (04) por API cuando compras a una persona sin RUC: vendedor, lugar de la operación, ítems y totales. La liquidación de compra (tipo `04`) la emites **tú, el comprador**, cuando le compras a una persona natural que no tiene RUC ni puede darte un comprobante (por ejemplo, un productor agrícola o un recolector). Sustenta tu compra y el crédito fiscal. - Endpoint: `POST /liquidaciones-compra` · alcance `comprobantes.emitir` · desde el Plan PRO. - Usa el mismo JSON de la API antigua (`/api/purchase-settlements`); el local sale de tu clave o de `establecimiento`. - Serie: la de liquidaciones de compra de tu local (`GET /establecimientos`, tipo `04`). En el ejemplo, `L001`. ## JSON mínimo Compras 500 kg de papa a S/ 1,18 el kilo con IGV: ```json { "serie_documento": "L001", "numero_documento": "#", "fecha_de_emision": "2026-11-04", "hora_de_emision": "08:30:00", "codigo_tipo_operacion": "0101", "codigo_tipo_documento": "04", "codigo_tipo_moneda": "PEN", "datos_del_proveedor_o_receptor": { "codigo_tipo_documento_identidad": "1", "numero_documento": "41234567", "apellidos_y_nombres_o_razon_social": "Juan Mamani Quispe", "codigo_pais": "PE", "ubigeo": "080101", "direccion": "Comunidad San Jerónimo s/n" }, "datos_de_la_operacion": { "codigo_pais": "PE", "ubigeo": "080101", "direccion": "Comunidad San Jerónimo s/n" }, "totales": { "total_operaciones_gravadas": 500, "total_igv": 90, "total_impuestos": 90, "total_valor": 500, "subtotal_compra": 590, "total_compra": 590 }, "items": [{ "codigo_interno": "PAPA-01", "descripcion": "Papa blanca", "unidad_de_medida": "KGM", "cantidad": 500, "valor_unitario": 1, "codigo_tipo_precio": "01", "precio_unitario": 1.18, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 500, "porcentaje_igv": 18, "total_igv": 90, "total_impuestos": 90, "total_valor_item": 500, "total_item": 590 }] } ``` ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/liquidaciones-compra \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: liquidacion-$(uuidgen)" \ -d @liquidacion.json ``` ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => file_get_contents('liquidacion.json'), ]); $liquidacion = json_decode(curl_exec($ch), true)['data']; ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/liquidaciones-compra', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: await readFile('liquidacion.json', 'utf8'), }); const { data: liquidacion } = await res.json(); ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/liquidaciones-compra", json=json.load(open("liquidacion.json", encoding="utf-8")), headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, timeout=90, ) liquidacion = r.json()["data"] ``` ## Campos clave | Campo | Qué poner | |---|---| | `datos_del_proveedor_o_receptor` | La persona que te vende: normalmente con DNI (`1`) | | `datos_de_la_operacion` | Dónde se hizo la compra: país, ubigeo y dirección | | `totales.subtotal_compra` / `total_compra` | El total de la compra (en las ventas se llaman `subtotal_venta` y `total_venta`) | | `items[].codigo_interno` | Obligatorio en cada línea | Si corresponde retener el impuesto a la renta, cada línea acepta `codigo_tipo_afectacion_igv_impuesto_renta`, `porcentaje_retencion_renta` y `monto_retencion_renta`. La respuesta tiene la misma forma que la de la [retención](https://www.sistemafacturacionperu.com/desarrolladores/guias/retenciones/): número, estado, respuesta de SUNAT y enlaces públicos al PDF, XML y CDR. --- # Casos especiales: detracción, anticipos, exportación, gratuitas e ICBPER URL: https://www.sistemafacturacionperu.com/desarrolladores/guias/casos-especiales/ Los campos que cambian en una factura con detracción, anticipos, exportación, moneda extranjera, operaciones gratuitas o impuesto a las bolsas plásticas (ICBPER). Todos estos casos van en `POST /comprobantes` con el JSON de la [factura](https://www.sistemafacturacionperu.com/desarrolladores/guias/factura/). Aquí solo están **los campos que cambian**. El servidor aplica las mismas reglas que la pantalla de la web, así que la API no deja emitir lo que la web tampoco deja. > **Prueba cada caso en el modo pruebas** antes de pasarlo a producción: la beta de SUNAT valida los importes igual que > producción y te dice qué corregir. ## Detracción Para una venta sujeta al sistema de detracciones (SPOT): ```json { "codigo_tipo_operacion": "1001", "detraccion": { "codigo_tipo_detraccion": "037", "porcentaje": 12, "monto": 141.6, "codigo_metodo_pago": "001", "cuenta_bancaria": "00-000-123456" } } ``` - `codigo_tipo_operacion` va con `1001` (o `1004` si es transporte de carga). Con otra operación responde 422 `detraccion_operacion`. - `codigo_tipo_detraccion` es el código del bien o servicio (catálogo 54 de SUNAT) y tiene que estar activo en tu empresa (**Ventas › Configuración › Detracciones**); si no, 422 `detraccion_codigo`. - **Mínimo por código.** Cada código aplica desde un monto: el `037` (demás servicios gravados con el IGV), por ejemplo, a ventas mayores a S/ 700. Por debajo responde 422 `detraccion_minimo`, salvo que tu empresa tenga encendida «Detracción para montos menores» en Ventas › Configuración. En moneda extranjera, el mínimo se compara en soles con `factor_tipo_de_cambio`. - **Sin retención junto.** SUNAT no aplica detracción y retención del IGV a la misma venta: si mandas las dos, 422 `detraccion_con_retencion`. - `cuenta_bancaria` es tu cuenta de detracciones del Banco de la Nación y `codigo_metodo_pago` el medio de pago del catálogo 59 (`001` depósito en cuenta). **Transporte de carga (`1004`).** La detracción acepta además `ubigeo_origen`, `direccion_origen`, `ubigeo_destino`, `direccion_destino`, `detalle_viaje`, `valor_referencial_servicio_transporte`, `valor_referencial_carga_util` y `valor_referencia_carga_efectiva`. El mínimo se compara con el mayor entre el total y el valor referencial del servicio. ## Anticipos Un anticipo es un pago antes de entregar el bien o el servicio. Se hace en dos pasos: 1. **Emite el comprobante del anticipo** como una factura normal, con `"pago_anticipado": true`. 2. **En la factura final, descuéntalo.** Lista el anticipo en `anticipos` y su descuento global, y pon el importe en `totales.total_anticipos`: ```json { "anticipos": [ { "numero": "F001-25", "codigo_tipo_documento": "02", "monto": 500, "total": 590 } ], "descuentos": [ { "codigo": "04", "descripcion": "Descuento global por anticipos gravados", "factor": 0.25, "monto": 500, "base": 2000 } ], "totales": { "total_anticipos": 590, "total_descuentos": 500, "total_operaciones_gravadas": 1500, "total_igv": 270, "total_impuestos": 270, "total_valor": 1500, "total_venta": 1770 } } ``` - `numero` es la serie y el número del anticipo (`F001-25`); `codigo_tipo_documento` es el tipo del catálogo 12 (`02` factura de anticipo, `03` boleta de anticipo). - El descuento global usa el código `04` si el anticipo está gravado (`05` exonerado, `06` inafecto). - El servidor revisa cada anticipo: que exista, que sea de anticipo, que no esté anulado y que tenga saldo para lo que descuentas. Si no, 422 `anticipo_no_existe` o `anticipo_sin_saldo` (con el saldo que le queda). ## Exportación ```json { "codigo_tipo_operacion": "0200", "codigo_tipo_moneda": "USD", "factor_tipo_de_cambio": 3.741, "datos_del_cliente_o_receptor": { "codigo_tipo_documento_identidad": "0", "numero_documento": "98-7654321", "apellidos_y_nombres_o_razon_social": "ANDES IMPORTS LLC", "codigo_pais": "US", "direccion": "1200 Brickell Ave, Miami FL" }, "totales": { "total_exportacion": 1000, "total_igv": 0, "total_impuestos": 0, "total_valor": 1000, "total_venta": 1000 }, "items": [{ "codigo_interno": "CAF-EXP", "descripcion": "Café en grano tostado, saco de 25 kg", "unidad_de_medida": "NIU", "cantidad": 10, "valor_unitario": 100, "codigo_tipo_precio": "01", "precio_unitario": 100, "codigo_tipo_afectacion_igv": "40", "total_base_igv": 1000, "porcentaje_igv": 18, "total_igv": 0, "total_impuestos": 0, "total_valor_item": 1000, "total_item": 1000 }] } ``` - `0200` es exportación de bienes (catálogo 51). Las líneas van con afectación `40` y sin IGV. - El cliente del exterior va con el tipo `0` (no domiciliado, sin RUC), su número de identificación y su país. ## Moneda extranjera Cualquier comprobante puede ir en dólares: `"codigo_tipo_moneda": "USD"` y `"factor_tipo_de_cambio": 3.741`. Las `cuotas` van en la misma moneda. El tipo de cambio de SUNAT del día sale de [`GET /consultas/tipo-cambio`](https://www.sistemafacturacionperu.com/desarrolladores/consultas/). ## Operaciones gratuitas Un bien que entregas sin cobrar (un premio, una muestra, una bonificación) va en el comprobante con su valor referencial, pero **no suma** al total que paga el cliente. En la línea gratuita: | Campo | Qué poner | |---|---| | `codigo_tipo_afectacion_igv` | El de transferencia gratuita: `11` a `16` si está gravada (por ejemplo, `15` bonificación), `21` exonerada, `31` a `37` inafecta | | `codigo_tipo_precio` | `02` (valor referencial en operaciones no onerosas) | | `valor_unitario` | `0` | | `precio_unitario` | El valor referencial unitario | En los totales, el valor de lo gratuito va en `total_operaciones_gratuitas` (y su IGV, si está gravada, en `total_igv_operaciones_gratuitas`), fuera de `total_venta`. Si SUNAT responde **2640** («Operación gratuita, solo debe consignar un monto referencial»), revisa que la línea no tenga precio cobrado. ## Impuesto a las bolsas plásticas (ICBPER) Si entregas bolsas de plástico, cada bolsa paga el ICBPER. En la línea de la bolsa: ```json { "codigo_interno": "BOLSA-01", "descripcion": "Bolsa plástica", "unidad_de_medida": "NIU", "cantidad": 2, "valor_unitario": 0.08, "codigo_tipo_precio": "01", "precio_unitario": 0.1, "codigo_tipo_afectacion_igv": "10", "total_base_igv": 0.17, "porcentaje_igv": 18, "total_igv": 0.03, "total_impuestos_bolsa_plastica": 1, "total_impuestos": 1.03, "total_valor_item": 0.17, "total_item": 1.2 } ``` - `total_impuestos_bolsa_plastica` de la línea es la cantidad de bolsas por el monto por bolsa (S/ 0,50 desde 2023). El monto por bolsa sale de la configuración de tu empresa. - En `totales`, suma ese impuesto en `total_impuestos_bolsa_plastica`, en `total_impuestos` y en `total_venta`. ## Cargos y descuentos Los descuentos y cargos por línea o globales van en `descuentos` y `cargos` con `codigo` (catálogo 53), `descripcion`, `factor`, `monto` y `base`, y sus sumas en `totales.total_descuentos` y `totales.total_cargos`. --- # Webhooks URL: https://www.sistemafacturacionperu.com/desarrolladores/webhooks/ Recibe un aviso firmado cuando SUNAT acepta, observa o rechaza un comprobante. Eventos, verificación de la firma en PHP, Node.js y Python, reintentos y reenvíos. En vez de preguntar cada minuto «¿ya lo aceptó SUNAT?», registra una dirección de tu sistema y te avisamos cuando cambia el estado. Funciona para lo que emites por la API **y** para lo que la empresa emite desde la web. ## Eventos | Evento | Cuándo | |---|---| | `comprobante.aceptado` | SUNAT aceptó un comprobante (también cada boleta cuando se procesa su resumen diario) | | `comprobante.observado` | SUNAT lo aceptó con observaciones | | `comprobante.rechazado` | SUNAT lo rechazó | | `comprobante.anulado` | Quedó anulado (baja o resumen de anulación) | | `guia.aceptada` / `guia.rechazada` | Respuesta de SUNAT a una guía de remisión | | `resumen.procesado` | SUNAT procesó un resumen diario o una comunicación de baja | La API de socios tiene sus propios eventos (`empresa.lista`, `saldo.bajo`, `cuota.vencida`…, ver [API de socios](https://www.sistemafacturacionperu.com/desarrolladores/socios/)) y «Conectar con Sistema Facturación Perú» avisa `conexion.revocada` ([ver](https://www.sistemafacturacionperu.com/desarrolladores/conectar/)). Todos se firman igual. ## Registra tu webhook En **Configuración › Sistema › Integraciones › API › Webhooks**, o por la API con el alcance `webhooks.gestionar`: ```bash curl -X POST https://pruebas.sistemafacturacionperu.com/api/v1/webhooks \ -H "Authorization: Bearer $SFP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: webhook-mi-sistema" \ -d '{"url": "https://mi-sistema.pe/avisos-sfp", "eventos": ["comprobante.aceptado", "comprobante.rechazado"]}' ``` ```php true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: webhook-mi-sistema', ], CURLOPT_POSTFIELDS => json_encode([ 'url' => 'https://mi-sistema.pe/avisos-sfp', 'eventos' => ['comprobante.aceptado', 'comprobante.rechazado'], ]), ]); $webhook = json_decode(curl_exec($ch), true)['data']; // Guarda $webhook['secreto'] (whsec_…) en tu servidor: no se vuelve a mostrar. ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'webhook-mi-sistema', }, body: JSON.stringify({ url: 'https://mi-sistema.pe/avisos-sfp', eventos: ['comprobante.aceptado', 'comprobante.rechazado'] }), }); const { data: webhook } = await res.json(); // Guarda webhook.secreto (whsec_…) en tu servidor: no se vuelve a mostrar. ``` ```python r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/webhooks", json={"url": "https://mi-sistema.pe/avisos-sfp", "eventos": ["comprobante.aceptado", "comprobante.rechazado"]}, headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": "webhook-mi-sistema"}, timeout=30, ) webhook = r.json()["data"] # guarda webhook["secreto"] (whsec_…): no se vuelve a mostrar ``` - La dirección tiene que ser **https** y de un dominio público. - La respuesta trae el **secreto `whsec_…` una sola vez**. Con él verificas cada aviso. - Hasta 10 webhooks por empresa (422 `limite_webhooks` si te pasas). `GET /webhooks` los lista junto con los eventos disponibles; `DELETE /webhooks/{id}` borra uno. - Con una clave de pruebas, sus webhooks avisan solo de lo que emitió esa clave. ## Qué te llega Un `POST` con un JSON y tres cabeceras: ```http POST /avisos-sfp HTTP/1.1 Content-Type: application/json X-SFP-Event: comprobante.aceptado X-SFP-Delivery: 0b8f3c1e-2d4a-4f7e-9a61-6c5d2e1f0a9b X-SFP-Signature: t=1762269309,v1=5f2c0b7e9d… ``` ```json { "id": "0b8f3c1e-2d4a-4f7e-9a61-6c5d2e1f0a9b", "evento": "comprobante.aceptado", "creado": "2026-11-04T10:15:09-05:00", "data": { "tipo_documento": "comprobante", "id": 1520, "external_id": "6f1c7d0e-8f7e-4a1c-9d5b-2a2f8e1c9b10", "tipo": "01", "numero_completo": "F001-120", "fecha_emision": "2026-11-04", "total": 1180, "moneda": "PEN", "estado": { "codigo": "05", "clave": "aceptado", "descripcion": "Aceptado" } } } ``` `id` es igual a `X-SFP-Delivery`: guárdalo y **no proceses dos veces** el mismo aviso (un reintento trae el mismo id). ## Verifica la firma `X-SFP-Signature` tiene la hora del envío (`t`, segundos Unix) y la firma (`v1`): un HMAC-SHA256, en hexadecimal, con tu secreto `whsec_…` sobre `"."`. Calcula la firma sobre el **cuerpo tal como llegó** (no sobre el JSON ya decodificado), compárala en tiempo constante y rechaza los avisos con más de 5 minutos. ```php '…', 'v1' => '…'] $esperada = hash_hmac('sha256', ($partes['t'] ?? '') . '.' . $cuerpo, getenv('SFP_WEBHOOK_SECRET')); if (!hash_equals($esperada, $partes['v1'] ?? '') || abs(time() - (int) ($partes['t'] ?? 0)) > 300) { http_response_code(400); exit; } $aviso = json_decode($cuerpo, true); // Encola el trabajo y responde rápido: $aviso['id'], $aviso['evento'], $aviso['data']['external_id']… http_response_code(200); ``` ```js // Node.js con Express: lee el cuerpo crudo solo en esta ruta. import crypto from 'node:crypto'; import express from 'express'; const app = express(); app.post('/avisos-sfp', express.raw({ type: 'application/json' }), (req, res) => { const partes = Object.fromEntries(String(req.get('X-SFP-Signature') || '').split(',').map((p) => p.split('='))); const esperada = crypto .createHmac('sha256', process.env.SFP_WEBHOOK_SECRET) .update(`${partes.t}.`) .update(req.body) .digest('hex'); const ok = typeof partes.v1 === 'string' && partes.v1.length === esperada.length && crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v1)) && Math.abs(Date.now() / 1000 - Number(partes.t)) <= 300; if (!ok) return res.sendStatus(400); const aviso = JSON.parse(req.body.toString('utf8')); // Encola el trabajo y responde rápido: aviso.id, aviso.evento, aviso.data.external_id… res.sendStatus(200); }); ``` ```python # Python con Flask import hashlib, hmac, json, os, time from flask import Flask, request, abort app = Flask(__name__) @app.post("/avisos-sfp") def avisos_sfp(): cuerpo = request.get_data() # bytes, tal como llegaron partes = dict(p.split("=", 1) for p in request.headers.get("X-SFP-Signature", "").split(",") if "=" in p) esperada = hmac.new(os.environ["SFP_WEBHOOK_SECRET"].encode(), partes.get("t", "").encode() + b"." + cuerpo, hashlib.sha256).hexdigest() if not hmac.compare_digest(esperada, partes.get("v1", "")) or abs(time.time() - int(partes.get("t", 0))) > 300: abort(400) aviso = json.loads(cuerpo) # Encola el trabajo y responde rápido: aviso["id"], aviso["evento"], aviso["data"]["external_id"]… return "", 200 ``` ## Responde rápido y reintentamos - Responde **2xx en menos de 10 segundos**. Si tienes trabajo pesado, encólalo y responde primero. - No seguimos redirecciones: un 301 o 302 cuenta como fallo. - Si no responde 2xx, reintentamos a **1 min, 5 min, 30 min, 2 h, 6 h y 24 h**. Después la entrega queda `fallida`. - Las entregas se guardan 30 días. `GET /webhooks/{id}/entregas?estado=fallido` las lista (con el código de respuesta y el error) y `POST /webhooks/entregas/{id}/reenviar` vuelve a mandar una ya mismo. También puedes reenviar desde la pantalla. ## Prueba en tu equipo Para recibir avisos mientras desarrollas, expón tu servidor local con un túnel https (por ejemplo `ngrok` o `cloudflared`) y registra esa dirección en un webhook de tu **clave de pruebas**. Emite una factura en el modo pruebas y mira llegar `comprobante.aceptado`. --- # Catálogo de errores URL: https://www.sistemafacturacionperu.com/desarrolladores/errores/ Todos los códigos de error de la API v1, la API de socios y Conectar, con su causa y qué hacer, y los rechazos y observaciones de SUNAT más comunes explicados. Cuando algo sale mal, la respuesta trae siempre un `code` **estable** y un `message` en español: ```json { "success": false, "code": "validacion", "message": "Revisa los datos enviados.", "errors": { "totales.total_venta": ["El campo totales.total venta es obligatorio."] } } ``` - **Decide por el `code`**, nunca por el texto del mensaje: el texto puede mejorar, el código no cambia. - **Muestra el `message`** a tu usuario si te sirve: está pensado para leerse. - `errors` aparece en las validaciones, con los campos que fallaron. - En los 5xx no hay detalles internos. Reintenta con la misma `Idempotency-Key`. ## API v1 ### Autenticación y permisos | HTTP | `code` | Causa | Qué hacer | |---|---|---|---| | 401 | `clave_invalida` | Falta la clave o no existe | Envíala en `Authorization: Bearer ` | | 401 | `clave_revocada` | La revocaron | Crea otra en Configuración › Sistema › Integraciones › API | | 401 | `clave_vencida` | Pasó su fecha de vencimiento | Crea otra o usa una sin vencimiento | | 401 | `clave_en_url` | La mandaste en la URL o en el cuerpo | Solo en la cabecera `Authorization` | | 401 | `clave_de_pruebas` | Una clave `sfp_test_` fuera del modo pruebas | Usa `pruebas.sistemafacturacionperu.com` o una clave `sfp_live_` | | 401 | `token_invalido`, `token_vencido` | Conectar: el token no sirve o venció | Renuévalo con tu token de renovación | | 401 | `conexion_revocada` | Conectar: la empresa desconectó tu aplicación | Pídele que vuelva a conectar | | 401 | `aplicacion_suspendida` | Conectar: suspendimos tu aplicación | Escríbenos | | 403 | `scope_faltante` | La clave no tiene el alcance (cabecera `X-SFP-Scope-Required`) | Crea una clave con ese alcance | | 403 | `sin_permiso` | El usuario de la clave no tiene esa función | Dale la función al usuario o usa otra clave | | 403 | `plan_sin_api` | El plan no incluye la API | La API viene desde el Plan PRO | | 403 | `plan_insuficiente` | Conectar en Básico: esa función viene desde PRO (`errors.plan`, cabecera `X-SFP-Plan-Required`) | La empresa sube de plan; no hace falta reconectar | | 403 | `establecimiento_no_permitido` | La clave tiene un local fijo y pediste otro | Usa una clave sin local fijo o la de ese local | | 423 | `cuenta_bloqueada` | Pago vencido o cuenta suspendida | La empresa regulariza su pago | ### Petición | HTTP | `code` | Causa | Qué hacer | |---|---|---|---| | 400 | `json_invalido` | El cuerpo no es JSON | Revisa el JSON y `Content-Type: application/json` | | 400 | `idempotencia_invalida` | La `Idempotency-Key` está mal formada | Hasta 255 caracteres visibles; usa un UUID | | 400 | `peticion_invalida` | La petición está mal armada | Revisa método, cabeceras y cuerpo | | 404 | `no_encontrado` | No existe o tu clave no lo puede ver | Revisa el id; en pruebas, cada clave ve solo lo suyo | | 404 | `cdr_no_disponible` | El comprobante aún no tiene constancia de SUNAT | Espera a que esté aceptado | | 404 | `archivo_no_disponible` | El PDF o XML no está disponible | Reintenta en unos segundos | | 405 | `metodo_no_permitido` | Método equivocado para esa dirección | Revisa la [referencia](https://www.sistemafacturacionperu.com/desarrolladores/referencia/) | | 409 | `idempotencia_distinta` | Misma `Idempotency-Key` con otro cuerpo | Usa una clave nueva para cada operación distinta | | 409 | `idempotencia_en_proceso` | La primera petición con esa clave aún corre | Reintenta en unos segundos con la misma clave | | 409 | `duplicado` | Ya existe (un comprobante con esa serie y número, un cliente con ese documento…) | Consulta el que existe | | 409 | `ya_anulado` | Ya está anulado o en proceso de anulación | Nada: consulta su estado | | 409 | `no_reenviable` | No se puede reenviar a SUNAT (ya está aceptado, por ejemplo) | Consulta su estado | | 413 | `cuerpo_muy_grande` | El cuerpo es demasiado grande | Envía menos datos por llamada | | 422 | `validacion` | Faltan datos o tienen otro formato (detalle en `errors`) | Corrige los campos de `errors` | | 429 | `demasiadas_solicitudes` | Más de 120 llamadas por minuto con la clave | Espera los segundos de `Retry-After` | | 429 | `demasiadas_emisiones` | Más de 30 emisiones por minuto | Espera `Retry-After`; reparte las emisiones | ### Reglas del negocio (422) | `code` | Causa | Qué hacer | |---|---|---| | `limite_plan` | El plan ya no permite emitir más este ciclo | La empresa sube de plan o espera el siguiente ciclo | | `serie_invalida` | La serie no corresponde al tipo de comprobante o al local | Usa una de `GET /establecimientos` | | `establecimiento_no_existe` | No hay un local con ese código | Revisa `GET /establecimientos` | | `almacen_invalido` | El almacén no es del local | Usa uno de los almacenes del local | | `producto_no_existe` | Con control de stock, el `codigo_interno` no existe | Créalo con `POST /productos` | | `stock_insuficiente` | No alcanza el stock | Revisa `GET /stock` | | `lote_no_disponible`, `lote_sin_stock` | El lote no existe o no tiene stock | Usa otro lote | | `serie_no_disponible`, `series_cantidad` | Un número de serie no está disponible o no son tantos como la cantidad | Manda una serie por unidad | | `regla_de_inventario` | Otra regla del inventario (el mensaje dice cuál) | Sigue el mensaje | | `detraccion_minimo` | La detracción no aplica a ese monto | Quítala o activa «Detracción para montos menores» | | `detraccion_con_retencion` | Detracción y retención del IGV juntas | Quita la retención | | `detraccion_operacion` | Detracción con una operación que no es 1001-1004 | Usa `1001` (o `1004`) | | `detraccion_codigo` | El código de detracción no existe o está desactivado | Actívalo en Ventas › Configuración › Detracciones | | `anticipo_no_existe`, `anticipo_sin_saldo` | El anticipo no existe, no es de anticipo, está anulado o no tiene saldo | Revisa la serie-número y el saldo | | `regimen_no_permite` | SUNAT no permite ese comprobante a tu régimen (por ejemplo, facturas en el Nuevo RUS) | Emite el comprobante que te corresponde | | `ficha_ruc_pendiente` | Falta verificar la Ficha RUC de la empresa | Súbela en Configuración › Empresa › Régimen tributario | | `deuda_vencida` | El cliente tiene deuda vencida y la empresa restringe la venta | Cobra la deuda o cambia la regla en la empresa | | `nota_credito_excede` | La nota de crédito pasa lo que queda del comprobante | Baja el importe | | `documento_afectado_no_existe` | El comprobante que la nota modifica no existe | Revisa `documento_afectado` | | `origen_no_existe`, `origen_no_permitido`, `nota_venta_no_facturable` | El origen (cotización, pedido, nota de venta o guía) no existe o no se puede facturar | Revisa `origen` | | `no_anulable`, `va_en_resumen` | No se puede anular o reenviar (las boletas van en el resumen diario) | Ver [Anular](https://www.sistemafacturacionperu.com/desarrolladores/guias/anulaciones/) | | `no_cobrable`, `cobro_excede_saldo`, `cobro_invalido` | El cobro no corresponde o pasa el saldo | Revisa `GET /comprobantes/{id}/cobros` | | `limite_webhooks` | Ya tienes 10 webhooks | Borra uno para crear otro | | `certificado_faltante` | Falta el certificado digital | Súbelo en Configuración › Empresa › Certificado | | `ubigeo_invalido` | El ubigeo no existe | Usa el código INEI de 6 dígitos | | `sunat_rechazo` | SUNAT rechazó el comprobante (el mensaje trae su código) | Ver [errores de SUNAT](#errores-de-sunat) | | `regla_de_negocio` | Otra regla (el mensaje dice qué hacer) | Sigue el mensaje | ### Servicios externos | HTTP | `code` | Causa | Qué hacer | |---|---|---|---| | 500 | `error_interno` | Falla nuestra | Reintenta con la misma `Idempotency-Key`; si se repite, escríbenos con la hora | | 502 | `envio_fallido` | El correo o el WhatsApp no salió | Revisa el destino o la línea de WhatsApp de la empresa | | 502 | `sunat_no_responde` | SUNAT no respondió | Reintenta más tarde | | 502 / 503 | `consulta_no_disponible`, `no_disponible` | El servicio de consultas (RUC, DNI, tipo de cambio) no respondió | Reintenta más tarde | ### Empresas de un socio Además, la API v1 de una empresa dada de alta por un socio puede responder: | HTTP | `code` | Causa | |---|---|---| | 402 | `saldo_agotado` | El saldo del socio es 0 y la empresa envía por nuestro PSE: no se creó nada | | 402 | `cuota_pendiente` | El socio tiene una cuota mensual vencida | | 423 | `suspendida_por_socio` | El socio pausó la emisión por API de esa empresa | | 423 | `socio_suspendido` | La cuenta del socio está suspendida | ## API de socios | HTTP | `code` | Causa | |---|---|---| | 400 | `json_invalido`, `idempotencia_invalida` | La petición está mal armada | | 400 | `grant_no_soportado` | `POST /token` solo acepta `client_credentials` | | 400 | `secreto_en_url` | Mandaste el secreto en la URL | | 401 | `credenciales_invalidas` | El `client_id` o el `client_secret` no sirven (o la credencial se revocó) | | 401 | `token_invalido`, `token_vencido`, `token_en_url` | El token no sirve, venció (dura 1 hora) o fue en la URL | | 403 | `socio_suspendido` | Tu cuenta de socio está suspendida | | 403 | `ip_no_permitida` | La credencial está limitada a otras IP | | 404 | `no_encontrado` | No existe o no es tuya (nunca decimos si existe) | | 409 | `ruc_ya_registrado` | Ese RUC ya tiene cuenta en el sistema (no decimos de quién) | | 409 | `empresa_creandose` | El alta todavía corre (con `Retry-After`) | | 409 | `empresa_con_error` | El alta falló: escríbenos con el `id` | | 409 | `idempotencia_distinta`, `idempotencia_en_proceso` | Conflicto con la `Idempotency-Key` | | 422 | `validacion` | Datos que corregir (detalle en `errors`) | | 422 | `certificado_invalido` | El `.pfx` no abre con su clave | | 422 | `regla_de_negocio` | Otra regla (el mensaje dice cuál) | | 429 | `demasiadas_solicitudes`, `demasiados_intentos` | Espera `Retry-After` segundos | | 500 | `error_interno` | Falla nuestra: reintenta | La API antigua de reseller (`api/reseller/*`) responde 410 `api_retirada`: usa la API de socios. ## Conectar (OAuth 2.0) `POST /oauth/token` y `POST /oauth/revoke` responden con el formato estándar de OAuth (RFC 6749 §5.2): `{"error": "…", "error_description": "…"}`. | HTTP | `error` | Causa | |---|---|---| | 400 | `invalid_request` | Falta un parámetro o mandaste las credenciales de dos formas | | 400 | `invalid_grant` | El código o el token de renovación no sirve: vencido, ya usado, de otra aplicación, otra `redirect_uri` o PKCE que no corresponde | | 400 | `invalid_scope` | Pediste al renovar un permiso que la empresa no concedió | | 400 | `unsupported_grant_type` | Solo `authorization_code` y `refresh_token` | | 401 | `invalid_client` | `client_id` o `client_secret` equivocados, o tu aplicación está suspendida | | 429 | `temporarily_unavailable` | Demasiadas llamadas: espera `Retry-After` | En la ventana de conexión, los errores vuelven a tu `redirect_uri` como `?error=invalid_request|invalid_scope|access_denied…` (salvo si el `client_id` o la `redirect_uri` no están registrados: entonces se muestran en la pantalla y no redirigimos). ## Errores de SUNAT Cuando SUNAT responde, su código queda en `sunat.codigo` del comprobante (y en el mensaje de `sunat_rechazo`). SUNAT agrupa sus códigos así: | Rango | Qué significa | |---|---| | `0` | Aceptado | | `0100` a `1999` | Excepciones: SUNAT no procesó el envío (credenciales, archivo, servicio caído). Corrige y vuelve a enviar el **mismo** comprobante. | | `2000` a `3999` | Rechazo: el comprobante no es válido. Corrige y emite **otro** (con otro número). | | `4000` en adelante | Observaciones: el comprobante es válido (`observado`), pero corrige el dato para los siguientes. | Los que más aparecen, en palabras simples: | Código | SUNAT dice | Qué significa y qué hacer | |---|---|---| | `0102`, `0103`, `0104` | Usuario o contraseña incorrectos / el usuario no existe / la clave es incorrecta | El usuario secundario SOL o su clave están mal. Revísalos en Configuración › Empresa › Datos y facturación SUNAT. | | `0105` | El usuario no está activo | Activa el usuario secundario en SUNAT Operaciones en Línea. | | `0111` | No tiene el perfil para enviar comprobantes electrónicos | Al usuario secundario le falta el perfil de emisión electrónica: dáselo en SUNAT Operaciones en Línea. | | `0112` | El usuario debe ser secundario | Usa un usuario secundario, no el principal. | | `0109`, `0130` | El sistema no puede responder su solicitud | SUNAT está caído o lento. Reintenta más tarde (`POST /comprobantes/{id}/reenviar-sunat` en facturas). | | `0127` | El ticket no existe | El ticket de una guía, baja o resumen no está en SUNAT. Consulta otra vez en unos minutos. | | `1032` | El comprobante ya está informado y se encuentra anulado o rechazado | Ese número ya no sirve: emite uno nuevo. | | `1033` | El comprobante fue registrado previamente con otros datos | Ese número ya está en SUNAT con otros datos: consulta su CDR o emite con el siguiente número. | | `2010`, `2011` | El contribuyente no está activo / no está habido | El RUC del emisor tiene problemas en SUNAT. Revisa la Ficha RUC. | | `2017` | El número de documento de identidad del receptor debe ser RUC | Una factura va a un cliente con RUC. Para un DNI, emite una boleta. | | `2108` | Presentación fuera de fecha | Se envió fuera del plazo de SUNAT. | | `2329` | La fecha de emisión se encuentra fuera del límite permitido | La fecha es muy antigua o futura: emite con la fecha de hoy. | | `2335` | El documento electrónico ingresado ha sido alterado | La firma no coincide (el certificado o el XML cambió). Vuelve a emitir. | | `2640` | Operación gratuita, solo debe consignar un monto referencial | La línea gratuita tiene precio cobrado: ver [gratuitas](https://www.sistemafacturacionperu.com/desarrolladores/guias/casos-especiales/). | | `2800`, `2801`, `2802` | El tipo o el número de documento del receptor no es válido | Revisa `codigo_tipo_documento_identidad` y el número (un DNI tiene 8 dígitos). | | `3249` | Si la venta es al crédito, debe existir al menos una cuota | Agrega las `cuotas` en una venta a crédito. | | `4001` | El número de RUC del receptor no existe | Observación: el comprobante vale, pero revisa el RUC del cliente con `GET /consultas/ruc/{ruc}`. | | `4287` | El precio unitario difiere de los cálculos | Observación: revisa `valor_unitario`, `precio_unitario` y el IGV de la línea. | | `4332` | El código de producto SUNAT no es válido | Observación: revisa `codigo_producto_sunat` del producto. | Un comprobante `observado` es **válido**: no lo anules por una observación. --- # Consultas de RUC, DNI y tipo de cambio URL: https://www.sistemafacturacionperu.com/desarrolladores/consultas/ Consulta por API los datos de un RUC en SUNAT, un DNI en RENIEC y el tipo de cambio de SUNAT del día, con el mismo servicio que usan las pantallas del sistema. Tres consultas para completar formularios y validar antes de emitir, con el mismo servicio que usan las pantallas del sistema. Todas piden el alcance `consultas` y vienen también con Conectar en el Plan Básico. | Consulta | Endpoint | |---|---| | RUC (SUNAT) | `GET /consultas/ruc/{ruc}` | | DNI (RENIEC) | `GET /consultas/dni/{dni}` | | Tipo de cambio (SUNAT) | `GET /consultas/tipo-cambio?fecha=AAAA-MM-DD` | ## RUC El RUC tiene 11 dígitos y empieza con 10, 15, 16, 17 o 20. ```bash curl https://pruebas.sistemafacturacionperu.com/api/v1/consultas/ruc/20601234567 \ -H "Authorization: Bearer $SFP_API_KEY" ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SFP_API_KEY')], ]); $ruc = json_decode(curl_exec($ch), true)['data']; ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/consultas/ruc/20601234567', { headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}` }, }); const { data: ruc } = await res.json(); ``` ```python r = requests.get( "https://pruebas.sistemafacturacionperu.com/api/v1/consultas/ruc/20601234567", headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}"}, timeout=30, ) ruc = r.json()["data"] ``` ```json { "success": true, "data": { "ruc": "20601234567", "razon_social": "BODEGA LIMA S.A.C.", "nombre_comercial": null, "direccion": "AV. LIMA 1", "ubigeo": "150101", "estado": "ACTIVO", "condicion": "HABIDO", "agente_de_retencion": false } } ``` Úsalo para: - completar la razón social y la dirección de la factura sin que tu usuario las escriba; - avisar si el cliente **no está activo o no está habido** antes de facturarle; - saber si el cliente es **agente de retención** (te retendrá parte del IGV al pagarte). ## DNI ```bash curl https://pruebas.sistemafacturacionperu.com/api/v1/consultas/dni/70123456 \ -H "Authorization: Bearer $SFP_API_KEY" ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SFP_API_KEY')], ]); $dni = json_decode(curl_exec($ch), true)['data']; ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/consultas/dni/70123456', { headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}` }, }); const { data: dni } = await res.json(); ``` ```python r = requests.get( "https://pruebas.sistemafacturacionperu.com/api/v1/consultas/dni/70123456", headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}"}, timeout=30, ) dni = r.json()["data"] ``` ```json { "success": true, "data": { "dni": "70123456", "nombre": "PEREZ GOMEZ ANA", "nombres": "ANA", "apellido_paterno": "PEREZ", "apellido_materno": "GOMEZ", "direccion": null, "ubigeo": null } } ``` ## Tipo de cambio Sin `fecha`, el de hoy. Úsalo para el `factor_tipo_de_cambio` de un comprobante en dólares. ```bash curl "https://pruebas.sistemafacturacionperu.com/api/v1/consultas/tipo-cambio?fecha=2026-11-04" \ -H "Authorization: Bearer $SFP_API_KEY" ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SFP_API_KEY')], ]); $cambio = json_decode(curl_exec($ch), true)['data']; // ['fecha' => …, 'compra' => …, 'venta' => …] ``` ```js const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/consultas/tipo-cambio?fecha=2026-11-04', { headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}` }, }); const { data: cambio } = await res.json(); // { fecha, compra, venta } ``` ```python r = requests.get( "https://pruebas.sistemafacturacionperu.com/api/v1/consultas/tipo-cambio", params={"fecha": "2026-11-04"}, headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}"}, timeout=30, ) cambio = r.json()["data"] ``` ```json { "success": true, "data": { "fecha": "2026-11-04", "compra": 3.712, "venta": 3.741 } } ``` ## Errores | HTTP | `code` | Qué pasó | |---|---|---| | 404 | `no_encontrado` | El RUC o el DNI no existe | | 422 | `validacion` | El número no tiene el formato (11 dígitos el RUC, 8 el DNI) | | 502 / 503 | `consulta_no_disponible`, `no_disponible` | El servicio de consultas no respondió: reintenta más tarde | Las consultas cuentan para el límite de 120 llamadas por minuto de tu clave. Si consultas mucho el mismo RUC, guarda el resultado unas horas en tu sistema. --- # Límites, idempotencia, paginación y versiones URL: https://www.sistemafacturacionperu.com/desarrolladores/limites-y-versiones/ Cuántas llamadas por minuto acepta la API, cómo reintentar sin emitir dos veces con Idempotency-Key, cómo paginar los listados y cómo cuidamos la compatibilidad. ## Límites de uso | API | Límite | |---|---| | API v1 | 120 llamadas por minuto por clave y 30 emisiones por minuto (los `POST` que crean documentos) | | API de socios | 120 llamadas por minuto por token; 10 pedidos de token por minuto por IP y por `client_id` | | Conectar (`/oauth/token`, `/oauth/revoke`) | 30 llamadas por minuto por IP y 120 por `client_id` | Cada respuesta trae `X-RateLimit-Limit` y `X-RateLimit-Remaining`. Al pasarte responde **429** con `Retry-After` (en segundos): `demasiadas_solicitudes` o `demasiadas_emisiones` en la v1. - Si emites en lote (por ejemplo, el cierre del día), reparte las emisiones: 30 por minuto es una cada dos segundos. - Respeta `Retry-After` y reintenta con la **misma** `Idempotency-Key`. - Las consultas (RUC, DNI) también cuentan: guarda sus resultados un rato en tu sistema. ## No emitir dos veces: Idempotency-Key La red falla. Si tu sistema manda una factura, se corta la conexión y no sabes si llegó, reintentar a ciegas puede emitirla dos veces. Para eso está la cabecera `Idempotency-Key`: manda en **cada `POST` y `PUT`** (y `PATCH` en la API de socios) un valor único por operación, por ejemplo un UUID. ```http POST /api/v1/comprobantes HTTP/1.1 Authorization: Bearer sfp_live_… Content-Type: application/json Idempotency-Key: 3f1c2a1e-0b7d-4c55-9f3e-8a0c6d2b9e11 ``` | Si reintentas… | Respuesta | |---|---| | Con la misma clave y el **mismo cuerpo** | La misma respuesta de la primera vez, con la cabecera `Idempotent-Replayed: true`. **No se emite dos veces.** | | Con la misma clave y **otro cuerpo** | 409 `idempotencia_distinta` | | Mientras la primera todavía corre | 409 `idempotencia_en_proceso`: reintenta en unos segundos | | Después de un error nuestro (5xx) | La clave queda libre: puedes reintentar con la misma | - La clave se recuerda **24 horas**. - Hasta 255 caracteres visibles. Un UUID nuevo por comprobante es lo más simple; también sirve algo tuyo y único, como `venta-`. - **Guárdala junto a tu venta** antes de llamar: así, si tu sistema se cae en medio, al volver reintenta con la misma. ```php true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 90, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SFP_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencia, ], CURLOPT_POSTFIELDS => json_encode($comprobante), ]); $cuerpo = curl_exec($ch); $estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $json = $cuerpo !== false ? json_decode($cuerpo, true) : null; $enProceso = $estado === 409 && ($json['code'] ?? '') === 'idempotencia_en_proceso'; if ($json !== null && $estado < 500 && $estado !== 429 && !$enProceso) { return $json; // 201, o un error que hay que corregir (4xx) } sleep(min(2 ** $intento, 30)); // red caída, 5xx, 429 o «en proceso»: espera y reintenta } throw new RuntimeException('No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.'); } ``` ```js // Reintento seguro: la misma Idempotency-Key en cada intento. async function emitir(comprobante, idempotencia) { for (let intento = 1; intento <= 4; intento++) { try { const res = await fetch('https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFP_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencia, }, body: JSON.stringify(comprobante), signal: AbortSignal.timeout(90_000), }); const json = await res.json().catch(() => null); const enProceso = res.status === 409 && json?.code === 'idempotencia_en_proceso'; if (json && res.status < 500 && res.status !== 429 && !enProceso) return json; // 201, o un 4xx que hay que corregir const espera = Number(res.headers.get('Retry-After')) || 2 ** intento; await new Promise((r) => setTimeout(r, Math.min(espera, 30) * 1000)); } catch { await new Promise((r) => setTimeout(r, Math.min(2 ** intento, 30) * 1000)); // red caída } } throw new Error('No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.'); } ``` ```python import os, time, requests def emitir(comprobante: dict, idempotencia: str) -> dict: """Reintento seguro: la misma Idempotency-Key en cada intento.""" for intento in range(1, 5): try: r = requests.post( "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes", json=comprobante, headers={"Authorization": f"Bearer {os.environ['SFP_API_KEY']}", "Idempotency-Key": idempotencia}, timeout=90, ) en_proceso = r.status_code == 409 and r.json().get("code") == "idempotencia_en_proceso" if r.status_code < 500 and r.status_code != 429 and not en_proceso: return r.json() # 201, o un 4xx que hay que corregir espera = int(r.headers.get("Retry-After", 2 ** intento)) except requests.RequestException: espera = 2 ** intento # red caída time.sleep(min(espera, 30)) raise RuntimeError("No se pudo emitir: reintenta más tarde con la misma Idempotency-Key.") ``` > Un 409 `idempotencia_distinta` no se arregla reintentando: significa que usaste la misma clave para dos comprobantes > distintos. Usa una nueva. ## Paginación Los listados (`GET /comprobantes`, `/guias`, `/clientes`, `/productos`, `/stock`, `/webhooks/{id}/entregas` y los de socios) aceptan `page` y `per_page` (20 por defecto, hasta 100) y devuelven `meta`: ```json { "success": true, "data": [{ "id": 1520, "numero_completo": "B001-845" }], "meta": { "page": 1, "per_page": 100, "total": 347, "last_page": 4 } } ``` Recorre las páginas hasta `last_page`. Para sincronizar, filtra por fechas (`desde`, `hasta`) en vez de bajar todo. ```bash curl "https://pruebas.sistemafacturacionperu.com/api/v1/comprobantes?desde=2026-11-01&hasta=2026-11-04&estado=aceptado&per_page=100&page=2" \ -H "Authorization: Bearer $SFP_API_KEY" ``` Un usuario que no ve toda la empresa (por ejemplo, un vendedor) ve solo sus comprobantes, como en la web. ## Versiones y compatibilidad - La versión va en la ruta: `/api/v1`. Dentro de la v1 **solo agregamos**: campos nuevos en las respuestas, endpoints nuevos, eventos nuevos y códigos de error nuevos. Tu integración debe ignorar los campos que no conoce. - Un cambio que rompa lo existente iría en una versión nueva (`/api/v2`), con aviso y tiempo para migrar. - Los `code` de error son estables: no cambian de significado. - Cada cambio queda en las [novedades](https://www.sistemafacturacionperu.com/desarrolladores/novedades/). ### La API antigua `/api/documents`, `/api/dispatches` y las demás rutas de antes siguen funcionando igual para quien ya las usa. No las documentamos ni les agregamos funciones: todo lo nuevo está en la v1. Dentro de unos meses avisaremos de su retiro a quien siga usándolas (lo sabemos por el registro de llamadas). --- # API de socios URL: https://www.sistemafacturacionperu.com/desarrolladores/socios/ Para programadores y empresas de software con muchos clientes: da de alta empresas, emite con su API v1 y paga con bolsas prepago de comprobantes desde S/ 0,048. Si tienes un sistema con muchos clientes propios (un ERP, un POS, una app de reservas), la API de socios te deja **darlos de alta como empresas** de Sistema Facturación Perú y emitir sus comprobantes con la API v1 de cada una. Tus clientes no tienen que contratar nada: tú pagas por lo que emiten, con bolsas prepago. 1. Das de alta a tu cliente con `POST /empresas` (RUC, razón social, administrador y, si quieres, certificado y usuario SOL). 2. Creas una clave v1 para esa empresa con `POST /empresas/{id}/claves`. 3. Emites con esa clave en `https://.sistemafacturacionperu.com/api/v1`, igual que en la [API v1](https://www.sistemafacturacionperu.com/desarrolladores/inicio-rapido/). - Base: `https://api.sistemafacturacionperu.com/socios/v1` (siempre este host, nunca el subdominio de una empresa). - Formato: el mismo de la API v1 (`{"success": true, "data": …}` o `{"success": false, "code": …, "message": …}`). ## Precios Pagas por adelantado con **bolsas de comprobantes**, para todas tus empresas juntas. Precios con IGV incluido: | Bolsa | Precio | Por comprobante | Validez | |---|---|---|---| | 3.300 comprobantes | **S/ 180** | S/ 0,055 | 6 meses | | 5.500 comprobantes | **S/ 285** | S/ 0,052 | 6 meses | | 10.000 comprobantes | **S/ 480** | S/ 0,048 | 6 meses | Más una **cuota de S/ 9 al mes con IGV por cada empresa activa**: la que emitió al menos un comprobante en el mes, por cualquier vía. Cubre lo que no es el envío a SUNAT: la base de datos de cada empresa, guardar sus XML, PDF y constancias los años que pide SUNAT, el servidor y el soporte. - Cada bolsa vale **6 meses** desde la compra. Si emites más, compras varias: no hay bolsas más grandes. - Se consume primero la bolsa que **vence antes**. - **La compra de bolsas se coordina con nosotros** (transferencia o Yape) y la registramos en el momento. Por ahora no hay pago en línea de bolsas. ### Qué descuenta de la bolsa Descuenta **1** todo lo que sale por **nuestro PSE**: - cada factura, boleta, nota de crédito, nota de débito, retención, percepción y liquidación de compra; - cada resumen diario de boletas; - cada baja (comunicación de baja o resumen de anulación); - cada guía de remisión. Se descuenta **una sola vez por documento**, aunque se reintente el envío. En el entorno de pruebas de SUNAT no se descuenta nada. **Si la empresa envía directo a SUNAT con su propio usuario SOL** (mandaste `certificado` y `sol` en el alta), **no descuenta nada de tu bolsa**: esa empresa solo paga la cuota de S/ 9 los meses en que emite. ### Sin saldo - Antes de crear un comprobante o una guía de una empresa que envía por nuestro PSE, si tu saldo es 0, su API v1 responde **402 `saldo_agotado`** y no se crea nada (tampoco desde la web de esa empresa). Lo ya emitido no se toca. - Los **resúmenes y las bajas** de lo ya emitido **sí se envían** sin saldo (dejar boletas sin informar sería peor): se descuentan igual y el negativo sale de tu siguiente recarga. - Te avisamos al usar el **80 %** y el **95 %** de tus bolsas vigentes (`saldo.bajo`) y al llegar a 0 (`saldo.agotado`), por webhook y por correo. - La cuota mensual se calcula el día 1 y vence a los 10 días. Vencida e impaga, tus empresas no emiten por API (**402 `cuota_pendiente`**); su web sigue funcionando. Al pagarla, vuelven al instante. `GET /saldo` te da el saldo, tus bolsas, el próximo vencimiento, las cuotas pendientes y los precios; `GET /movimientos` los consumos y las recargas, filtrables por `tipo`, `empresa_id`, `desde` y `hasta`. ## 1. Pide un token Te damos una credencial: `client_id` (`sfp_partner_…`) y `client_secret` (`sfp_secret_…`, se muestra una vez). El token dura 1 hora: pide otro cuando venza (401 `token_vencido`). ```bash curl -s https://api.sistemafacturacionperu.com/socios/v1/token \ -u "$SFP_CLIENT_ID:$SFP_CLIENT_SECRET" \ -d grant_type=client_credentials ``` ```php true, CURLOPT_USERPWD => getenv('SFP_CLIENT_ID') . ':' . getenv('SFP_CLIENT_SECRET'), CURLOPT_POSTFIELDS => http_build_query(['grant_type' => 'client_credentials']), ]); $token = json_decode(curl_exec($ch), true)['access_token']; ``` ```js const basic = Buffer.from(`${process.env.SFP_CLIENT_ID}:${process.env.SFP_CLIENT_SECRET}`).toString('base64'); const res = await fetch('https://api.sistemafacturacionperu.com/socios/v1/token', { method: 'POST', headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials' }), }); const { access_token: token } = await res.json(); ``` ```python r = requests.post( "https://api.sistemafacturacionperu.com/socios/v1/token", auth=(os.environ["SFP_CLIENT_ID"], os.environ["SFP_CLIENT_SECRET"]), data={"grant_type": "client_credentials"}, timeout=30, ) token = r.json()["access_token"] ``` ```json { "success": true, "access_token": "sfp_pat_Q1w2…", "token_type": "Bearer", "expires_in": 3600, "expires_at": "2026-11-04T15:04:05-05:00" } ``` ## 2. Da de alta a tu cliente ```bash curl -s https://api.sistemafacturacionperu.com/socios/v1/empresas \ -H "Authorization: Bearer $SFP_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: alta-20601030013" \ -d '{ "ruc": "20601030013", "razon_social": "FERRETERIA EL TORNILLO S.A.C.", "nombre_comercial": "El Tornillo", "direccion": { "direccion": "Av. Grau 123, Lima", "ubigeo": "150101" }, "administrador": { "nombre": "Ana Quispe", "correo": "ana@tornillo.pe", "celular": "987654321" }, "certificado": { "pfx_base64": "MIIK…", "clave": "clave-del-pfx" }, "sol": { "usuario": "FACTURAS1", "clave": "clave-sol" } }' ``` Responde **201 al instante** con `"estado": "creando"`; la empresa queda lista en 1 a 3 minutos (es la misma alta de la compra en la web). Consulta `GET /empresas/{id}` o espera el webhook `empresa.lista`. - Sin `administrador.contrasena`, el administrador recibe el correo de bienvenida para crear la suya. - Sin `subdominio`, lo armamos del nombre (`el-tornillo`). - Con `certificado` y `sol`, la empresa envía directo a SUNAT con su SOL (no descuenta de tu bolsa). Sin ellos, o con `"envio": "pse"`, envía por nuestro PSE, que activamos nosotros. - Usa una `Idempotency-Key` como `alta-`: si la red falla, el reintento no crea la empresa dos veces. - Si el RUC ya tiene cuenta, responde 409 `ruc_ya_registrado` (sin decir de quién). Si es tu cliente, pídele que nos escriba para pasarla a tu cuenta de socio. ### El estado de la empresa | `estado` | Qué significa | |---|---| | `creando` | El alta corre (1 a 3 minutos) | | `por_verificar` | Ya existe, pero aún no emite: mira `pendientes` | | `lista` | Ya emite. Te avisamos con `empresa.lista` | | `suspendida` | La pausaste tú (`suspension.por = socio`) o la bloqueó la plataforma (`agencia`) | | `error` | El alta falló: escríbenos con el `id` | `pendientes` dice qué falta: `ficha_ruc` (como toda empresa nueva, emite cuando su Ficha RUC queda verificada: el administrador la sube en Configuración › Empresa › Régimen tributario), `produccion`, `certificado`, `usuario_sol` o `credenciales_pse` (lo activamos nosotros). ## 3. Crea su clave v1 y emite ```bash curl -s https://api.sistemafacturacionperu.com/socios/v1/empresas/15/claves \ -H "Authorization: Bearer $SFP_PARTNER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"nombre": "Mi sistema · producción", "establecimiento": "0000"}' ``` ```json { "success": true, "data": { "id": 3, "nombre": "Mi sistema · producción", "permisos": ["comprobantes.emitir", "comprobantes.leer", "comprobantes.anular", "guias.emitir", "guias.leer", "catalogo.leer", "catalogo.escribir", "consultas", "webhooks.gestionar"], "clave": "sfp_live_Ab12…", "api_v1": "https://el-tornillo.sistemafacturacionperu.com/api/v1", "aviso": "Guarda la clave ahora…" } } ``` La clave se muestra **una vez**. Sin `permisos`, trae los de emitir y consultar. Con ella emites en `api_v1` todo lo de la [API v1](https://www.sistemafacturacionperu.com/desarrolladores/guias/): comprobantes, guías, notas, anulaciones, consultas y webhooks de esa empresa. ## Más operaciones | Operación | Endpoint | |---|---| | Listar tus empresas (filtra por `estado` y `ruc`) | `GET /empresas` | | Ver una empresa (vuelve a leer su estado) | `GET /empresas/{id}` | | Cambiar contacto, nombre comercial, dirección, certificado, usuario SOL o forma de envío | `PATCH /empresas/{id}` | | Comprobantes del mes por tipo y lo que descontó de tu bolsa | `GET /empresas/{id}/uso?mes=2026-11` | | Pausar la emisión por API (por ejemplo, tu cliente no te pagó) | `POST /empresas/{id}/suspender` | | Volver a permitirla | `POST /empresas/{id}/reactivar` | | Listar y revocar claves | `GET /empresas/{id}/claves`, `DELETE /empresas/{id}/claves/{keyId}` | | Saldo y movimientos | `GET /saldo`, `GET /movimientos` | | Webhooks del socio | `GET`/`POST /webhooks`, `DELETE /webhooks/{id}`, `GET /webhooks/{id}/entregas` | Mientras una empresa está pausada, su API v1 responde 423 `suspendida_por_socio` al emitir; consultar sigue funcionando y su web no cambia. **Lo que un socio no puede:** ver empresas que no son suyas (responden 404), bloquearlas por cobranza, cambiar su plan, entrar como su administrador ni usar el acceso de soporte. Eso es solo de la plataforma. La empresa de un socio tiene **emitir y consultar**, no el ERP completo. Si tu cliente quiere el sistema completo (POS, inventario, compras, tienda), se pasa a un plan normal. ## Webhooks del socio ```bash curl -s https://api.sistemafacturacionperu.com/socios/v1/webhooks \ -H "Authorization: Bearer $SFP_PARTNER_TOKEN" -H "Content-Type: application/json" \ -d '{"url": "https://tusistema.pe/webhooks/sfp", "eventos": ["empresa.lista", "saldo.bajo", "saldo.agotado", "cuota.vencida"]}' ``` | Evento | Cuándo | |---|---| | `empresa.lista` | Una empresa ya puede emitir | | `empresa.suspendida` / `empresa.reactivada` | La pausaste (o la bloqueamos) / volvió | | `empresa.error` | El alta falló | | `saldo.bajo` | Usaste el 80 % o el 95 % de lo vigente (`data.umbral`) | | `saldo.agotado` | El saldo llegó a 0 | | `cuota.pendiente`, `cuota.por_vencer`, `cuota.vencida` | El cargo mensual | Se firman y se reintentan igual que los [webhooks de la API v1](https://www.sistemafacturacionperu.com/desarrolladores/webhooks/), y el cuerpo trae además `socio_id`. ## Seguridad y límites - El secreto y los tokens se guardan solo como hash. Para rotar sin corte te damos una segunda credencial, la cambias y revocamos la vieja. Una credencial puede limitarse a tus IP (403 `ip_no_permitida` desde otra). - 120 llamadas por minuto por token y 10 intentos de token por minuto por IP y por `client_id`. - `Idempotency-Key` en todo `POST` y `PATCH`; se recuerda 24 horas. ¿Tus clientes **ya tienen** cuenta en Sistema Facturación Perú? Entonces no los des de alta: conéctalos con un botón, con [Conectar con Sistema Facturación Perú](https://www.sistemafacturacionperu.com/desarrolladores/conectar/). Seguramente necesites las dos. --- # Conectar con Sistema Facturación Perú URL: https://www.sistemafacturacionperu.com/desarrolladores/conectar/ Pon un botón en tu sistema para que tus clientes conecten su cuenta de Sistema Facturación Perú sin copiar claves: OAuth 2.0 con PKCE, ventana emergente y tokens. Si tu sistema (ERP, tienda, app) tiene clientes que **ya usan** Sistema Facturación Perú, ponles un botón «Conectar con Sistema Facturación Perú». El cliente escribe su RUC, entra con su usuario de siempre y pulsa «Permitir»: tú recibes un token para emitir con su cuenta, sin pedirle claves de API y sin que su contraseña pase por tu sistema. - Solo **conecta una cuenta que ya existe**: nunca crea empresas ni usuarios. Para dar de alta empresas está la [API de socios](https://www.sistemafacturacionperu.com/desarrolladores/socios/). - Es gratis para tu sistema. La empresa paga su plan de siempre. - Es OAuth 2.0 con *authorization code* y **PKCE S256 obligatorio** (RFC 6749, 7636, 7009 y 9207). 1. **Tu servidor** genera `state` y `code_verifier`, los guarda en la sesión del usuario y arma la URL de `/conectar`. 2. **Tu botón** abre esa URL en una ventana emergente, con nuestra dirección visible. 3. **El usuario** escribe el RUC de su empresa, entra con su correo y contraseña en el subdominio de su empresa (con su segundo factor, si lo tiene) y pulsa «Permitir». 4. **Volvemos** a tu `redirect_uri` con `code`, `state` e `iss`. 5. **Tu servidor** comprueba el `state` y canjea el código en `POST /oauth/token`. 6. **Emites** en la API v1 de esa empresa con el token de acceso, y lo renuevas cada hora. ## 1. Registra tu aplicación Escríbenos con el nombre y el logo de tu sistema, tu web, un correo de soporte, tus **direcciones de retorno** (`redirect_uri` exactas: solo `https://`, o `http://localhost` y `http://127.0.0.1` para desarrollar) y los permisos que vas a pedir. Te damos: - `client_id` (`sfp_app_…`), que es público; - `client_secret` (`sfp_secret_…`), que se muestra **una sola vez**: guárdalo en tu servidor, nunca en el navegador; - si nos das una URL de webhook, su secreto `whsec_…` para verificar el aviso `conexion.revocada`. Sin ese registro no hay botón: así nadie se hace pasar por tu aplicación. ## 2. El botón y la ventana emergente No metas nuestro inicio de sesión dentro de un `