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.
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
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;
{
"success": true,
"data": {
"access_token": "12|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"token_type": "Bearer",
"expires_at": "2026-08-16T21:30:00.000000Z"
}
}
Usar el token
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',
},
});
{
"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/contextdice con qué credencial estás. Siapi_client.scopeesaccountno podrás emitir, y si escompanyno podrás consultar RUC/DNI ni administrar empresas. Es la primera llamada que conviene hacer cuando algo responde 403. -
companiessiempre es "tu" empresa, nunca todas. Conscope: companydevuelve un único elemento: la empresa que esa credencial emite (nunca el resto de empresas de la cuenta, aunque existan). Conscope: accountsí lista todas las tuyas, porque esa credencial las administra. Úsalo para que tu sistema averigüe sucompany_codesolo, sin que nadie lo teclee a mano. -
POST /api/v1/auth/revokeinvalida 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
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.
}
{
"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
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.
}
{
"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
# 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' },
});
{
"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
AgentconkeepAlive: 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
# --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
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',
}),
});
{
"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
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,
});
{
"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, conreference_documentyreason_codeadicionales.
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
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_valueva sin IGV.line_value= cantidad × valor unitario. -
igv_type_codedecide 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
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
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
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
# 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)
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)
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,
}],
}),
});
{
"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)
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,
}],
}),
});
{
"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
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' },
});
{
"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
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' },
});
{
"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
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' },
});
{
"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
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();
Content-Type: application/xml
Content-Disposition: attachment; filename="T001-124.xml"
<DespatchAdvice ...>...</DespatchAdvice>
Descargar el CDR
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();
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
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' },
});
{
"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
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);
});
{
"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
# 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
- Importa los cuatro archivos en Postman.
- Selecciona el entorno apis.admin - servidor.
-
Completa
client_idyclient_secretcon tu credencial de Cuenta, ycompany_client_idycompany_client_secretcon la de la Empresa que vaya a emitir. -
Ajusta
issuer_rucal RUC real de esa empresa: si no coincide con el de la credencial, la emisión responde 422. - 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.