Volver al blog
APIDescarga MasivaDesarrolladoresComprobantes de PagoSUNATPerunio

Descarga masiva de XML de SUNAT por API (guía en JavaScript)

6 min de lectura
Descarga masiva de XML de SUNAT por API (guía en JavaScript)

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

  1. La empresa en Perunio, agregada con sus credenciales SOL guardadas.
  2. 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.
  3. 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}/items dice qué archivo falló y por qué.
  • Historial y cancelación: listar tus descargas masivas, o cancelar una en curso.
  • Reintentos e idempotencia: maxRetries por archivo y el encabezado Idempotency-Key, para reintentar un POST sin 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.

Volver al blog