Saltar al contenido

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.

GET /v1/carriers
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.

PlanRequests / minutoWebhooks activosConsultas
Free303hasta 1.000 al mes
Básico6050sin tope
Pro120200sin tope
A medidanegociado200+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_raw con 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 unknown con el literal original en status_raw — no aproximado al canónico más parecido. Un switch sin 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_documents es false en los cinco— y donde el crudo lo traía se limpia antes de servirse, así que pedirlo con include=raw tampoco lo trae. Los nombres de remitente y destinatario sí llegan en los couriers que ya los publicaban. Consulta provides.parties en GET /v1/carriers antes 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.

200 OK · application/json
{
  "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.

200 OK · application/json
{
  "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.

200 OK · /v1/tracking/marvisur/V001-0000001
{
  "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.

request
{
  "items": [
    { "custom_id": "a", "carrier": "marvisur", "number": "V001-0000001" },
    { "custom_id": "b", "carrier": "marvisur", "number": "V999-9999999" }
  ]
}
200 OK · application/json
{
  "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.

200 OK · ?carrier=olva&ubigeo=150122
{
  "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.

200 OK · ?ubigeo=150101
{
  "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).

200 OK · /v1/agencies/olva/579
{
  "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.

request
{ "url": "https://tuservicio.com/hooks/tracking" }
201 Created · application/json
{
  "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.

request
{
  "carrier": "marvisur",
  "number": "V001-0000001",
  "code": "opcional-2do-factor"
}
201 Created · application/json
{
  "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. 1registeredEl courier lo recibió y lo dio de alta.
  2. 2at_originEn la agencia de origen, sin salir todavía.
  3. 3in_transitViajando entre sedes.
  4. 4at_destinationLlegó a la ciudad de destino.
  5. 5out_for_deliveryoavailable_for_pickupSale a repartir, o queda esperando en agencia.
  6. 6deliveredEntregado. 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.

CarrierlevelRastreoAgencias
marvisurExpreso Marvisurliveoperativo190
olvaOlva Couriercontract_onlypendiente417
urbanoUrbano Expressliveoperativo211
cruzdelsurCruz del Sur Cargoliveoperativo163
shalomShalomcode_derivedpendiente544

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
X-Webhook-Signature: t=<unix>,v1=<hex>

v1 = HMAC-SHA256("<t>" + "." + <cuerpo crudo>, signing_secret)

Una entrega

POST a tu endpoint
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.

la forma de un error
{
  "error": {
    "code": "carrier_ambiguous",
    "message": "varios carriers reconocen ese identificador (olva, urbano); indica ?carrier=",
    "request_id": "01KZ4TVKTZD72KE2RSZWT407YH",
    "candidates": ["olva", "urbano"]
  }
}
HTTPcodeCuándo
400bad_requestParámetros inválidos.
400invalid_tracking_numberEl formato del número no corresponde a ese carrier.
400carrier_unknownNingún carrier reconoce el identificador.
400carrier_ambiguousVarios lo reconocen. Trae `candidates` para que elijas.
401unauthorizedFalta la X-API-Key o no es válida.
401key_expiredLa key venció. Se renueva y la misma key vuelve.
401carrier_auth_failedEl courier rechazó las credenciales del usuario final.
403forbiddenLa key no puede hacer esa operación.
404not_foundNo existe el envío, la agencia o el carrier.
409conflictEl recurso ya existe o está en un estado incompatible.
409webhook_not_configuredSuscribiste un envío sin tener el webhook habilitado.
413payload_too_largeEl cuerpo excede el máximo.
415unsupported_media_typeFalta Content-Type: application/json.
422carrier_rejectedEl sistema del courier rechazó el pedido.
429rate_limitedPasaste tu límite por minuto.
429quota_exceededPasaste el tope mensual de consultas. Solo lo tiene el plan Free.
429carrier_rate_limitedEl cupo es del courier, no tuyo.
500internalFalla nuestra.
501carrier_not_supportedEse carrier todavía no soporta la operación. Viene con el motivo.
502carrier_unavailableEl sistema del courier falló.
503carrier_disabledEl carrier está apagado en esta instancia.
503carrier_cooldownCortamos el tráfico a ese courier tras fallas seguidas. Trae Retry-After.
504carrier_timeoutEl 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.