API Perunio
El XML, PDF y CDR de tus comprobantes en tu propio sistema: un archivo al instante, o descargas masivas por lista o por periodo de SIRE.
- 1
Agrega la empresa
En Perunio, con sus credenciales SOL guardadas.
- 2
Crea una clave
En
Integraciones → API. Se muestra una sola vez: guárdala como secreto. - 3
Llama a la API
Desde tu servidor, con
Authorization: Bearer <clave>. Cada clave es de una empresa.
Rutas
Elige una ruta y cambia los valores: el ejemplo se actualiza solo. La clave se lee de PERUNIO_API_KEY; nunca la pongas en una página web o en una app instalada.
POST → GET (cada 5 s) → GET /file
Descarga masiva de principio a fin
Crea el trabajo, consulta su estado hasta que termine y guarda el ZIP. Vacía items para bajar el periodo completo según SIRE.
Opcional. Reenviar la misma clave con el mismo cuerpo devuelve el trabajo original.
rce: Registro de Compras · rvie: Registro de Ventas.
Periodo SIRE, AAAAMM.
Reintentos por archivo ante fallas pasajeras de SUNAT (0 a 8), con espera creciente desde 1 s. Las fallas definitivas (no existe, datos inválidos) no se reintentan.
import { writeFile } from "node:fs/promises";const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…async function call(path, init = {}) {const res = await fetch(API + path, {...init,headers: { Authorization: `Bearer ${KEY}`, ...init.headers },});if (res.status >= 400) {const { error } = await res.json();throw new Error(`${error.code}: ${error.message}`);}return res;}// 1. Crear la descarga masivaconst created = await call("/bulk-downloads", {method: "POST",headers: {"Content-Type": "application/json","Idempotency-Key": "rce-202609-run-1",},body: JSON.stringify({registro: "rce",period: "202609",fileTypes: ["xml", "pdf"],// Sin items: Perunio lista el periodo completo en SIRE y descarga todos sus// comprobantes descargables.items: [{ rucEmisor: "20100070970", codTipoCdp: "01", serie: "F001", numero: "12345" },{ rucEmisor: "20100070970", codTipoCdp: "07", serie: "FC01", numero: "321" },{ rucEmisor: "20512345678", codTipoCdp: "03", serie: "B001", numero: "4567" },],}),});let job = await created.json();// 2. Consultar cada 5 segundos hasta que terminewhile (!["completed", "failed", "cancelled"].includes(job.status)) {await new Promise((resolve) => setTimeout(resolve, 5000));const res = await call(`/bulk-downloads/${job.id}`);job = await res.json();}if (job.status !== "completed") throw new Error(job.error?.code ?? job.status);console.log(`${job.downloadedFiles} archivos, ${job.failedFiles} fallidos`);// 3. Ver qué falló y por qué. Un comprobante con el XML pero sin el PDF// queda "completed": revisa failedFiles, no solo status=failed.for (let cursor = ""; job.failedFiles > 0; ) {const res = await call(`/bulk-downloads/${job.id}/items?limit=1000${cursor}`);const page = await res.json();for (const item of page.data) {for (const f of item.failedFiles) {console.warn(`${item.serie}-${item.numero} ${f.fileType}: ${f.code} ${f.message}`);}}if (!page.nextCursor) break;cursor = `&cursor=${page.nextCursor}`;}// 4. Bajar el ZIP: /file redirige a un enlace temporalconst file = await call(`/bulk-downloads/${job.id}/file`, { redirect: "manual" });const zip = await fetch(file.headers.get("location"));await writeFile("comprobantes.zip", Buffer.from(await zip.arrayBuffer()));
/v1/openapi.jsonEsta especificación
La especificación OpenAPI 3.1 de la API. No requiere clave.
const API = "https://api.perunio.pe/v1";const res = await fetch(`${API}/openapi.json`);const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result);
- 200Especificación OpenAPI.
/v1/comprobantes/{registro}/{rucEmisor}/{codTipoCdp}/{serie}/{numero}/{fileType}Descargar un archivo
Devuelve el XML, PDF o CDR de un comprobante tal como lo entrega SUNAT. Consume 1 descarga del plan, solo si la descarga tiene éxito. Para más de unos pocos archivos usa la descarga masiva.
rce: Registro de Compras · rvie: Registro de Ventas.
Número de documento del emisor (RUC u otro).
Archivo del comprobante.
Reintentos ante una falla pasajera de SUNAT (0 a 3), con espera de 1 s, 2 s y 4 s.
import { writeFile } from "node:fs/promises";const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/comprobantes/rce/20100070970/01/F001/12345/xml`, {headers: { Authorization: `Bearer ${KEY}` },});if (!res.ok) {const { error } = await res.json();throw new Error(`${error.code}: ${error.message}`);}// El nombre del archivo viene en Content-Dispositionconst name = res.headers.get("content-disposition").match(/filename="(.+)"/)[1];await writeFile(name, Buffer.from(await res.arrayBuffer()));
- 200El archivo.
Content-Dispositiontrae su nombre. - 400Parámetros inválidos (
VALIDATION_ERROR) o tipo que SUNAT no entrega (NOT_DOWNLOADABLE). - 404SUNAT no tiene el archivo (
COMPROBANTE_NOT_FOUND). Un comprobante emitido en SEE-SOL (serie E…) no tiene CDR. - 422SUNAT rechazó las credenciales (
SUNAT_AUTH_FAILED), pide doble factor (SOL_TWO_FACTOR_REQUIRED) o rechazó la solicitud (SUNAT_REJECTED). - 502SUNAT no responde (
SUNAT_UNAVAILABLE). Reintenta más tarde.
/v1/bulk-downloadsCrear una descarga masiva
Empieza un trabajo que descarga los archivos pedidos a un ZIP. Con items, descarga esos comprobantes; sin items, lista el periodo completo en SIRE y descarga todo. Consulta el trabajo hasta que termine y luego pide /file. Cada archivo descargado consume 1 descarga del plan. Una empresa tiene como máximo un trabajo activo a la vez; 20 creaciones por hora por clave.
Opcional. Reenviar la misma clave con el mismo cuerpo devuelve el trabajo original.
rce: Registro de Compras · rvie: Registro de Ventas.
Periodo SIRE, AAAAMM.
Reintentos por archivo ante fallas pasajeras de SUNAT (0 a 8), con espera creciente desde 1 s. Las fallas definitivas (no existe, datos inválidos) no se reintentan.
const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads`, {method: "POST",headers: {Authorization: `Bearer ${KEY}`,"Content-Type": "application/json","Idempotency-Key": "rce-202609-run-1",},body: JSON.stringify({registro: "rce",period: "202609",fileTypes: ["xml", "pdf"],// Sin items: Perunio lista el periodo completo en SIRE y descarga todos sus// comprobantes descargables.items: [{ rucEmisor: "20100070970", codTipoCdp: "01", serie: "F001", numero: "12345" },{ rucEmisor: "20100070970", codTipoCdp: "07", serie: "FC01", numero: "321" },{ rucEmisor: "20512345678", codTipoCdp: "03", serie: "B001", numero: "4567" },],}),});const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result);
- 200Misma
Idempotency-Keyy mismo cuerpo que un trabajo anterior: ese trabajo. - 202Trabajo creado.
Locationapunta a él. - 400Cuerpo inválido (
VALIDATION_ERROR) o ningún comprobante descargable (NOT_DOWNLOADABLE). - 409Ya hay un trabajo activo (
ACTIVE_JOB_EXISTS,details.jobId) o laIdempotency-Keyse usó con otro cuerpo (IDEMPOTENCY_KEY_REUSED). - 413Cuerpo de más de 2 MB (
PAYLOAD_TOO_LARGE). - 422Descarga por periodo sin credenciales SIRE (
SIRE_CREDENTIALS_MISSING). - 503Almacenamiento no disponible (
STORAGE_UNAVAILABLE).
/v1/bulk-downloadsListar descargas masivas
Los trabajos creados por API para la empresa de la clave, del más reciente al más antiguo.
queued → (listing, solo por periodo) → processing → completed | failed | cancelled.
El nextCursor de la página anterior.
const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads`, {headers: { Authorization: `Bearer ${KEY}` },});const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result.data);// Hay más páginas mientras result.nextCursor no sea null: pásalo como ?cursor=
- 200Una página de trabajos.
/v1/bulk-downloads/{id}Consultar una descarga masiva
const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads/3f2a9c0e-8b1d-4e6f-a7c5-b2d1e0f9a8b7`, {headers: { Authorization: `Bearer ${KEY}` },});const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result);
- 200El trabajo.
- 404No existe un trabajo con ese id para esta clave (
NOT_FOUND).
/v1/bulk-downloads/{id}Cancelar una descarga masiva
Los archivos ya descargados siguen contando en el plan; el ZIP parcial se borra.
const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads/3f2a9c0e-8b1d-4e6f-a7c5-b2d1e0f9a8b7`, {method: "DELETE",headers: { Authorization: `Bearer ${KEY}` },});const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result);
- 200El trabajo, ahora
cancelled. - 404No existe un trabajo con ese id para esta clave (
NOT_FOUND). - 409El trabajo ya terminó (
JOB_ALREADY_FINISHED).
/v1/bulk-downloads/{id}/itemsResultado por comprobante
El nextCursor de la página anterior.
const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads/3f2a9c0e-8b1d-4e6f-a7c5-b2d1e0f9a8b7/items`, {headers: { Authorization: `Bearer ${KEY}` },});const result = await res.json();if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message}`);console.log(result.data);// Hay más páginas mientras result.nextCursor no sea null: pásalo como ?cursor=
- 200Una página de ítems, en el orden enviado.
- 404No existe un trabajo con ese id para esta clave (
NOT_FOUND).
/v1/bulk-downloads/{id}/fileDescargar el ZIP
Redirige (302) a un enlace temporal de 15 minutos. Pide uno nuevo cada vez; no lo guardes.
import { writeFile } from "node:fs/promises";const API = "https://api.perunio.pe/v1";const KEY = process.env.PERUNIO_API_KEY; // prn_live_…const res = await fetch(`${API}/bulk-downloads/3f2a9c0e-8b1d-4e6f-a7c5-b2d1e0f9a8b7/file`, {headers: { Authorization: `Bearer ${KEY}` },redirect: "manual",});if (res.status !== 302) {const { error } = await res.json();throw new Error(`${error.code}: ${error.message}`);}// Enlace temporal de 15 minutos: descárgalo enseguida, sin la claveconst zip = await fetch(res.headers.get("location"));await writeFile("comprobantes.zip", Buffer.from(await zip.arrayBuffer()));
- 302Redirección al ZIP.
- 404No existe el trabajo (
NOT_FOUND), o terminó sin archivos (NO_FILES). Un trabajo vencido desaparece al pasar la limpieza horaria. - 409El trabajo no terminó (
JOB_NOT_FINISHED). - 410El ZIP superó la retención del plan (
FILE_EXPIRED).
Estados de una descarga masiva
| queued | Aceptado; todavía no empieza. |
| listing | Solo por periodo: listando el periodo en SIRE. totalItems es 0 hasta que termine. |
| processing | Descargando. processedItems avanza. |
| completed | Terminó. Puede tener fallidos (failedFiles): revísalos en /items. |
| failed | Falló entero; error.code dice por qué. Con INTERRUPTED, créalo de nuevo con otra Idempotency-Key. |
| cancelled | Cancelado. Lo ya descargado sigue contando en el plan. |
El objeto trabajo
| id | string | |
| status | "queued" | "listing" | "processing" | "completed" | "failed" | "cancelled" | queued → (listing, solo por periodo) → processing → completed | failed | cancelled. |
| mode | "items" | "period" | |
| registro | "rce" | "rvie" | rce: Registro de Compras · rvie: Registro de Ventas. |
| period | string | Periodo SIRE, AAAAMM. |
| fileTypes | "xml" | "pdf" | "cdr"[] | |
| maxRetries | integer | |
| totalItems | integer | Comprobantes del trabajo. 0 mientras se lista el periodo. |
| processedItems | integer | |
| downloadedFiles | integer | Archivos descargados (cada uno consumió 1 descarga del plan). |
| failedFiles | integer | |
| fileAvailable | boolean | Si /file entrega el ZIP ahora. |
| fileSizeBytes | integer | null | |
| expiresAt | string | null | Cuándo se borra el ZIP. null: no vence. |
| error | object | null | Por qué falló el trabajo, si status es failed. |
| createdAt | string | |
| startedAt | string | null | |
| completedAt | string | null | |
| links | object |
Errores
Todo error tiene esta forma. Decide con code, que es estable; message puede cambiar. Cualquier ruta con clave puede responder 401, 403, 429, 500. Toda respuesta trae X-Request-Id.
{
"error": {
"code": "DOWNLOAD_QUOTA_EXCEEDED",
"message": "Las descargas que quedan en tu plan no alcanzan para este trabajo.",
"details": { "limit": 450, "remaining": 12, "requested": 80 },
"requestId": "req_3f2a9c0e8b1d4e6fa7c5b2d1e0f9a8b7"
}
}| VALIDATION_ERROR | Solicitud inválida; details lista cada problema con su path. |
| NOT_DOWNLOADABLE | SUNAT solo entrega archivos de los tipos 01, 02, 03, 07 y 08. |
| INVALID_API_KEY | Falta la clave, no es válida o fue revocada. |
| MODULE_NOT_ENTITLED | El plan no incluye la API (Contabilidad Pro o el módulo API Perunio). |
| ACCOUNT_DISABLED | La cuenta de Perunio está desactivada. |
| COMPANY_CREDENTIALS_MISSING | La empresa ya no tiene credenciales SOL guardadas. |
| COMPANY_UNAVAILABLE | Empresa bloqueada por el límite del plan, o por eliminarse. |
| DOWNLOAD_QUOTA_EXCEEDED | No alcanzan las descargas del plan; details trae el límite y lo que queda. |
| SUNAT_ACCESS_FORBIDDEN | El usuario SOL no tiene acceso a esa consulta. |
| NOT_FOUND | No existe, o no es de esta clave. |
| COMPROBANTE_NOT_FOUND | SUNAT no tiene ese archivo. |
| NO_FILES | El trabajo terminó sin archivos. |
| ACTIVE_JOB_EXISTS | Ya hay una descarga masiva activa; details.jobId es su id. |
| JOB_NOT_FINISHED | El trabajo todavía no terminó con éxito. |
| JOB_ALREADY_FINISHED | Ya terminó; no se puede cancelar. |
| IDEMPOTENCY_KEY_REUSED | Esa Idempotency-Key ya se usó con otro cuerpo. |
| FILE_EXPIRED | El ZIP superó la retención del plan. |
| PAYLOAD_TOO_LARGE | El cuerpo pesa más de 2 MB. |
| SIRE_CREDENTIALS_MISSING | Para bajar un periodo, la empresa necesita sus credenciales de API SIRE. |
| SUNAT_AUTH_FAILED | SUNAT rechazó las credenciales SOL; actualízalas en Perunio. |
| SUNAT_REJECTED | SUNAT rechazó la solicitud. |
| SOL_TWO_FACTOR_REQUIRED | El usuario SOL pide doble factor. |
| RATE_LIMITED | Demasiadas solicitudes; espera lo que indica Retry-After. |
| INTERNAL_ERROR | Error inesperado. Si persiste, escríbenos con el requestId. |
| INTERRUPTED | Se interrumpió (por ejemplo, un reinicio). Créalo de nuevo. |
| SUNAT_UNAVAILABLE | SUNAT no responde. Reintenta en unos minutos. |
| STORAGE_UNAVAILABLE | Almacenamiento no disponible. Reintenta más tarde. |
| API_KEYS_UNAVAILABLE | Las claves no están disponibles en este momento. |
Límites y plan
| Descargas | 1 por archivo bajado con éxito, como en la app. Ilimitadas en Contabilidad Pro. |
| Retención del ZIP | La del plan. En Contabilidad Pro no vence. |
| Masivas activas | 1 por empresa a la vez (aparte de las de la app). |
| Comprobantes por trabajo | Hasta 10 000. |
| Solicitudes | 120 por minuto por clave; 600 por minuto por IP. |
| Creación de masivas | 20 por hora por clave. |
Al pasar un límite de solicitudes la respuesta es 429 con Retry-After. Las respuestas traen RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset.
Versiones
/v1 solo crece: se agregan campos, rutas y códigos, nunca se quitan ni cambian de significado. Ignora los campos que no conozcas. Un cambio incompatible sería /v2, con /v1 vivo al menos 6 meses más.