Desarrolladores · v1.0.0

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. 1

    Agrega la empresa

    En Perunio, con sus credenciales SOL guardadas.

  2. 2

    Crea una clave

    En Integraciones → API. Se muestra una sola vez: guárdala como secreto.

  3. 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.

Catálogo 01 completo

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.

JavaScript · Node.js 18 o superior
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 masiva
const 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 termine
while (!["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 temporal
const 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()));
No se envía nada: es lo que respondería producción.
Estados de una descarga masiva
queuedAceptado; todavía no empieza.
listingSolo por periodo: listando el periodo en SIRE. totalItems es 0 hasta que termine.
processingDescargando. processedItems avanza.
completedTerminó. Puede tener fallidos (failedFiles): revísalos en /items.
failedFalló entero; error.code dice por qué. Con INTERRUPTED, créalo de nuevo con otra Idempotency-Key.
cancelledCancelado. Lo ya descargado sigue contando en el plan.

El objeto trabajo

idstring
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.
periodstringPeriodo SIRE, AAAAMM.
fileTypes"xml" | "pdf" | "cdr"[]
maxRetriesinteger
totalItemsintegerComprobantes del trabajo. 0 mientras se lista el periodo.
processedItemsinteger
downloadedFilesintegerArchivos descargados (cada uno consumió 1 descarga del plan).
failedFilesinteger
fileAvailablebooleanSi /file entrega el ZIP ahora.
fileSizeBytesinteger | null
expiresAtstring | nullCuándo se borra el ZIP. null: no vence.
errorobject | nullPor qué falló el trabajo, si status es failed.
createdAtstring
startedAtstring | null
completedAtstring | null
linksobject
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_ERRORSolicitud inválida; details lista cada problema con su path.
NOT_DOWNLOADABLESUNAT solo entrega archivos de los tipos 01, 02, 03, 07 y 08.
INVALID_API_KEYFalta la clave, no es válida o fue revocada.
MODULE_NOT_ENTITLEDEl plan no incluye la API (Contabilidad Pro o el módulo API Perunio).
ACCOUNT_DISABLEDLa cuenta de Perunio está desactivada.
COMPANY_CREDENTIALS_MISSINGLa empresa ya no tiene credenciales SOL guardadas.
COMPANY_UNAVAILABLEEmpresa bloqueada por el límite del plan, o por eliminarse.
DOWNLOAD_QUOTA_EXCEEDEDNo alcanzan las descargas del plan; details trae el límite y lo que queda.
SUNAT_ACCESS_FORBIDDENEl usuario SOL no tiene acceso a esa consulta.
NOT_FOUNDNo existe, o no es de esta clave.
COMPROBANTE_NOT_FOUNDSUNAT no tiene ese archivo.
NO_FILESEl trabajo terminó sin archivos.
ACTIVE_JOB_EXISTSYa hay una descarga masiva activa; details.jobId es su id.
JOB_NOT_FINISHEDEl trabajo todavía no terminó con éxito.
JOB_ALREADY_FINISHEDYa terminó; no se puede cancelar.
IDEMPOTENCY_KEY_REUSEDEsa Idempotency-Key ya se usó con otro cuerpo.
FILE_EXPIREDEl ZIP superó la retención del plan.
PAYLOAD_TOO_LARGEEl cuerpo pesa más de 2 MB.
SIRE_CREDENTIALS_MISSINGPara bajar un periodo, la empresa necesita sus credenciales de API SIRE.
SUNAT_AUTH_FAILEDSUNAT rechazó las credenciales SOL; actualízalas en Perunio.
SUNAT_REJECTEDSUNAT rechazó la solicitud.
SOL_TWO_FACTOR_REQUIREDEl usuario SOL pide doble factor.
RATE_LIMITEDDemasiadas solicitudes; espera lo que indica Retry-After.
INTERNAL_ERRORError inesperado. Si persiste, escríbenos con el requestId.
INTERRUPTEDSe interrumpió (por ejemplo, un reinicio). Créalo de nuevo.
SUNAT_UNAVAILABLESUNAT no responde. Reintenta en unos minutos.
STORAGE_UNAVAILABLEAlmacenamiento no disponible. Reintenta más tarde.
API_KEYS_UNAVAILABLELas claves no están disponibles en este momento.
Límites y plan
Descargas1 por archivo bajado con éxito, como en la app. Ilimitadas en Contabilidad Pro.
Retención del ZIPLa del plan. En Contabilidad Pro no vence.
Masivas activas1 por empresa a la vez (aparte de las de la app).
Comprobantes por trabajoHasta 10 000.
Solicitudes120 por minuto por clave; 600 por minuto por IP.
Creación de masivas20 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.