APIs Peru

Documentación de las APIs

Facturación electrónica, consultas y administración de empresas. Base: https://api.apisperu.pro

Pruébalo sin registrarte

Entras al panel con una empresa de ejemplo y un plan activo, en el ambiente de pruebas de SUNAT. La cuenta y sus datos se borran solos en 3 horas.

Hay dos credenciales distintas y no son intercambiables. La de Cuenta consulta y administra empresas; la de Empresa emite comprobantes. Cada una responde 403 en las rutas de la otra: no es una jerarquía, son dos llaves para dos puertas.

¿Buscas todo en un solo documento?

Hay una guía técnica completa para integración automatizada, con el contrato de cada endpoint en una sola página.

Ver guía de integración

Autenticación

Todo empieza aquí. Con tu client_id y client_secret pides un token, y ese token acompaña al resto de las peticiones en la cabecera Authorization.

De dónde salen las credenciales

  • Credencial de Cuenta — panel, Mi cuenta > Integraciones API. Una por cuenta; sirve para consultar y administrar empresas.
  • Credencial de Empresa — panel, Mi cuenta > Empresas y sedes, icono del escudo. Una por empresa; sirve para emitir sus comprobantes.

El client_secret se muestra una sola vez, al crearlo. No se puede volver a consultar: si se pierde, se regenera.

Obtener un token

Petición
curl -X POST https://api.apisperu.pro/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "client_id": "cli_XXXXXXXXXXXX",
    "client_secret": "sec_XXXXXXXXXXXXXXXXXXXX"
  }'
$respuesta = Http::acceptJson()->post('https://api.apisperu.pro/api/v1/auth/token', [
    'client_id'     => 'cli_XXXXXXXXXXXX',
    'client_secret' => 'sec_XXXXXXXXXXXXXXXXXXXX',
]);

$token = $respuesta->json('data.access_token');
const respuesta = await fetch('https://api.apisperu.pro/api/v1/auth/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({
    client_id: 'cli_XXXXXXXXXXXX',
    client_secret: 'sec_XXXXXXXXXXXXXXXXXXXX',
  }),
});

const { data } = await respuesta.json();
const token = data.access_token;
Respuesta
{
  "success": true,
  "data": {
    "access_token": "12|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "token_type": "Bearer",
    "expires_at": "2026-08-16T21:30:00.000000Z"
  }
}

Usar el token

Petición
curl https://api.apisperu.pro/api/v1/auth/context \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
$respuesta = Http::withToken($token)
    ->acceptJson()
    ->get('https://api.apisperu.pro/api/v1/auth/context');
const respuesta = await fetch('https://api.apisperu.pro/api/v1/auth/context', {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Accept': 'application/json',
  },
});
Respuesta
{
  "success": true,
  "message": "Integración autenticada.",
  "data": {
    "api_client": { "uuid": "...", "name": "Sistema comercial", "scope": "company" },
    "account": { "safe_identifier": "cuenta.demo", "name": "Empresa Demo SAC" },
    "companies": [
      { "company_code": "EMP-000123", "ruc": "20600000001", "name": "Empresa Demo SAC", "setup_status": "complete", "active": true }
    ],
    "token": { "expires_at": "2026-08-16T21:30:00+00:00", "abilities": ["api:consume"] }
  }
}

Qué conviene saber

  • El token dura 60 minutos. Al vencer se pide otro con las mismas credenciales. No hace falta guardarlo más allá de eso.
  • /auth/context dice con qué credencial estás. Si api_client.scope es account no podrás emitir, y si es company no podrás consultar RUC/DNI ni administrar empresas. Es la primera llamada que conviene hacer cuando algo responde 403.
  • companies siempre es "tu" empresa, nunca todas. Con scope: company devuelve un único elemento: la empresa que esa credencial emite (nunca el resto de empresas de la cuenta, aunque existan). Con scope: account sí lista todas las tuyas, porque esa credencial las administra. Úsalo para que tu sistema averigüe su company_code solo, sin que nadie lo teclee a mano.
  • POST /api/v1/auth/revoke invalida el token actual. No toca la credencial: eso se hace desde el panel.
  • Puedes restringir una credencial por IP y ponerle fecha de vencimiento al crearla.

Errores

Código Qué significa
401 Credenciales inválidas, integración inactiva o vencida, o empresa desactivada. También al usar un token ya caducado.
403 La credencial es válida pero no es la de esa ruta, o la IP no está entre las permitidas.
422 Falta client_id o client_secret.
429 Más de 10 solicitudes de token por minuto. Reintenta pasado el minuto.

Consultas

RUC, DNI, establecimientos y tipo de cambio. Exigen la credencial de Cuenta y responden desde las tablas locales del sistema: no hay proveedor externo detrás, así que la respuesta no depende de que SUNAT esté disponible.

Requieren al menos una empresa con plan vigente y comprobantes disponibles. Sin eso responden 402. Las consultas no descuentan comprobantes de tu plan: son parte de lo que incluye, no un consumo aparte.

Endpoint Devuelve
GET /api/consultas/v1/ruc/{ruc} Razón social, estado, condición, ubigeo y dirección fiscal.
GET /api/consultas/v1/dni/{dni} Nombres, apellidos y ubigeo.
GET /api/consultas/v1/establecimientos/{ruc} Lista de locales anexos declarados.
GET /api/consultas/v1/tipo-cambio Compra y venta que rigen una fecha.

Consultar un RUC

Petición
curl https://api.apisperu.pro/api/consultas/v1/ruc/20100047218 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
$respuesta = Http::withToken($token)
    ->acceptJson()
    ->get('https://api.apisperu.pro/api/consultas/v1/ruc/20100047218');

if ($respuesta->status() === 404) {
    // El RUC no esta en el padron.
}
const respuesta = await fetch(
  'https://api.apisperu.pro/api/consultas/v1/ruc/20100047218',
  { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }
);

if (respuesta.status === 404) {
  // El RUC no esta en el padron.
}
Respuesta
{
  "success": true,
  "message": "RUC consultado correctamente.",
  "document_type": "6",
  "document_number": "20131312955",
  "entity_type": "company",
  "display_name": "SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA - SUNAT",
  "business_name": "SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA - SUNAT",
  "status": "ACTIVO",
  "condition": "HABIDO",
  "ubigeo_code": "150101",
  "department": "LIMA",
  "province": "LIMA",
  "district": "LIMA",
  "address": "AV. GARCILASO DE LA VEGA NRO. 1472",
  "fiscal_address": "AV. GARCILASO DE LA VEGA NRO. 1472 LIMA - LIMA - LIMA",
  "location_text": "LIMA - LIMA - LIMA"
}

La respuesta va plana, sin envolver en data. address se compone a partir de las piezas sueltas que publica SUNAT (vía, número, interior, manzana, lote...): cuando el padrón no las declara para ese contribuyente, viene en null — es dato disperso real de SUNAT, no un campo que falte.

Consultar un DNI

Petición
curl https://api.apisperu.pro/api/consultas/v1/dni/41375341 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
$respuesta = Http::withToken($token)
    ->acceptJson()
    ->get('https://api.apisperu.pro/api/consultas/v1/dni/41375341');

if ($respuesta->status() === 404) {
    // El DNI no esta en el padron.
}
const respuesta = await fetch(
  'https://api.apisperu.pro/api/consultas/v1/dni/41375341',
  { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }
);

if (respuesta.status === 404) {
  // El DNI no esta en el padron.
}
Respuesta
{
  "success": true,
  "message": "DNI consultado correctamente.",
  "document_type": "1",
  "document_number": "41375341",
  "entity_type": "person",
  "display_name": "MARITZA ABANTO ABANTO",
  "full_name": "MARITZA ABANTO ABANTO",
  "first_names": "MARITZA",
  "last_names": "ABANTO ABANTO",
  "paternal_surname": "ABANTO",
  "maternal_surname": "ABANTO",
  "ubigeo_code": "010101",
  "department": "AMAZONAS",
  "province": "CHACHAPOYAS",
  "district": "CHACHAPOYAS",
  "location_text": "AMAZONAS - CHACHAPOYAS - CHACHAPOYAS",
  "voting_group": "000001"
}

RUC y DNI devuelven la misma estructura de campos; los que no aplican al tipo de documento vienen en null (un DNI nunca trae address ni status; un RUC nunca trae first_names ni voting_group).

Tipo de cambio de una fecha

Petición
# Sin el parametro fecha, responde el que rige hoy.
curl "https://api.apisperu.pro/api/consultas/v1/tipo-cambio?fecha=2026-08-16" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
$respuesta = Http::withToken($token)
    ->acceptJson()
    ->get('https://api.apisperu.pro/api/consultas/v1/tipo-cambio', [
        'fecha' => '2026-08-16',
    ]);

$venta = $respuesta->json('data.venta');
const url = new URL('https://api.apisperu.pro/api/consultas/v1/tipo-cambio');
url.searchParams.set('fecha', '2026-08-16');

const respuesta = await fetch(url, {
  headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
Respuesta
{
  "success": true,
  "data": {
    "moneda": "USD",
    "fecha": "2026-08-16",
    "compra": "3.358",
    "venta": "3.368",
    "fecha_publicacion": "2026-08-14",
    "vigente_desde": "2026-08-15",
    "arrastrado": true,
    "dias_arrastre": 1,
    "fuente": "bcrp"
  }
}

Cómo funciona el tipo de cambio

Lo que la SBS publica un día rige al día siguiente, y sigue rigiendo mientras no haya otra publicación. Por eso sábado, domingo y lunes comparten el valor del viernes: la publicación del lunes recién rige el martes.

No hace falta que calcules días hábiles ni feriados: preguntas por una fecha y recibes el que rige. Los campos arrastrado y dias_arrastre te dicen si el valor viene de una publicación anterior, por si tu integración quiere distinguirlo.

Errores

402 Ninguna de tus empresas tiene plan vigente con comprobantes disponibles. Código: no_active_company_plan.
403 Estás usando la credencial de una Empresa. Estas rutas son de la Cuenta.
404 El documento no está en el padrón. En establecimientos, un RUC real sin anexos responde 200 con lista vacía, no 404.
422 RUC que no tiene 11 dígitos, DNI que no tiene 8, o fecha fuera del formato AAAA-MM-DD.
429 Más de 60 consultas por minuto.

Si las consultas te van lentas, mira esto primero

La búsqueda en sí tarda menos de un milisegundo: los padrones están indexados por RUC y por DNI. Casi todo el tiempo que mides es de red, y la mayor parte se va en abrir la conexión, no en la consulta.

Conexión nueva en cada llamada ~440 ms
Reutilizando la conexión ~180 ms

Son 260 ms por consulta que se ahorran sin tocar nada del servidor. Dos reglas:

  • Reutiliza el cliente HTTP. Crear uno nuevo por consulta obliga a repetir el saludo TLS cada vez. En PHP, guarda el cliente de Guzzle; en Node, un Agent con keepAlive: true; con cURL en línea de comandos, pasa varias URL a la misma invocación.
  • Reutiliza el token durante sus 60 minutos de vida. Pedir uno nuevo antes de cada consulta duplica los viajes de ida y vuelta, y además consume el límite de 10 por minuto del endpoint de autenticación.

Y si mides desde Postman: abre conexión nueva en cada envío, así que el número que muestra es el de la primera fila, no el que verá tu integración.

Validar muchos documentos de una vez

No hay un endpoint por lotes, y no hace falta: el servicio habla HTTP/2, así que varias consultas viajan multiplexadas por una sola conexión. Lo que importa es lanzarlas a la vez. Un foreach que espera cada respuesta antes de pedir la siguiente no aprovecha nada: son veinte viajes de ida y vuelta en fila.

20 consultas Tiempo
Una tras otra, abriendo conexión cada vez ~9 560 ms
Una tras otra, reutilizando la conexión ~3 490 ms
En paralelo, HTTP/1.1 ~960 ms
En paralelo, HTTP/2 ~770 ms

Consultar varios RUC en paralelo

Petición
# --parallel lanza las peticiones a la vez sobre una sola conexion.
curl --parallel --parallel-max 10 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  https://api.apisperu.pro/api/consultas/v1/ruc/20100047218 \
  https://api.apisperu.pro/api/consultas/v1/ruc/20131312955 \
  https://api.apisperu.pro/api/consultas/v1/ruc/20604742115
// Http::pool lanza todas a la vez y espera al conjunto.
$rucs = ['20100047218', '20131312955', '20604742115'];

$respuestas = Http::pool(fn ($pool) => array_map(
    fn ($ruc) => $pool->as($ruc)
        ->withToken($token)
        ->acceptJson()
        ->get("https://api.apisperu.pro/api/consultas/v1/ruc/{$ruc}"),
    $rucs
));

foreach ($rucs as $ruc) {
    $respuesta = $respuestas[$ruc];

    if ($respuesta->status() === 404) {
        continue;   // No esta en el padron.
    }

    $empresa = $respuesta->json('data');
}

// Manten el limite por minuto en mente: si el lote es grande,
// partelo en tandas en vez de lanzar mil de golpe.
// Promise.all lanza todas a la vez; fetch reutiliza la conexion.
const rucs = ['20100047218', '20131312955', '20604742115'];

const respuestas = await Promise.all(
  rucs.map((ruc) =>
    fetch(`https://api.apisperu.pro/api/consultas/v1/ruc/${ruc}`, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    }).then(async (r) => ({ ruc, estado: r.status, cuerpo: await r.json() }))
  )
);

for (const { ruc, estado, cuerpo } of respuestas) {
  if (estado === 404) continue;   // No esta en el padron.
  console.log(ruc, cuerpo.data.business_name);
}

// En Node, si usas un cliente propio en vez de fetch, dale un Agent
// con keepAlive: true o volveras a pagar el saludo TLS en cada tanda.

Cuidado con el límite. El paralelismo no lo esquiva: siguen siendo 60 consultas por minuto. Lanzar un lote de mil de golpe te devolverá 429 a partir de la número 60. Parte los lotes en tandas y respeta la cabecera Retry-After cuando llegue.

Empresas

Listar y crear empresas exige la credencial de Cuenta (la misma de las consultas). La ficha, edición y aprovisionamiento SUNAT de una empresa exigen la credencial de esa propia empresa — la misma que emite en /api/v2/cpe: ya identifica una única empresa sin ambigüedad, así que esas rutas no llevan código de empresa en la URL.

Listar y crear no exigen tener un plan, a diferencia de las consultas. Es deliberado: una cuenta nueva no tiene empresas, y si se bloquearan aquí no podría crear la primera ni contratar nada.

Endpoint Para qué Credencial
GET /api/v2/account/context Datos de tu cuenta. Cuenta
GET /api/v2/companies Listado de tus empresas. Cuenta
POST /api/v2/companies Registrar una empresa. Cuenta
GET /api/v2/company Ficha de tu empresa. Empresa
PATCH /api/v2/company Actualizar sus datos. Empresa
PUT /api/v2/company/sunat-credentials Usuario y clave SOL. Empresa
PUT /api/v2/company/gre-credentials Credenciales API REST de Guía de Remisión. Empresa
POST /api/v2/company/digital-certificate Certificado digital (.pfx/.p12). Empresa

Registrar una empresa

Petición
curl -X POST https://api.apisperu.pro/api/v2/companies \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "ruc": "20600000001",
    "business_name": "MI EMPRESA SAC",
    "commercial_name": "Mi Empresa",
    "address": "AV. SIEMPRE VIVA 123",
    "ubigeo": "150101",
    "department": "LIMA",
    "province": "LIMA",
    "district": "LIMA",
    "email": "contacto@miempresa.com",
    "phone": "999999999"
  }'
$respuesta = Http::withToken($token)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/companies', [
        'ruc'             => '20600000001',
        'business_name'   => 'MI EMPRESA SAC',
        'commercial_name' => 'Mi Empresa',
        'address'         => 'AV. SIEMPRE VIVA 123',
        'ubigeo'          => '150101',
        'department'      => 'LIMA',
        'province'        => 'LIMA',
        'district'        => 'LIMA',
        'email'           => 'contacto@miempresa.com',
        'phone'           => '999999999',
    ]);

$codigo = $respuesta->json('data.company_code');
const respuesta = await fetch('https://api.apisperu.pro/api/v2/companies', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    ruc: '20600000001',
    business_name: 'MI EMPRESA SAC',
    address: 'AV. SIEMPRE VIVA 123',
    ubigeo: '150101',
    department: 'LIMA',
    province: 'LIMA',
    district: 'LIMA',
  }),
});
Respuesta
{
  "success": true,
  "data": {
    "company_code": "EMP-XXXXXXXX",
    "ruc": "20600000001",
    "business_name": "MI EMPRESA SAC"
  }
}

De aquí en adelante, todo se hace con la credencial de esa empresa (panel: Empresas → icono del escudo → Crear credencial) — la misma que usarás luego para emitir. Genérala justo después de crear la empresa; el resto de esta página ya no vuelve a usar la credencial de Cuenta.

Cargar el certificado digital

Petición
curl -X POST https://api.apisperu.pro/api/v2/company/digital-certificate \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Accept: application/json" \
  -F "certificate_file=@/ruta/a/tu/certificado.pfx" \
  -F "certificate_password=tu-clave-del-certificado"
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->attach('certificate_file', file_get_contents('/ruta/a/tu/certificado.pfx'), 'certificado.pfx')
    ->post('https://api.apisperu.pro/api/v2/company/digital-certificate', [
        'certificate_password' => 'tu-clave-del-certificado',
    ]);

$conectado = $respuesta->json('data.connected');
const formulario = new FormData();
formulario.append('certificate_file', archivoCertificado); // File del input
formulario.append('certificate_password', 'tu-clave-del-certificado');

const respuesta = await fetch('https://api.apisperu.pro/api/v2/company/digital-certificate', {
  method: 'POST',
  headers: { Authorization: `Bearer ${tokenEmpresa}` },
  body: formulario,
});
Respuesta
{
  "success": true,
  "message": "Certificado digital guardado correctamente.",
  "data": {
    "connected": true,
    "status": "valid",
    "serial_number": "1A2B3C",
    "fingerprint_sha256": "AB:CD:...",
    "valid_from": "2026-01-01T00:00:00+00:00",
    "valid_until": "2027-01-01T00:00:00+00:00",
    "days_until_expiration": 102
  }
}

PUT /api/v2/company/sunat-credentials funciona igual, con sol_username y sol_password en JSON normal (no multipart). PUT /api/v2/company/gre-credentials es el mismo contrato, con client_id y client_secret — la credencial OAuth2 que generas en el menú SOL específicamente para la API de Guía de Remisión Electrónica, distinta del Usuario/Clave SOL. En las tres rutas, el primer guardado exige ambos campos; en los siguientes, la clave/secreto es opcional — si se omite, se conserva la actual. Ninguna devuelve la clave, el secreto ni la contraseña del certificado: solo connected y los metadatos públicos.

El código de empresa no se envía, se recibe

company_code lo genera el servidor al crear la empresa y no cambia nunca. Mandarlo en el cuerpo de una petición se rechaza con 422, aquí y al emitir. Las rutas de ficha/edición/aprovisionamiento ni siquiera lo piden: la credencial de Empresa ya sabe cuál es la suya.

Lo mismo con user_id, team_id, status y otros campos internos: se rechazan en vez de ignorarse, para que quede claro que no deciden nada.

Errores

403 Credencial equivocada para la ruta: de Cuenta en /company*, o de Empresa en GET/POST /companies.
404 Empresa inexistente o ajena — solo aplica a GET/POST /companies. /company nunca lo produce: siempre resuelve la empresa de tu propia credencial.
422 Datos inválidos, RUC repetido, o campos reservados en el cuerpo.
429 Más de 120 lecturas o 20 escrituras por minuto.

Facturación

Emisión de comprobantes electrónicos. Exige la credencial de la Empresa que emite: cada empresa tiene la suya y no sirve para las demás.

Un solo endpoint para Factura, Boleta y Notas.

  • Facturas (tipo 01, serie F###) y boletas (tipo 03, serie B###): una a una, por POST /api/v2/cpe. Misma respuesta síncrona para las dos — la boleta no necesita pasar por resumen diario, aunque esa vía sigue disponible para quien prefiera agrupar (más abajo).
  • Notas de crédito/débito (tipo 07/08): también por POST /api/v2/cpe, con reference_document y reason_code adicionales.

Emisión síncrona y determinista

Una llamada a /api/v2/cpe prepara el XML, lo firma, lo empaqueta, lo transmite a SUNAT y solo entonces persiste el comprobante. Cuando responde, la suerte está echada.

Un rechazo de SUNAT también responde 201. Es un desenlace válido, no un error de la API: el estado viene dentro de la respuesta.

Emitir una factura

Petición
curl -X POST https://api.apisperu.pro/api/v2/cpe \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "VTA-000123",
    "document_type_code": "01",
    "series": "F001",
    "number": "123",
    "issue_date": "2026-08-16",
    "currency_code": "PEN",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "operation_type_code": "0101",
    "issuer": { "ruc": "20600000001" },
    "customer": {
      "document_type_code": "6",
      "document_number": "20100047218",
      "name": "BANCO DE CREDITO DEL PERU",
      "address": "AV. CENTENARIO NRO. 156"
    },
    "items": [{
      "description": "Servicio de desarrollo",
      "quantity": "1.00",
      "unit_code": "ZZ",
      "unit_value": "1500.00",
      "line_value": "1500.00",
      "igv_type_code": "10",
      "igv_percentage": "18.00",
      "igv_amount": "270.00",
      "line_total": "1770.00"
    }],
    "totals": {
      "taxable_amount": "1500.00",
      "exonerated_amount": "0.00",
      "unaffected_amount": "0.00",
      "igv_amount": "270.00",
      "total_amount": "1770.00"
    },
    "payment": { "type": "contado" }
  }'
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/cpe', [
        'external_reference' => 'VTA-000123',
        'document_type_code' => '01',
        'series'             => 'F001',
        'number'             => '123',
        'issue_date'         => '2026-08-16',
        'currency_code'      => 'PEN',
        'country_code'       => 'PE',
        'sunat_establishment_code' => '0000',
        'operation_type_code'      => '0101',
        'issuer'   => ['ruc' => '20600000001'],
        'customer' => [
            'document_type_code' => '6',
            'document_number'    => '20100047218',
            'name'               => 'BANCO DE CREDITO DEL PERU',
        ],
        'items' => [[
            'description'    => 'Servicio de desarrollo',
            'quantity'       => '1.00',
            'unit_code'      => 'ZZ',
            'unit_value'     => '1500.00',
            'line_value'     => '1500.00',
            'igv_type_code'  => '10',
            'igv_percentage' => '18.00',
            'igv_amount'     => '270.00',
            'line_total'     => '1770.00',
        ]],
        'totals' => [
            'taxable_amount'    => '1500.00',
            'exonerated_amount' => '0.00',
            'unaffected_amount' => '0.00',
            'igv_amount'        => '270.00',
            'total_amount'      => '1770.00',
        ],
        'payment' => ['type' => 'contado'],
    ]);
const respuesta = await fetch('https://api.apisperu.pro/api/v2/cpe', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'VTA-000123',
    document_type_code: '01',
    series: 'F001',
    number: '123',
    issue_date: '2026-08-16',
    currency_code: 'PEN',
    country_code: 'PE',
    sunat_establishment_code: '0000',
    operation_type_code: '0101',
    issuer: { ruc: '20600000001' },
    customer: {
      document_type_code: '6',
      document_number: '20100047218',
      name: 'BANCO DE CREDITO DEL PERU',
    },
    items: [{
      description: 'Servicio de desarrollo',
      quantity: '1.00',
      unit_code: 'ZZ',
      unit_value: '1500.00',
      line_value: '1500.00',
      igv_type_code: '10',
      igv_percentage: '18.00',
      igv_amount: '270.00',
      line_total: '1770.00',
    }],
    totals: {
      taxable_amount: '1500.00',
      exonerated_amount: '0.00',
      unaffected_amount: '0.00',
      igv_amount: '270.00',
      total_amount: '1770.00',
    },
    payment: { type: 'contado' },
  }),
});

Cómo se arman los importes

  • unit_value va sin IGV. line_value = cantidad × valor unitario.
  • igv_type_code decide a qué total suma la línea: 10 gravado, 20 exonerado, 30 inafecto.
  • total_amount = gravado + exonerado + inafecto + IGV.
  • Los importes van como cadena, no como número: en coma flotante «0.1 + 0.2» no da «0.30», y un comprobante no admite ese margen.
  • Con payment.type = "credito" hay que enviar las cuotas, y su suma debe cuadrar con el total.

Factura al crédito, en cuotas

Petición
curl -X POST https://api.apisperu.pro/api/v2/cpe \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "VTA-CRED-000124",
    "document_type_code": "01",
    "series": "F001",
    "number": "124",
    "issue_date": "2026-08-16",
    "currency_code": "PEN",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "operation_type_code": "0101",
    "issuer": { "ruc": "20600000001" },
    "customer": {
      "document_type_code": "6",
      "document_number": "20131312955",
      "name": "SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA",
      "address": "AV. GARCILASO DE LA VEGA NRO. 1472 - LIMA"
    },
    "items": [{
      "description": "Laptop 14 pulgadas 16GB RAM",
      "quantity": "2.00",
      "unit_code": "NIU",
      "unit_value": "2500.00",
      "line_value": "5000.00",
      "igv_type_code": "10",
      "igv_percentage": "18.00",
      "igv_amount": "900.00",
      "line_total": "5900.00"
    }],
    "totals": {
      "taxable_amount": "5000.00",
      "exonerated_amount": "0.00",
      "unaffected_amount": "0.00",
      "igv_amount": "900.00",
      "total_amount": "5900.00"
    },
    "payment": {
      "type": "credito",
      "installments": [
        { "amount": "2950.00", "due_date": "2026-09-15" },
        { "amount": "2950.00", "due_date": "2026-10-15" }
      ]
    }
  }'
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/cpe', [
        'external_reference' => 'VTA-CRED-000124',
        'document_type_code' => '01',
        'series'             => 'F001',
        'number'             => '124',
        'issue_date'         => '2026-08-16',
        'currency_code'      => 'PEN',
        'country_code'       => 'PE',
        'sunat_establishment_code' => '0000',
        'operation_type_code'      => '0101',
        'issuer'   => ['ruc' => '20600000001'],
        'customer' => [
            'document_type_code' => '6',
            'document_number'    => '20131312955',
            'name'               => 'SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA',
        ],
        'items' => [[
            'description'    => 'Laptop 14 pulgadas 16GB RAM',
            'quantity'       => '2.00',
            'unit_code'      => 'NIU',
            'unit_value'     => '2500.00',
            'line_value'     => '5000.00',
            'igv_type_code'  => '10',
            'igv_percentage' => '18.00',
            'igv_amount'     => '900.00',
            'line_total'     => '5900.00',
        ]],
        'totals' => [
            'taxable_amount'    => '5000.00',
            'exonerated_amount' => '0.00',
            'unaffected_amount' => '0.00',
            'igv_amount'        => '900.00',
            'total_amount'      => '5900.00',
        ],
        'payment' => [
            'type' => 'credito',
            'installments' => [
                ['amount' => '2950.00', 'due_date' => '2026-09-15'],
                ['amount' => '2950.00', 'due_date' => '2026-10-15'],
            ],
        ],
    ]);
const respuesta = await fetch('https://api.apisperu.pro/api/v2/cpe', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'VTA-CRED-000124',
    document_type_code: '01',
    series: 'F001',
    number: '124',
    issue_date: '2026-08-16',
    currency_code: 'PEN',
    country_code: 'PE',
    sunat_establishment_code: '0000',
    operation_type_code: '0101',
    issuer: { ruc: '20600000001' },
    customer: {
      document_type_code: '6',
      document_number: '20131312955',
      name: 'SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA',
    },
    items: [{
      description: 'Laptop 14 pulgadas 16GB RAM',
      quantity: '2.00',
      unit_code: 'NIU',
      unit_value: '2500.00',
      line_value: '5000.00',
      igv_type_code: '10',
      igv_percentage: '18.00',
      igv_amount: '900.00',
      line_total: '5900.00',
    }],
    totals: {
      taxable_amount: '5000.00',
      exonerated_amount: '0.00',
      unaffected_amount: '0.00',
      igv_amount: '900.00',
      total_amount: '5900.00',
    },
    payment: {
      type: 'credito',
      installments: [
        { amount: '2950.00', due_date: '2026-09-15' },
        { amount: '2950.00', due_date: '2026-10-15' },
      ],
    },
  }),
});

De 1 a 36 cuotas. La suma de installments[].amount debe cuadrar exactamente con total_amount, y cada due_date va en formato AAAA-MM-DD. Con payment.type = "contado" no se envían cuotas.

Factura mixta: gravado, exonerado e inafecto

Petición
curl -X POST https://api.apisperu.pro/api/v2/cpe \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "VTA-MIX-000125",
    "document_type_code": "01",
    "series": "F001",
    "number": "125",
    "issue_date": "2026-08-16",
    "currency_code": "PEN",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "operation_type_code": "0101",
    "issuer": { "ruc": "20600000001" },
    "customer": {
      "document_type_code": "6",
      "document_number": "20100047218",
      "name": "BANCO DE CREDITO DEL PERU"
    },
    "items": [
      {
        "description": "Monitor 24 pulgadas",
        "quantity": "3.00",
        "unit_code": "NIU",
        "unit_value": "600.00",
        "line_value": "1800.00",
        "igv_type_code": "10",
        "igv_percentage": "18.00",
        "igv_amount": "324.00",
        "line_total": "2124.00"
      },
      {
        "description": "Libro tecnico de contabilidad",
        "quantity": "5.00",
        "unit_code": "NIU",
        "unit_value": "80.00",
        "line_value": "400.00",
        "igv_type_code": "20",
        "igv_percentage": "0.00",
        "igv_amount": "0.00",
        "line_total": "400.00"
      },
      {
        "description": "Servicio de transporte de carga internacional",
        "quantity": "1.00",
        "unit_code": "ZZ",
        "unit_value": "950.00",
        "line_value": "950.00",
        "igv_type_code": "30",
        "igv_percentage": "0.00",
        "igv_amount": "0.00",
        "line_total": "950.00"
      }
    ],
    "totals": {
      "taxable_amount": "1800.00",
      "exonerated_amount": "400.00",
      "unaffected_amount": "950.00",
      "igv_amount": "324.00",
      "total_amount": "3474.00"
    },
    "payment": { "type": "contado" }
  }'
// Tres regimenes de IGV en el mismo comprobante:
// igv_type_code 10 = gravado (suma a taxable_amount y genera IGV)
// igv_type_code 20 = exonerado (suma a exonerated_amount, IGV en cero)
// igv_type_code 30 = inafecto (suma a unaffected_amount, IGV en cero)
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/cpe', [
        'external_reference' => 'VTA-MIX-000125',
        'document_type_code' => '01',
        'series'             => 'F001',
        'number'             => '125',
        'issue_date'         => '2026-08-16',
        'currency_code'      => 'PEN',
        'country_code'       => 'PE',
        'sunat_establishment_code' => '0000',
        'operation_type_code'      => '0101',
        'issuer'   => ['ruc' => '20600000001'],
        'customer' => [
            'document_type_code' => '6',
            'document_number'    => '20100047218',
            'name'               => 'BANCO DE CREDITO DEL PERU',
        ],
        'items' => [
            ['description' => 'Monitor 24 pulgadas', 'quantity' => '3.00', 'unit_code' => 'NIU', 'unit_value' => '600.00', 'line_value' => '1800.00', 'igv_type_code' => '10', 'igv_percentage' => '18.00', 'igv_amount' => '324.00', 'line_total' => '2124.00'],
            ['description' => 'Libro tecnico de contabilidad', 'quantity' => '5.00', 'unit_code' => 'NIU', 'unit_value' => '80.00', 'line_value' => '400.00', 'igv_type_code' => '20', 'igv_percentage' => '0.00', 'igv_amount' => '0.00', 'line_total' => '400.00'],
            ['description' => 'Servicio de transporte de carga internacional', 'quantity' => '1.00', 'unit_code' => 'ZZ', 'unit_value' => '950.00', 'line_value' => '950.00', 'igv_type_code' => '30', 'igv_percentage' => '0.00', 'igv_amount' => '0.00', 'line_total' => '950.00'],
        ],
        'totals' => [
            'taxable_amount'    => '1800.00',
            'exonerated_amount' => '400.00',
            'unaffected_amount' => '950.00',
            'igv_amount'        => '324.00',
            'total_amount'      => '3474.00',
        ],
        'payment' => ['type' => 'contado'],
    ]);
const respuesta = await fetch('https://api.apisperu.pro/api/v2/cpe', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'VTA-MIX-000125',
    document_type_code: '01',
    series: 'F001',
    number: '125',
    issue_date: '2026-08-16',
    currency_code: 'PEN',
    country_code: 'PE',
    sunat_establishment_code: '0000',
    operation_type_code: '0101',
    issuer: { ruc: '20600000001' },
    customer: {
      document_type_code: '6',
      document_number: '20100047218',
      name: 'BANCO DE CREDITO DEL PERU',
    },
    items: [
      { description: 'Monitor 24 pulgadas', quantity: '3.00', unit_code: 'NIU', unit_value: '600.00', line_value: '1800.00', igv_type_code: '10', igv_percentage: '18.00', igv_amount: '324.00', line_total: '2124.00' },
      { description: 'Libro tecnico de contabilidad', quantity: '5.00', unit_code: 'NIU', unit_value: '80.00', line_value: '400.00', igv_type_code: '20', igv_percentage: '0.00', igv_amount: '0.00', line_total: '400.00' },
      { description: 'Servicio de transporte de carga internacional', quantity: '1.00', unit_code: 'ZZ', unit_value: '950.00', line_value: '950.00', igv_type_code: '30', igv_percentage: '0.00', igv_amount: '0.00', line_total: '950.00' },
    ],
    totals: {
      taxable_amount: '1800.00',
      exonerated_amount: '400.00',
      unaffected_amount: '950.00',
      igv_amount: '324.00',
      total_amount: '3474.00',
    },
    payment: { type: 'contado' },
  }),
});

Cada línea suma a su total según igv_type_code, y total_amount es la suma de los tres más el IGV: 1800 + 400 + 950 + 324 = 3474. Si los totales no cuadran con las líneas, la respuesta es 422 antes de llegar a SUNAT.

Si se corta la red, reenvía lo mismo

external_reference es tu identificador de la venta y funciona como clave de idempotencia. Reenviar exactamente el mismo devuelve el comprobante ya emitido en lugar de emitir otro, y no descuenta cuota. Por eso debe ser estable, no un aleatorio nuevo en cada intento.

Boleta directa

El mismo POST /api/v2/cpe del ejemplo de arriba, cambiando document_type_code a "03" y la serie a B###. El customer debe identificarse por DNI (document_type_code: "1") o sin documento — nunca por RUC. La respuesta llega igual de rápido que una factura, con el CDR de SUNAT en la misma llamada.

Notas de crédito y débito

document_type_code "07" (crédito) o "08" (débito), más dos campos que Factura/Boleta no llevan:

  • reference_document: el comprobante que la nota corrige (document_type_code, series, number) — debe existir ya emitido y aceptado por tu empresa.
  • reason_code: motivo de la nota, según el catálogo SUNAT 09 (crédito) o 10 (débito) — ver el tab Públicas.

La serie de la nota debe empezar con el mismo prefijo del documento referenciado: F si corrige una Factura, B si corrige una Boleta.

Nota de crédito sobre una factura

Petición
curl -X POST https://api.apisperu.pro/api/v2/cpe \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "NC-000045",
    "document_type_code": "07",
    "series": "FC01",
    "number": "1",
    "issue_date": "2026-08-16",
    "currency_code": "PEN",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "operation_type_code": "0101",
    "issuer": { "ruc": "20600000001" },
    "customer": {
      "document_type_code": "6",
      "document_number": "20100047218",
      "name": "BANCO DE CREDITO DEL PERU"
    },
    "items": [{
      "description": "Anulacion de servicio de desarrollo",
      "quantity": "1.00",
      "unit_code": "ZZ",
      "unit_value": "1500.00",
      "line_value": "1500.00",
      "igv_type_code": "10",
      "igv_percentage": "18.00",
      "igv_amount": "270.00",
      "line_total": "1770.00"
    }],
    "totals": {
      "taxable_amount": "1500.00",
      "exonerated_amount": "0.00",
      "unaffected_amount": "0.00",
      "igv_amount": "270.00",
      "total_amount": "1770.00"
    },
    "payment": { "type": "contado" },
    "reference_document": {
      "document_type_code": "01",
      "series": "F001",
      "number": "123"
    },
    "reason_code": "01"
  }'
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/cpe', [
        // ... mismos campos de una factura ...
        'document_type_code' => '07',
        'series'              => 'FC01',
        'number'              => '1',
        'reference_document' => [
            'document_type_code' => '01',
            'series'              => 'F001',
            'number'              => '123',
        ],
        'reason_code' => '01',
    ]);
const respuesta = await fetch('https://api.apisperu.pro/api/v2/cpe', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    // ... mismos campos de una factura ...
    document_type_code: '07',
    series: 'FC01',
    number: '1',
    reference_document: {
      document_type_code: '01',
      series: 'F001',
      number: '123',
    },
    reason_code: '01',
  }),
});

Descargar el XML firmado y el CDR

Petición
# XML UBL 2.1 firmado
curl https://api.apisperu.pro/api/v2/cpe/$UUID/xml \
  -H "Authorization: Bearer $TOKEN_EMPRESA" -o comprobante.xml

# CDR (ZIP oficial de SUNAT)
curl https://api.apisperu.pro/api/v2/cpe/$UUID/cdr \
  -H "Authorization: Bearer $TOKEN_EMPRESA" -o cdr.zip
$xml = Http::withToken($tokenEmpresa)->get("https://api.apisperu.pro/api/v2/cpe/{$uuid}/xml")->body();
$cdr = Http::withToken($tokenEmpresa)->get("https://api.apisperu.pro/api/v2/cpe/{$uuid}/cdr")->body();
const xml = await (await fetch(`https://api.apisperu.pro/api/v2/cpe/${uuid}/xml`, {
  headers: { Authorization: `Bearer ${tokenEmpresa}` },
})).blob();

GET /api/v2/cpe/{uuid} solo dice si cada artifact existe (artifacts.xml, artifacts.cdr...). Estas dos rutas devuelven los bytes reales. El CDR es el único artifact opcional: un comprobante rechazado sin CDR responde 404 con un mensaje distinto al de "comprobante inexistente".

Resumen diario de boletas (alternativa a la boleta directa)

Petición
curl -X POST https://api.apisperu.pro/api/v2/daily-summaries \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -d '{
    "external_reference": "RC-20260816",
    "summary_date": "2026-08-15",
    "generation_date": "2026-08-16",
    "correlative": "1",
    "currency_code": "PEN",
    "items": [{
      "document_type_code": "03",
      "series": "B001",
      "number": "1",
      "issue_date": "2026-08-15",
      "customer": { "document_type_code": "1", "document_number": "45678912" },
      "currency_code": "PEN",
      "condition_code": "1",
      "taxable_amount": "100.00",
      "exonerated_amount": "0.00",
      "unaffected_amount": "0.00",
      "igv_amount": "18.00",
      "total_amount": "118.00"
    }]
  }'

# La respuesta trae un ticket: el proceso es asincrono.
curl -X POST https://api.apisperu.pro/api/v2/daily-summaries/$UUID/status-check \
  -H "Authorization: Bearer $TOKEN_EMPRESA"
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/daily-summaries', [
        'external_reference' => 'RC-20260816',
        'summary_date'       => '2026-08-15',
        'generation_date'    => '2026-08-16',
        'correlative'        => '1',
        'currency_code'      => 'PEN',
        'items' => [[
            'document_type_code' => '03',
            'series'             => 'B001',
            'number'             => '1',
            'issue_date'         => '2026-08-15',
            'customer' => [
                'document_type_code' => '1',
                'document_number'    => '45678912',
            ],
            'currency_code'     => 'PEN',
            'condition_code'    => '1',
            'taxable_amount'    => '100.00',
            'exonerated_amount' => '0.00',
            'unaffected_amount' => '0.00',
            'igv_amount'        => '18.00',
            'total_amount'      => '118.00',
        ]],
    ]);

$uuid = $respuesta->json('data.uuid');

// Mas tarde, el estado ante SUNAT:
Http::withToken($tokenEmpresa)
    ->post("https://api.apisperu.pro/api/v2/daily-summaries/{$uuid}/status-check");
const respuesta = await fetch('https://api.apisperu.pro/api/v2/daily-summaries', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'RC-20260816',
    summary_date: '2026-08-15',
    generation_date: '2026-08-16',
    correlative: '1',
    currency_code: 'PEN',
    items: [{
      document_type_code: '03',
      series: 'B001',
      number: '1',
      issue_date: '2026-08-15',
      customer: { document_type_code: '1', document_number: '45678912' },
      currency_code: 'PEN',
      condition_code: '1',
      taxable_amount: '100.00',
      exonerated_amount: '0.00',
      unaffected_amount: '0.00',
      igv_amount: '18.00',
      total_amount: '118.00',
    }],
  }),
});

const { data } = await respuesta.json();
// data.uuid sirve para consultar el estado despues.

El resumen descuenta tantos comprobantes como boletas contenga, no uno: son varios documentos emitidos aunque viajen en un solo envío. summary_date es el día al que pertenecen las boletas y correlative el número de resumen dentro del día de generación.

Errores

402 Sin cuota. El campo code dice cuál de los cuatro motivos: no_plan, plan_expired, quota_exhausted o plan_cancelled.
403 Estás usando la credencial de la Cuenta. Emitir exige la de la Empresa.
422 Totales que no cuadran con las líneas, issuer.ruc distinto al de la credencial, document_type_code no soportado, serie que no corresponde al tipo, company_code en el cuerpo, o (solo notas) reference_document/ reason_code ausente, inválido o inexistente.
404 Al descargar XML/CDR: comprobante inexistente, ajeno, o sin ese artifact disponible.
429 Más de 20 emisiones por minuto.

Guía de Remisión

Emisión de Guías de Remisión Electrónicas (Remitente, tipo 09). Exige la credencial de la Empresa — la misma que emite comprobantes —, y además dos contenedores de credenciales configurados de antemano (ver Empresas): las credenciales SOL de siempre, y unas credenciales GRE nuevas (client_id/ client_secret), que generas en el menú SOL específicamente para este servicio.

Alcance actual: motivo de traslado libre, transporte privado o público.

  • Transporte privado (vehículo y conductor propios de la empresa) o público (transportista tercero), según transport_mode_code. Si se omite, se asume privado.
  • Solo Guía Remitente (tipo 09). Guía Transportista (tipo 31): todavía no.

Asíncrona: la respuesta trae un ticket, no un resultado final

A diferencia de Factura/Boleta (sendBill, síncrono con CDR inmediato), la Guía de Remisión usa el servicio REST nuevo de SUNAT. Una respuesta 201 con status: "ticket_received" confirma que SUNAT aceptó el envío para procesarlo — todavía no es la decisión tributaria final. Consulta POST /api/v2/dispatch-guides/{uuid}/status-check después para obtenerla (ver ejemplo más abajo).

status: "rejected" sí es un rechazo real de SUNAT (por validación de forma), sin llegar a generar ticket.

Emitir una guía de remisión (venta, transporte privado)

Petición
curl -X POST https://api.apisperu.pro/api/v2/dispatch-guides \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "GRE-000123",
    "series": "T001",
    "number": "123",
    "issue_date": "2026-09-23",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "transfer_reason_code": "01",
    "transfer_description": null,
    "gross_weight": "150.500",
    "weight_unit_code": "KGM",
    "total_packages": 3,
    "transfer_start_date": "2026-09-23",
    "recipient": {
      "document_type_code": "6",
      "document_number": "20100047218",
      "name": "BANCO DE CREDITO DEL PERU"
    },
    "departure": { "ubigeo": "150101", "address": "Av. Siempre Viva 123" },
    "arrival": { "ubigeo": "150102", "address": "Jr. Destino 456" },
    "vehicle": { "plate": "ABC-123", "circulation_permit": null },
    "drivers": [{
      "type": "Principal",
      "document_type_code": "1",
      "document_number": "45678912",
      "first_name": "Juan",
      "last_name": "Perez Quispe",
      "license": "Q12345678"
    }],
    "items": [{
      "code": "PROD-001",
      "description": "Producto de prueba",
      "quantity": "10.000000",
      "unit_code": "NIU",
      "sunat_product_code": null
    }]
  }'
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/dispatch-guides', [
        'external_reference' => 'GRE-000123',
        'series' => 'T001',
        'number' => '123',
        'issue_date' => '2026-09-23',
        'country_code' => 'PE',
        'sunat_establishment_code' => '0000',
        'transfer_reason_code' => '01',
        'transfer_description' => null,
        'gross_weight' => '150.500',
        'weight_unit_code' => 'KGM',
        'total_packages' => 3,
        'transfer_start_date' => '2026-09-23',
        'recipient' => [
            'document_type_code' => '6',
            'document_number' => '20100047218',
            'name' => 'BANCO DE CREDITO DEL PERU',
        ],
        'departure' => ['ubigeo' => '150101', 'address' => 'Av. Siempre Viva 123'],
        'arrival' => ['ubigeo' => '150102', 'address' => 'Jr. Destino 456'],
        'vehicle' => ['plate' => 'ABC-123', 'circulation_permit' => null],
        'drivers' => [[
            'type' => 'Principal',
            'document_type_code' => '1',
            'document_number' => '45678912',
            'first_name' => 'Juan',
            'last_name' => 'Perez Quispe',
            'license' => 'Q12345678',
        ]],
        'items' => [[
            'code' => 'PROD-001',
            'description' => 'Producto de prueba',
            'quantity' => '10.000000',
            'unit_code' => 'NIU',
            'sunat_product_code' => null,
        ]],
    ]);

$ticket = $respuesta->json('data.provider.ticket');
const respuesta = await fetch('https://api.apisperu.pro/api/v2/dispatch-guides', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'GRE-000123',
    series: 'T001',
    number: '123',
    issue_date: '2026-09-23',
    country_code: 'PE',
    sunat_establishment_code: '0000',
    transfer_reason_code: '01',
    transfer_description: null,
    gross_weight: '150.500',
    weight_unit_code: 'KGM',
    total_packages: 3,
    transfer_start_date: '2026-09-23',
    recipient: {
      document_type_code: '6',
      document_number: '20100047218',
      name: 'BANCO DE CREDITO DEL PERU',
    },
    departure: { ubigeo: '150101', address: 'Av. Siempre Viva 123' },
    arrival: { ubigeo: '150102', address: 'Jr. Destino 456' },
    vehicle: { plate: 'ABC-123', circulation_permit: null },
    drivers: [{
      type: 'Principal',
      document_type_code: '1',
      document_number: '45678912',
      first_name: 'Juan',
      last_name: 'Perez Quispe',
      license: 'Q12345678',
    }],
    items: [{
      code: 'PROD-001',
      description: 'Producto de prueba',
      quantity: '10.000000',
      unit_code: 'NIU',
      sunat_product_code: null,
    }],
  }),
});
Respuesta
{
  "success": true,
  "message": "Guía de remisión enviada a SUNAT correctamente.",
  "stored": true,
  "retryable": false,
  "replay": false,
  "data": {
    "dispatch_guide": {
      "uuid": "290a3111-0d71-4c48-89a1-0f93367bcb47",
      "external_reference": "GRE-000123",
      "company_code": "EMP-000123",
      "document_type_code": "09",
      "series": "T001",
      "number": 123,
      "status": "ticket_received",
      "accepted": true
    },
    "provider": {
      "name": "sunat",
      "environment": "production",
      "ticket": "1234567890",
      "response_code": null,
      "description": null
    }
  }
}

Emitir una guía de remisión (transporte público, con transportista)

Petición
curl -X POST https://api.apisperu.pro/api/v2/dispatch-guides \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_reference": "GRE-000124",
    "series": "T001",
    "number": "124",
    "issue_date": "2026-09-23",
    "country_code": "PE",
    "sunat_establishment_code": "0000",
    "transfer_reason_code": "01",
    "transfer_description": null,
    "gross_weight": "150.500",
    "weight_unit_code": "KGM",
    "total_packages": 3,
    "transfer_start_date": "2026-09-23",
    "recipient": {
      "document_type_code": "6",
      "document_number": "20100047218",
      "name": "BANCO DE CREDITO DEL PERU"
    },
    "departure": { "ubigeo": "150101", "address": "Av. Siempre Viva 123" },
    "arrival": { "ubigeo": "150102", "address": "Jr. Destino 456" },
    "transport_mode_code": "01",
    "carrier": {
      "document_type_code": "6",
      "document_number": "20600000001",
      "name": "Transportes El Rápido SAC",
      "mtc_registration_number": null
    },
    "items": [{
      "code": "PROD-001",
      "description": "Producto de prueba",
      "quantity": "10.000000",
      "unit_code": "NIU",
      "sunat_product_code": null
    }]
  }'
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post('https://api.apisperu.pro/api/v2/dispatch-guides', [
        'external_reference' => 'GRE-000124',
        'series' => 'T001',
        'number' => '124',
        'issue_date' => '2026-09-23',
        'country_code' => 'PE',
        'sunat_establishment_code' => '0000',
        'transfer_reason_code' => '01',
        'transfer_description' => null,
        'gross_weight' => '150.500',
        'weight_unit_code' => 'KGM',
        'total_packages' => 3,
        'transfer_start_date' => '2026-09-23',
        'recipient' => [
            'document_type_code' => '6',
            'document_number' => '20100047218',
            'name' => 'BANCO DE CREDITO DEL PERU',
        ],
        'departure' => ['ubigeo' => '150101', 'address' => 'Av. Siempre Viva 123'],
        'arrival' => ['ubigeo' => '150102', 'address' => 'Jr. Destino 456'],
        'transport_mode_code' => '01',
        'carrier' => [
            'document_type_code' => '6',
            'document_number' => '20600000001',
            'name' => 'Transportes El Rápido SAC',
            'mtc_registration_number' => null,
        ],
        'items' => [[
            'code' => 'PROD-001',
            'description' => 'Producto de prueba',
            'quantity' => '10.000000',
            'unit_code' => 'NIU',
            'sunat_product_code' => null,
        ]],
    ]);
const respuesta = await fetch('https://api.apisperu.pro/api/v2/dispatch-guides', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${tokenEmpresa}`,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    external_reference: 'GRE-000124',
    series: 'T001',
    number: '124',
    issue_date: '2026-09-23',
    country_code: 'PE',
    sunat_establishment_code: '0000',
    transfer_reason_code: '01',
    transfer_description: null,
    gross_weight: '150.500',
    weight_unit_code: 'KGM',
    total_packages: 3,
    transfer_start_date: '2026-09-23',
    recipient: {
      document_type_code: '6',
      document_number: '20100047218',
      name: 'BANCO DE CREDITO DEL PERU',
    },
    departure: { ubigeo: '150101', address: 'Av. Siempre Viva 123' },
    arrival: { ubigeo: '150102', address: 'Jr. Destino 456' },
    transport_mode_code: '01',
    carrier: {
      document_type_code: '6',
      document_number: '20600000001',
      name: 'Transportes El Rápido SAC',
      mtc_registration_number: null,
    },
    items: [{
      code: 'PROD-001',
      description: 'Producto de prueba',
      quantity: '10.000000',
      unit_code: 'NIU',
      sunat_product_code: null,
    }],
  }),
});
Respuesta
{
  "success": true,
  "message": "Guía de remisión enviada a SUNAT correctamente.",
  "stored": true,
  "retryable": false,
  "replay": false,
  "data": {
    "dispatch_guide": {
      "uuid": "3a1c9e22-1234-4c48-89a1-0f93367bcb47",
      "external_reference": "GRE-000124",
      "company_code": "EMP-000123",
      "document_type_code": "09",
      "series": "T001",
      "number": 124,
      "status": "ticket_received",
      "accepted": true
    },
    "provider": {
      "name": "sunat",
      "environment": "production",
      "ticket": "1234567891",
      "response_code": null,
      "description": null
    }
  }
}

vehicle/drivers y carrier son mutuamente excluyentes: el primer par es obligatorio (y el segundo se rechaza con 422) cuando transport_mode_code es 02 (privado); al revés cuando es 01 (público).

Campos que no se envían

company_code se rechaza con 422: la empresa emisora la fija la credencial. Tampoco hay bloque issuer (a diferencia de Factura/Boleta): no hay ningún dato declarado por ti que deba coincidir con tu empresa.

Listar tus guías de remisión

Petición
curl "https://api.apisperu.pro/api/v2/dispatch-guides?status=ticket_received&per_page=10" \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Accept: application/json"
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->get("https://api.apisperu.pro/api/v2/dispatch-guides", [
        'status' => 'ticket_received',
        'per_page' => 10,
    ]);
const respuesta = await fetch(`https://api.apisperu.pro/api/v2/dispatch-guides?status=ticket_received&per_page=10`, {
  headers: { Authorization: `Bearer ${tokenEmpresa}`, Accept: 'application/json' },
});
Respuesta
{
  "success": true,
  "message": "Guías de remisión obtenidas correctamente.",
  "search": null,
  "total": 1,
  "data": [{
    "dispatch_guide": {
      "uuid": "290a3111-0d71-4c48-89a1-0f93367bcb47",
      "company_code": "EMP-000123",
      "external_reference": "GRE-000123",
      "document_type_code": "09",
      "series": "T001",
      "number": 123,
      "status": "ticket_received",
      "final": false
    },
    "provider": { "name": "sunat", "environment": "production", "ticket": "1234567890", "response_code": null, "description": null },
    "artifacts": { "xml": true, "cdr": false },
    "received_at": "2026-09-24T10:00:00+00:00",
    "created_at": "2026-09-24T10:00:00+00:00",
    "updated_at": "2026-09-24T10:00:00+00:00"
  }]
}

search acepta un UUID exacto, un serie-número exacto (por ejemplo T001-123) o un external_reference exacto, en ese orden de prioridad. Filtros de fecha (issued_from/ issued_to, received_from/received_to) admiten un rango máximo de 366 días.

Ver el detalle de una guía

Petición
curl https://api.apisperu.pro/api/v2/dispatch-guides/{uuid} \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Accept: application/json"
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->get("https://api.apisperu.pro/api/v2/dispatch-guides/{$uuid}");
const respuesta = await fetch(`https://api.apisperu.pro/api/v2/dispatch-guides/${uuid}`, {
  headers: { Authorization: `Bearer ${tokenEmpresa}`, Accept: 'application/json' },
});
Respuesta
{
  "success": true,
  "message": "Guía de remisión obtenida correctamente.",
  "data": {
    "dispatch_guide": {
      "uuid": "290a3111-0d71-4c48-89a1-0f93367bcb47",
      "company_code": "EMP-000123",
      "external_reference": "GRE-000123",
      "document_type_code": "09",
      "series": "T001",
      "number": 123,
      "issue_date": "2026-09-24",
      "status": "accepted",
      "final": true
    },
    "provider": { "name": "sunat", "environment": "production", "ticket": "1234567890" },
    "response": { "code": null, "description": null, "status_check_count": 1, "last_status_checked_at": "2026-09-24T10:05:00+00:00" },
    "artifacts": { "xml": true, "cdr": true },
    "received_at": "2026-09-24T10:00:00+00:00",
    "created_at": "2026-09-24T10:00:00+00:00",
    "updated_at": "2026-09-24T10:05:00+00:00"
  }
}

Consultar el resultado final del ticket

Petición
curl -X POST https://api.apisperu.pro/api/v2/dispatch-guides/{uuid}/status-check \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -H "Accept: application/json"
$respuesta = Http::withToken($tokenEmpresa)
    ->acceptJson()
    ->post("https://api.apisperu.pro/api/v2/dispatch-guides/{$uuid}/status-check");

$estado = $respuesta->json('data.dispatch_guide.status'); // ticket_received | accepted | ticket_rejected
const respuesta = await fetch(`https://api.apisperu.pro/api/v2/dispatch-guides/${uuid}/status-check`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${tokenEmpresa}`, Accept: 'application/json' },
});
Respuesta
{
  "success": true,
  "message": "SUNAT aceptó la guía de remisión.",
  "stored": true,
  "retryable": false,
  "replay": false,
  "processing": false,
  "data": {
    "dispatch_guide": { "uuid": "290a3111-...", "status": "accepted", "final": true },
    "provider": { "name": "sunat", "response_code": null, "description": null, "checked_at": "2026-09-24T10:05:00+00:00" },
    "cdr": { "available": true }
  }
}

Sin body. Solo se puede consultar una guía en ticket_received. Mientras SUNAT sigue procesando (cod_respuesta = 98), responde 202 sin cambiar el estado — vuelve a consultar más tarde, no hay webhooks. Reconsultar un resultado ya final (accepted/ ticket_rejected) es un replay: no vuelve a llamar a SUNAT. cdr.available indica si el CDR ya tiene bytes persistidos y GET .../cdr (ver abajo) responderá 200 — no solo si SUNAT señaló haberlo generado, que puede pasar sin que lo adjunte en esa respuesta puntual.

Descargar el XML transmitido

Petición
curl https://api.apisperu.pro/api/v2/dispatch-guides/{uuid}/xml \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -o guia.xml
$respuesta = Http::withToken($tokenEmpresa)
    ->get("https://api.apisperu.pro/api/v2/dispatch-guides/{$uuid}/xml");

file_put_contents('guia.xml', $respuesta->body());
const respuesta = await fetch(`https://api.apisperu.pro/api/v2/dispatch-guides/${uuid}/xml`, {
  headers: { Authorization: `Bearer ${tokenEmpresa}` },
});
const xml = await respuesta.text();
Respuesta
Content-Type: application/xml
Content-Disposition: attachment; filename="T001-124.xml"

<DespatchAdvice ...>...</DespatchAdvice>

Descargar el CDR

Petición
curl https://api.apisperu.pro/api/v2/dispatch-guides/{uuid}/cdr \
  -H "Authorization: Bearer $TOKEN_EMPRESA" \
  -o cdr.zip
$respuesta = Http::withToken($tokenEmpresa)
    ->get("https://api.apisperu.pro/api/v2/dispatch-guides/{$uuid}/cdr");

file_put_contents('cdr.zip', $respuesta->body());
const respuesta = await fetch(`https://api.apisperu.pro/api/v2/dispatch-guides/${uuid}/cdr`, {
  headers: { Authorization: `Bearer ${tokenEmpresa}` },
});
const zipBytes = await respuesta.arrayBuffer();
Respuesta
Content-Type: application/zip
Content-Disposition: attachment; filename="R-T001-124.zip"

(bytes del ZIP)

El XML siempre está disponible tras la emisión (en ambos desenlaces: ticket_received y rejected). El CDR es el único artifact opcional: antes de la primera consulta de estado, o si SUNAT nunca lo adjuntó, responde 404 con un mensaje distinto al de "guía inexistente".

Errores

403 Estás con la credencial de la Cuenta, no la de la Empresa.
409 Credenciales GRE o SOL sin configurar, o conflicto de idempotencia (external_reference o serie+número ya usados con datos distintos).
422 Validación fallida — por ejemplo, serie sin el prefijo T, o sin conductores.
502 SUNAT respondió pero no de forma confiable (autenticación OAuth2 rechazada).
503 SUNAT no responde, o el envío a producción está deshabilitado en este entorno.
404 UUID inexistente, o guía de otra empresa. En GET .../cdr también aparece cuando el CDR todavía no está disponible.

Públicas

Sin credencial ni token. Son las tablas de códigos que necesitas para armar un comprobante, y sirven además de sonda: si responden, el servicio está en pie.

Endpoint Para qué
GET /api/ubigeos?search= Departamentos, provincias y distritos. Mínimo 3 caracteres. Con &type=select2 responde con la forma que Select2 espera.
GET /api/sunat/catalogos Los 36 catálogos del Anexo N.° 8.
GET /api/sunat/catalogos/{codigo}/items Contenido de un catálogo.
GET /api/sunat/billing-errors Catálogo de errores, para traducir un rechazo.

Los cuatro catálogos que más vas a usar

  • 01 — tipos de documento de identidad. Lo que va en customer.document_type_code: 1 = DNI, 6 = RUC.
  • 03 — unidades de medida, para items.*.unit_code: NIU para bienes, ZZ para servicios.
  • 07 — afectación del IGV, para items.*.igv_type_code. Es el campo donde más se descuadran los totales.
  • 51 — tipos de operación, para operation_type_code: 0101 cubre la venta interna.

Buscar un ubigeo

Petición
curl "https://api.apisperu.pro/api/ubigeos?search=miraflores" \
  -H "Accept: application/json"
$respuesta = Http::acceptJson()
    ->get('https://api.apisperu.pro/api/ubigeos', ['search' => 'miraflores']);
const url = new URL('https://api.apisperu.pro/api/ubigeos');
url.searchParams.set('search', 'miraflores');

const respuesta = await fetch(url, {
  headers: { Accept: 'application/json' },
});
Respuesta
{
  "success": true,
  "total": 3,
  "data": [
    {
      "code": "150122",
      "department_name": "LIMA",
      "province_name": "LIMA",
      "district_name": "MIRAFLORES",
      "full_name": "LIMA - LIMA - MIRAFLORES"
    }
  ]
}

¿Lo vas a enchufar a un Select2? Añade type=select2 y la respuesta sale ya con la forma que Select2 espera: el sobre pasa de data a results, y cada fila trae id (el código de ubigeo) y text (el nombre completo). Los demás campos siguen ahí, así que en select2:select tienes el departamento, la provincia y el distrito por separado sin volver a preguntar.

Buscar un ubigeo para Select2

Petición
curl "https://api.apisperu.pro/api/ubigeos?search=miraflores&type=select2" \
  -H "Accept: application/json"
// Normalmente no lo llamas desde PHP: el navegador pega directo contra
// el endpoint, que es publico y no necesita token. Si aun asi lo quieres
// por detras -para no exponer la URL, o para cachear-, devuelve el
// cuerpo tal cual y Select2 lo entiende sin tocar processResults.
$respuesta = Http::acceptJson()->get('https://api.apisperu.pro/api/ubigeos', [
    'search' => request('term'),
    'type' => 'select2',
    'limit' => 20,
]);

return response()->json($respuesta->json());
$('#ubigeo').select2({
  width: '100%',
  placeholder: 'Busque por distrito, provincia o codigo',
  allowClear: true,

  // El endpoint exige 3 caracteres; pedirlos aqui evita las dos
  // primeras peticiones, que siempre volverian vacias.
  minimumInputLength: 3,

  ajax: {
    url: 'https://api.apisperu.pro/api/ubigeos',
    dataType: 'json',
    delay: 250,
    data: (params) => ({
      search: params.term,
      type: 'select2',
      limit: 20,
    }),
    // La respuesta ya trae "results" con id y text, asi que no hay
    // nada que transformar.
    processResults: (respuesta) => ({ results: respuesta.results || [] }),
    cache: true,
  },
});

// Al elegir, tienes el ubigeo desglosado sin otra llamada.
$('#ubigeo').on('select2:select', (e) => {
  const u = e.params.data;
  console.log(u.id, u.department_name, u.province_name, u.district_name);
});
Respuesta
{
  "success": true,
  "total": 2,
  "results": [
    {
      "id": "040110",
      "text": "AREQUIPA - AREQUIPA - MIRAFLORES",
      "code": "040110",
      "department_code": "04",
      "province_code": "0401",
      "district_code": "040110",
      "department_name": "AREQUIPA",
      "province_name": "AREQUIPA",
      "district_name": "MIRAFLORES",
      "full_name": "AREQUIPA - AREQUIPA - MIRAFLORES"
    },
    {
      "id": "150122",
      "text": "LIMA - LIMA - MIRAFLORES",
      "code": "150122",
      "department_code": "15",
      "province_code": "1501",
      "district_code": "150122",
      "department_name": "LIMA",
      "province_name": "LIMA",
      "district_name": "MIRAFLORES",
      "full_name": "LIMA - LIMA - MIRAFLORES"
    }
  ]
}

Si el desplegable va dentro de una ventana modal, pásale dropdownParent apuntando al modal. Select2 cuelga su desplegable del body, y los modales que vuelven inerte todo lo de fuera dejan la caja de búsqueda visible pero sin poder escribir en ella: parece que la búsqueda está rota cuando lo que está roto es el foco.

Leer un catálogo

Petición
# Afectacion del IGV
curl https://api.apisperu.pro/api/sunat/catalogos/07/items \
  -H "Accept: application/json"
$igv = Http::acceptJson()
    ->get('https://api.apisperu.pro/api/sunat/catalogos/07/items')
    ->json('data');
const respuesta = await fetch(
  'https://api.apisperu.pro/api/sunat/catalogos/07/items',
  { headers: { Accept: 'application/json' } }
);

Límite: 300 peticiones por minuto, contadas por IP, porque aquí no hay credencial que identificar. Al superarlo, 429. No consumen cuota de facturación.

Colecciones de Postman

Las mismas llamadas de esta documentación, listas para ejecutar. Descarga las tres colecciones y el entorno, impórtalos en Postman y completa tus credenciales.

Cómo empezar

  1. Importa los cuatro archivos en Postman.
  2. Selecciona el entorno apis.admin - servidor.
  3. Completa client_id y client_secret con tu credencial de Cuenta, y company_client_id y company_client_secret con la de la Empresa que vaya a emitir.
  4. Ajusta issuer_ruc al RUC real de esa empresa: si no coincide con el de la credencial, la emisión responde 422.
  5. Ejecuta primero token en la colección que vayas a usar. El token queda guardado en el entorno y el resto de las llamadas lo toman solo.

Qué trae la colección de Facturación

  • Factura al contado, al crédito con cuotas, y mixta con los tres regímenes de IGV.
  • Boleta directa (síncrona, igual que Factura) y resumen diario de boletas con la consulta de su ticket.
  • Nota de crédito sobre una Factura y nota de débito sobre una Boleta.
  • Descarga del XML firmado y del CDR de un comprobante ya emitido.
  • Credenciales SOL y certificado digital por API (colección Consultas).
  • El reintento idempotente, para el caso de que se corte la red.
  • Los errores esperados —402, 403 y 422— como llamadas ejecutables, porque no son fallos sino respuestas que tu integración tiene que manejar.

Las fechas y la numeración se calculan solas en un script previo, así que los ejemplos no caducan ni chocan por correlativo repetido.