Tu ERP necesita los XML de todas las facturas de compra del mes. En el portal de SUNAT eso es ir comprobante por comprobante. Con la API Perunio son tres llamadas: creas una descarga masiva, la consultas hasta que termine y bajas un único ZIP con el XML, el PDF y el CDR de cada comprobante.
Esta guía hace solo eso, con JavaScript simple y sin librerías. La API tiene más rutas, que menciono al final.
Antes de empezar
- La empresa en Perunio, agregada con sus credenciales SOL guardadas.
- Una clave de API de esa empresa: en Perunio, entra a Integraciones → API y crea una. Se muestra una sola vez, así que guárdala como secreto. Cada clave es de una sola empresa.
- Node.js 18 o superior, que ya trae
fetch.
Guarda la clave en una variable de entorno, nunca en el código:
export PERUNIO_API_KEY="prn_live_…"
La API viene incluida en el plan Contabilidad Pro, o como módulo en un plan personalizado. Los detalles están en la página de la API Perunio.
Todas las llamadas usan este pequeño ayudante, que agrega la clave:
const API = "https://api.perunio.pe/v1";
const KEY = process.env.PERUNIO_API_KEY;
const perunio = (path, init = {}) =>
fetch(API + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, ...init.headers },
});
Paso 1: crear la descarga masiva
Un POST /v1/bulk-downloads dice qué quieres bajar:
const res = await perunio("/bulk-downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
registro: "rce", // compras; "rvie" para ventas
period: "202609", // septiembre de 2026 (AAAAMM)
fileTypes: ["xml", "pdf", "cdr"],
}),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
let job = await res.json();
console.log(job.id, job.status); // "3f2a9c0e-…" "listing"
Fíjate en lo que no se envía: la lista de comprobantes. Sin items, Perunio lista el periodo completo en SIRE y descarga todos sus comprobantes descargables: facturas, boletas, notas de crédito y débito y recibos por honorarios.
Si ya sabes qué comprobantes quieres, envíalos en items y Perunio baja solo esos:
items: [
{ rucEmisor: "20100070970", codTipoCdp: "01", serie: "F001", numero: "12345" },
{ rucEmisor: "20100070970", codTipoCdp: "07", serie: "FC01", numero: "321" },
],
La respuesta es un 202 Accepted con el trabajo. Lo que importa es su id: con él lo consultas en el paso 2.
Paso 2: consultar ese trabajo hasta que termine
La descarga corre en los servidores de Perunio, no en tu conexión. Consulta el trabajo con GET /v1/bulk-downloads/{id} cada 5 segundos:
while (!["completed", "failed", "cancelled"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const res = await perunio(`/bulk-downloads/${job.id}`);
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
job = await res.json();
console.log(job.status, `${job.processedItems}/${job.totalItems}`);
}
if (job.status !== "completed") throw new Error(job.error?.code ?? job.status);
El estado avanza así:
| Estado | Qué significa |
|---|---|
listing |
Perunio está listando el periodo en SIRE. totalItems es 0 hasta que termine. |
processing |
Descargando. processedItems avanza. |
completed |
Terminó: el ZIP está listo. |
failed |
El trabajo falló entero; error.code dice por qué. |
Un trabajo terminado se ve así (resumido):
{
"id": "3f2a9c0e-8b1d-4e6f-a7c5-b2d1e0f9a8b7",
"status": "completed",
"mode": "period",
"totalItems": 48,
"processedItems": 48,
"downloadedFiles": 139,
"failedFiles": 5,
"fileAvailable": true
}
Un detalle: completed puede traer failedFiles mayor que 0. Si SUNAT no tiene el PDF de un comprobante, el trabajo termina igual y el ZIP trae todo lo demás. Los archivos que fallan no consumen descargas de tu plan.
Paso 3: bajar el ZIP
GET /v1/bulk-downloads/{id}/file no devuelve el ZIP directamente: responde 302 con un enlace temporal, válido 15 minutos. Pide la redirección a mano (redirect: "manual") para que tu clave no viaje a ese enlace, y baja el archivo desde ahí:
import { writeFile } from "node:fs/promises";
const file = await perunio(`/bulk-downloads/${job.id}/file`, { redirect: "manual" });
if (file.status !== 302) throw new Error(JSON.stringify(await file.json()));
const zip = await fetch(file.headers.get("location"));
await writeFile("compras-202609.zip", Buffer.from(await zip.arrayBuffer()));
Pide un enlace nuevo cada vez que lo necesites; no lo guardes. Dentro del ZIP cada comprobante tiene su XML, PDF y CDR con los nombres de SUNAT.
El script completo
Guárdalo como descargar.mjs y ejecútalo con node descargar.mjs:
import { writeFile } from "node:fs/promises";
const API = "https://api.perunio.pe/v1";
const KEY = process.env.PERUNIO_API_KEY;
const perunio = (path, init = {}) =>
fetch(API + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, ...init.headers },
});
// 1. Crear la descarga masiva de las compras de septiembre de 2026
const res = await perunio("/bulk-downloads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ registro: "rce", period: "202609", fileTypes: ["xml", "pdf", "cdr"] }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
let job = await res.json();
// 2. Consultar ese trabajo cada 5 segundos hasta que termine
while (!["completed", "failed", "cancelled"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const res = await perunio(`/bulk-downloads/${job.id}`);
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
job = await res.json();
console.log(job.status, `${job.processedItems}/${job.totalItems}`);
}
if (job.status !== "completed") throw new Error(job.error?.code ?? job.status);
console.log(`${job.downloadedFiles} archivos, ${job.failedFiles} fallidos`);
// 3. Bajar el ZIP desde el enlace temporal
const file = await perunio(`/bulk-downloads/${job.id}/file`, { redirect: "manual" });
if (file.status !== 302) throw new Error(JSON.stringify(await file.json()));
const zip = await fetch(file.headers.get("location"));
await writeFile("compras-202609.zip", Buffer.from(await zip.arrayBuffer()));
Errores que vas a ver
Todo error tiene la misma forma, { "error": { "code", "message", "details", "requestId" } }. Decide con code, que no cambia; message es para personas.
| Código | Cuándo | Qué hacer |
|---|---|---|
401 INVALID_API_KEY |
Falta la clave, no es válida o fue revocada. | Revisa PERUNIO_API_KEY. |
409 ACTIVE_JOB_EXISTS |
La empresa ya tiene una descarga masiva en curso. | No crees otra: consulta la de details.jobId. |
403 DOWNLOAD_QUOTA_EXCEEDED |
No alcanzan las descargas de tu plan; details trae el límite y lo que queda. |
Con items, lo responde el POST. Por periodo, el tamaño se conoce después de listar SIRE, así que suele llegar como status: "failed" con este error.code. |
429 RATE_LIMITED |
Demasiadas solicitudes. | Espera los segundos de Retry-After. |
Lo que no cubre esta guía
La API tiene más rutas que estas tres:
- Descarga individual:
GET /v1/comprobantes/…entrega al instante el XML, PDF o CDR de un solo comprobante. - Resultado por comprobante:
GET /v1/bulk-downloads/{id}/itemsdice qué archivo falló y por qué. - Historial y cancelación: listar tus descargas masivas, o cancelar una en curso.
- Reintentos e idempotencia:
maxRetriespor archivo y el encabezadoIdempotency-Key, para reintentar unPOSTsin crear un trabajo duplicado.
Todas están en la documentación para desarrolladores, con los campos de cada ruta, ejemplos que cambian mientras editas los valores y un botón Simular respuesta que muestra lo que respondería producción, sin enviar nada. También puedes bajar la especificación OpenAPI 3.1 para generar un cliente en tu lenguaje o importarla en Postman.
¿Prefieres no programar? La misma descarga está en la app, a unos clics: descarga masiva de XML de facturas SUNAT.