Referencia
Documentación del API
Rastreo de envíos y catálogo de agencias de couriers peruanos detrás de un solo contrato REST. Todo lo de esta página sale de respuestas reales del servicio: los ejemplos están recortados para que entren en pantalla, nunca inventados.
Hoy 3 de 5 couriers rastrean (Expreso Marvisur, Urbano Express y Cruz del Sur Cargo) y 5 publican catálogo de agencias. El estado real, carrier por carrier, lo publica el propio API en GET /v1/carriers.
Autenticación
Toda llamada lleva tu key en la cabecera X-API-Key. Cada key va con un plan —Free, Básico, Pro o a medida—. La del plan Free se emite sola desde la web; las de pago, a pedido.
curl -H "X-API-Key: $API_KEY" \
"https://api.tracking-peru.com/v1/carriers"La key identifica y contabiliza: no la pongas en un cliente que se descargue al navegador o a una app, porque ahí queda publicada y la gasta cualquiera. Va del lado de tu servidor.
Cupos por plan
Tu key trae el cupo del plan con el que se emitió. El límite por minuto es un guardarraíl contra ráfagas, no una cuota mensual: ningún plan tiene tope de consultas al mes salvo en el plan Free, que tiene tope. El límite por minuto es un guardarraíl contra ráfagas, no una cuota. La key se entrega en la misma respuesta del alta.
| Plan | Requests / minuto | Webhooks activos | Consultas |
|---|---|---|---|
| Free | 30 | 3 | hasta 1.000 al mes |
| Básico | 60 | 50 | sin tope |
| Pro | 120 | 200 | sin tope |
| A medida | negociado | 200+ | sin tope, con SLA |
Tu primer llamado
GET /v1/carriers es el endpoint de descubrimiento y conviene que sea la primera llamada que escribas: dice qué couriers hay, qué puede cada uno y cuánta evidencia respalda cada integración. Tu código puede leer ese estado en vez de confiar en esta página — que es exactamente lo que recomendamos, porque el API se entera antes.
Convenciones
Cinco reglas que valen para todas las respuestas. Ninguna es cosmética: cambian cómo escribes el cliente.
- Todo en inglés, snake_case
- Nombres de campo y valores. Alguien que no habla español tiene que poder parsear el contrato, y tiene que ser el mismo para los cinco couriers.
- La zona horaria va explícita
- Cada fecha de un evento lleva su desplazamiento (
-05:00). Ningún courier lo manda: lo ponemos nosotros, porque una fecha sin zona es una fecha que cada cliente interpreta distinto. - El literal del courier siempre viaja
- Junto al estado canónico va
status_rawcon la palabra exacta que devolvió el courier. Normalizar no debería significar perder el dato original: si nuestro mapeo te parece mal, tienes con qué discutirlo. - unknown es un valor posible, y tu código tiene que aceptarlo
- El vocabulario de estados de un courier no está mapeado al 100 %, y uno puede devolver mañana un código que nunca vimos. En ese caso el estado llega como
unknowncon el literal original enstatus_raw— no aproximado al canónico más parecido. Unswitchsin rama por defecto se va a romper ahí, y es el único lugar donde lo va a hacer. - Los documentos de identidad no salen nunca
- Varios couriers exponen el DNI de quien recibió en endpoints públicos. Ningún carrier lo reenvía —
provides.party_documentsesfalseen los cinco— y donde el crudo lo traía se limpia antes de servirse, así que pedirlo coninclude=rawtampoco lo trae. Los nombres de remitente y destinatario sí llegan en los couriers que ya los publicaban. Consultaprovides.partiesenGET /v1/carriersantes de asumir, y trátalos como datos personales.
Endpoints
Los 9 que expone el servicio, con una respuesta real de cada uno.
GET/v1/carriers
Qué carriers hay, qué puede cada uno y cuánta evidencia lo respalda.
Es el endpoint de descubrimiento: tu código puede leer el estado en vez de confiar en esta página.
{
"carriers": [
{
"id": "marvisur",
"name": "Expreso Marvisur",
"enabled": true,
"verified": {
"level": "live",
"last_probe": "2026-08-03T22:10:23Z",
"notes": "hit y miss reproducidos byte a byte contra el upstream"
},
"id_format": "V###-####### (serie V + 3 dígitos, distinto de V000)",
"examples": ["V999-9999999"],
"detection": "strong",
"requires_code": false,
"requires_end_user_auth": false,
"anonymous_timeline": true,
"provides": {
"parties": true, "event_location": true,
"packages": true, "payment_status": true
}
}
]
}GET/v1/tracking
Rastreo unificado por número de guía.
El literal del courier viaja siempre en status_raw: normalizar no debería significar perder el dato original.
{
"carrier": "marvisur",
"tracking_number": "V001-0000001",
"status": "delivered",
"status_raw": "ENTREGADO",
"delivered": true,
"terminal": true,
"events": [
{
"seq": 0,
"status": "registered",
"status_raw": "RECEPCION",
"description": "SU ENVÍO FUÉ RECEPCIONADO EN NUESTRA SEDE",
"occurred_at": "2023-07-01T07:54:06-05:00",
"time_precision": "second",
"location": { "name": "GARCI CARBAJAL", "structured": false }
}
],
"detail": "full",
"fetched_at": "2026-08-03T22:10:31Z"
}GET/v1/tracking/{carrier}/{number}
La misma consulta con el carrier en la ruta.
Idéntica respuesta. Existe porque un carrier explícito en la ruta se cachea y se loguea mejor que un query param.
{
"carrier": "marvisur",
"tracking_number": "V001-0000001",
"status": "delivered",
"status_raw": "ENTREGADO",
"delivered": true,
"terminal": true,
"events": [
{
"seq": 0,
"status": "registered",
"status_raw": "RECEPCION",
"description": "SU ENVÍO FUÉ RECEPCIONADO EN NUESTRA SEDE",
"occurred_at": "2023-07-01T07:54:06-05:00",
"time_precision": "second",
"location": { "name": "GARCI CARBAJAL", "structured": false }
}
],
"detail": "full",
"fetched_at": "2026-08-03T22:10:31Z"
}POST/v1/tracking/batch
Hasta 50 envíos por request, con error por ítem.
Responde 200 aunque haya ítems fallidos: el error va por ítem. Aquí no hay detección automática.
{
"items": [
{ "custom_id": "a", "carrier": "marvisur", "number": "V001-0000001" },
{ "custom_id": "b", "carrier": "marvisur", "number": "V999-9999999" }
]
}{
"results": [
{ "custom_id": "a", "carrier": "marvisur",
"number": "V001-0000001",
"ok": true, "shipment": { "…": "el Shipment completo" } },
{ "custom_id": "b", "carrier": "marvisur",
"number": "V999-9999999",
"ok": false,
"error": {
"code": "not_found",
"message": "no se encontró el envío"
} }
],
"summary": { "total": 2, "ok": 1, "failed": 1 }
}GET/v1/agencies
Catálogo de agencias con filtros y paginación.
ubigeo_source es lo que hace auditable el join: "carrier" lo dio el courier, "matched" lo resolvimos por texto y puede fallar.
{
"agencies": [
{
"carrier": "olva",
"id": "948",
"name": "AGENTE OLVA MIRAFLORES - BENAVIDES C18",
"location": {
"address": "AV ALFREDO BENAVIDES NRO 1851",
"department": "LIMA", "province": "LIMA",
"district": "MIRAFLORES",
"ubigeo": "150122", "structured": true
},
"ubigeo_level": "district",
"ubigeo_source": "carrier",
"geo": { "lat": -12.1265813, "lng": -77.0132440 },
"hours": {
"weekly": [
{ "weekday": 1,
"spans": [ { "open": "08:00", "close": "20:00" } ] }
],
"structured": true,
"time_zone": "America/Lima"
},
"kind": "agent",
"services": { "dropoff": true, "pickup": true },
"synced_at": "2026-08-03T22:10:19Z"
}
],
"pagination": {
"page": 1, "per_page": 20, "total": 417, "total_pages": 21
},
"meta": { "sources": [ "…frescura por carrier" ] }
}GET/v1/coverage
Qué carriers tienen agencia en un distrito. Distingue "no cubre" de "no sabemos".
Solo un courier que publica distrito puede recibir un "no cubre". El que da su ubicación como texto libre cae en "unknown", con el motivo escrito.
{
"ubigeo": "150101",
"level": "district",
"carriers": [
{ "carrier": "olva", "count": 5, "kinds": { "agent": 5 } }
],
"not_covered": [],
"unknown": [
{
"carrier": "marvisur",
"reason": "el catálogo de este carrier no resuelve a nivel
district; la consulta pide ese nivel"
}
]
}GET/v1/agencies/{carrier}/{id}
Detalle de una agencia.
Devuelve la Agency pelada, sin envoltorio. La PK natural es el par (carrier, id).
{
"carrier": "olva",
"id": "579",
"name": "TIENDA CHACHAPOYAS",
"location": {
"address": "JR. AMAZONAS 1120",
"department": "AMAZONAS", "province": "CHACHAPOYAS",
"district": "CHACHAPOYAS",
"ubigeo": "010101", "structured": true
},
"ubigeo_level": "district",
"ubigeo_source": "carrier",
"kind": "office",
"services": { "dropoff": true, "pickup": true },
"synced_at": "2026-08-03T22:10:19Z"
}POST/v1/webhooks
Registra tu endpoint. Devuelve el signing secret una sola vez.
Se guarda deshabilitado y recibe un ping firmado: solo se habilita si devuelves el challenge como cuerpo.
{ "url": "https://tuservicio.com/hooks/tracking" }{
"webhook": {
"id": 11,
"url": "https://tuservicio.com/hooks/tracking",
"enabled": true,
"created_at": "2026-08-04T10:00:00Z"
},
"signing_secret": "9f2b…64 hex…c1",
"verified": true,
"usage": { "active_subscriptions": 0, "max_subscriptions": 50 }
}POST/v1/tracking/subscriptions
Suscribe un envío: te avisamos cuando cambie de estado.
carrier es OBLIGATORIO: no hay detección automática. Un rastreo mal detectado se ve en la respuesta; una suscripción mal detectada no la mira nadie durante semanas.
{
"carrier": "marvisur",
"number": "V001-0000001",
"code": "opcional-2do-factor"
}{
"subscription": {
"id": 4821,
"carrier": "marvisur",
"tracking_number": "V001-0000001",
"status": "active",
"next_poll_at": "2026-08-04T10:16:00Z",
"created_at": "2026-08-04T10:00:00Z",
"expires_at": "2026-08-25T10:00:00Z"
},
"outcome": "created",
"usage": { "active_subscriptions": 8, "max_subscriptions": 50 }
}Estados canónicos
Un vocabulario, no cinco. Este es el camino que recorre un envío que llega bien, en orden. Los cuatro estados que se salen de él están justo abajo.
- 1
registeredEl courier lo recibió y lo dio de alta. - 2
at_originEn la agencia de origen, sin salir todavía. - 3
in_transitViajando entre sedes. - 4
at_destinationLlegó a la ciudad de destino. - 5
out_for_deliveryoavailable_for_pickupSale a repartir, o queda esperando en agencia. - 6
deliveredEntregado. No hay estado después de este.
En Perú el retiro en agencia es la norma, no la excepción. Por eso hay un estado propio para «esperando en agencia»: decir «en reparto» cuando nadie está repartiendo es falso.
Los que se salen del camino
- delayed
- Sigue en curso, solo que tarde.
- returning
- Vuelve al origen. No es terminal: todavía puede entregarse.
- returned
- Volvió y quedó ahí.
- exception
- Algo que el courier reporta y no encaja en ningún otro estado.
Carriers y su nivel de verificación
Ninguno de estos APIs es oficial: cada adaptador se construyó leyendo cómo funciona el courier, así que la calidad de la evidencia varía. Cada carrier publica la suya en verified.level, para que distingas «implementado» de «probado contra el sistema real».
- live
Verificado en vivo
Respuestas reales capturadas contra el upstream, con hit y miss.
- code_derived
Derivado de código
Extraído de una integración que corre en producción, sin captura de tráfico.
- contract_only
Solo contrato
El contrato está verificado, pero nunca obtuvimos una respuesta con datos.
- none
Sin adaptador
No hay integración escrita.
| Carrier | level | Rastreo | Agencias |
|---|---|---|---|
marvisurExpreso Marvisur | live | operativo | 190 |
olvaOlva Courier | contract_only | pendiente | 417 |
urbanoUrbano Express | live | operativo | 211 |
cruzdelsurCruz del Sur Cargo | live | operativo | 163 |
shalomShalom | code_derived | pendiente | 544 |
Webhooks
Suscribes un envío y te avisamos cuando cambia de estado, en vez de que preguntes. Hoy se puede suscribir un envío de Expreso Marvisur, Urbano Express y Cruz del Sur Cargo; el alta de cualquier otro devuelve un error explícito en el POST, con el motivo — no te dejamos crear una suscripción que nunca iba a entregar nada.
Tipos de evento
- tracking.updated
- El envío cambió de estado y sigue en curso.
- tracking.delivered
- Se entregó. Es el único terminal con tipo propio: una devolución también es terminal, pero no es una entrega.
- tracking.expired
- Venció el TTL de la suscripción sin que el envío terminara. Se avisa en vez de callarse: «no pasó nada» y «dejamos de mirar» no son lo mismo.
- webhook.ping
- Verificación de propiedad al registrar el endpoint.
Verificar la firma
Cada entrega va firmada con el secreto que te devolvemos una sola vez al registrar el endpoint. Compara usando una función de tiempo constante y sobre el cuerpo crudo: si lo parseas y lo vuelves a serializar antes de firmar, la firma no va a coincidir.
X-Webhook-Signature: t=<unix>,v1=<hex>
v1 = HMAC-SHA256("<t>" + "." + <cuerpo crudo>, signing_secret)Una entrega
POST /hooks/tracking HTTP/1.1
User-Agent: tracking-peru-webhooks/1
X-Webhook-Event: tracking.updated
X-Webhook-Event-Id: evt_4821_tracking.updated_v1-1010100000000
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1785578400,v1=6f1a…
{
"id": "evt_4821_tracking.updated_v1-1010100000000",
"event": "tracking.updated",
"occurred_at": "2026-08-04T14:02:11Z",
"data": {
"subscription_id": 4821,
"carrier": "marvisur",
"tracking_number": "V001-0000001",
"status": "at_destination",
"previous_status": "in_transit",
"delivered": false,
"terminal": false,
"change": { "appeared": ["at_destination"] },
"shipment": { "…": "el Shipment completo, con su timeline" }
}
}Antes de escribir el receptor
Estas no son features: son decisiones que cambian tu código.
- Se avisa por cambio de estado, no por escaneo
- Tres ciudades y tres eventos in_transit son un solo webhook. El payload trae el timeline completo igual, así que no pierdes nada y no recibes un feed.
- Verify-before-enable
- Tu endpoint se guarda deshabilitado y recibe un webhook.ping firmado. Solo se habilita si respondes 2xx devolviendo el challenge como cuerpo — a propósito: eso prueba que del otro lado hay un receptor y no un reflector.
- El código de rastreo y la PII no viajan
- El segundo factor es credencial y ya lo tienes. Remitente, destinatario y contenido del bulto son datos de terceros y se quedan afuera del POST a tu servidor.
- Reintentos con id de evento estable
- Hasta 3 intentos por entrega (10 s de timeout, backoff de 500 ms). Si fallan, la marca de agua no avanza y el próximo poll reintenta el mismo cambio con el mismo id. Deduplica por X-Webhook-Event-Id.
- Cadencia atada al TTL del cache
- El poller nunca consulta más rápido que el cache: pedir 1 minuto cuando el TTL es de 10 daría diez lecturas idénticas. Se corrige solo y queda en el log.
- Solo HTTPS, y se revalida en cada entrega
- La URL se valida contra SSRF al registrar y la IP real se vuelve a chequear al conectar, contra DNS rebinding. No se siguen redirects.
Errores
Todos tienen la misma forma: un code estable, un message en castellano y un request_id para cuando nos escribas. Ramifica por el code, nunca por el texto: el mensaje se puede reescribir, el código no.
{
"error": {
"code": "carrier_ambiguous",
"message": "varios carriers reconocen ese identificador (olva, urbano); indica ?carrier=",
"request_id": "01KZ4TVKTZD72KE2RSZWT407YH",
"candidates": ["olva", "urbano"]
}
}| HTTP | code | Cuándo |
|---|---|---|
| 400 | bad_request | Parámetros inválidos. |
| 400 | invalid_tracking_number | El formato del número no corresponde a ese carrier. |
| 400 | carrier_unknown | Ningún carrier reconoce el identificador. |
| 400 | carrier_ambiguous | Varios lo reconocen. Trae `candidates` para que elijas. |
| 401 | unauthorized | Falta la X-API-Key o no es válida. |
| 401 | key_expired | La key venció. Se renueva y la misma key vuelve. |
| 401 | carrier_auth_failed | El courier rechazó las credenciales del usuario final. |
| 403 | forbidden | La key no puede hacer esa operación. |
| 404 | not_found | No existe el envío, la agencia o el carrier. |
| 409 | conflict | El recurso ya existe o está en un estado incompatible. |
| 409 | webhook_not_configured | Suscribiste un envío sin tener el webhook habilitado. |
| 413 | payload_too_large | El cuerpo excede el máximo. |
| 415 | unsupported_media_type | Falta Content-Type: application/json. |
| 422 | carrier_rejected | El sistema del courier rechazó el pedido. |
| 429 | rate_limited | Pasaste tu límite por minuto. |
| 429 | quota_exceeded | Pasaste el tope mensual de consultas. Solo lo tiene el plan Free. |
| 429 | carrier_rate_limited | El cupo es del courier, no tuyo. |
| 500 | internal | Falla nuestra. |
| 501 | carrier_not_supported | Ese carrier todavía no soporta la operación. Viene con el motivo. |
| 502 | carrier_unavailable | El sistema del courier falló. |
| 503 | carrier_disabled | El carrier está apagado en esta instancia. |
| 503 | carrier_cooldown | Cortamos el tráfico a ese courier tras fallas seguidas. Trae Retry-After. |
| 504 | carrier_timeout | El courier no respondió a tiempo. |
- Un 501 carrier_not_supported no es un incidente: es el estado documentado de ese adaptador, y viene con el motivo escrito.
- En 5 de los 7 couriers, el «no encontrado» llega con un 200 desde su lado. Lo normalizamos a 404 not_found para que no tengas que adivinar cuál de ellos miente.
- En un lote (POST /v1/tracking/batch) la respuesta es 200 aunque haya ítems fallidos: el error va por ítem, no en el HTTP.
Lo que falla no gasta cuota
El tope mensual cuenta solo respuestas 2xx. Un courier que todavía no rastrea, un envío que no existe o una falla nuestra no te descuentan nada. Vale igual para todos los carriers. Las suscripciones a webhooks tampoco entran: se miden aparte, por cuántas tienes activas a la vez.
Lo que esta página todavía no dice
Preferimos listar los huecos a que los encuentres con un request fallido.
Cuánto cupo te queda
Ya sabes qué pasa al pasarte del tope (429 quota_exceeded), pero no hay todavía una cabecera que te diga cuánto llevas consumido antes de llegar. Por ahora, lleva la cuenta por tu lado.
Los parámetros completos por endpoint
Se ven los que aparecen en los ejemplos —carrier, number, ubigeo, page, per_page—. La tabla exhaustiva de query params, con tipos y obligatoriedad, falta.
El vocabulario completo de cada courier
Los once estados canónicos están cerrados, pero el mapeo desde los códigos de cada courier no cubre el 100 % de los suyos: de Cruz del Sur y Urbano vimos pocos envíos. Lo que no reconocemos llega como unknown con su literal, y el API lleva la cuenta de esos casos para mapearlos por frecuencia.
Los errores sí están documentados — salieron de esta lista cuando dejaron de faltar. Y mientras tanto, GET /v1/carriers es la fuente que no envejece: publica el estado real de cada integración y tu código puede leerlo en vez de confiar en este texto.