Saltar al contenido
Pedir acceso

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 5 de 5 couriers rastrean (Expreso Marvisur, Olva Courier, Urbano Express, Cruz del Sur Cargo y Shalom) y 5 publican catálogo de agencias. El estado real, carrier por carrier, lo publica el propio API en GET /v1/carriers.

¿Prefieres generar el cliente en vez de escribirlo? El spec OpenAPI está en /openapi.json, generado en cada despliegue desde la misma fuente que esta página.

Lo que cambió y cuándo está en el registro de cambios; si algo no te responde ahora mismo, el sondeo en vivo de cada courier está en /status.

Autenticación con X-API-Key

Toda llamada lleva tu key en la cabecera X-API-Key. Cada key va con un plan —Gratuito, Básico, Plus o Premium—. La del Gratuito se emite sola desde la web; las de pago se contratan por correo.

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: el tope de consultas al mes se cuenta aparte. La key se entrega en la misma respuesta del alta.

PlanRequests / minutoWebhooks activosÓrdenes / mesConsultas
Gratuito303no incluidohasta 1.000 al mes
Básico6030120hasta 10.000 al mes
Plus120120500hasta 50.000 al mes
Premium3005002.000sin tope

Tu primer llamado al API

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 del API

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.

Los 11 endpoints del API

Los 11 que expone el servicio, con una respuesta real de cada uno.

POST/v1/shipments

Crea una guía con el courier. Un cuerpo canónico para todos.

El mismo cuerpo sirve para todos los couriers. Lo que sólo pide uno va en carrier_options, y su esquema se publica en GET /v1/carriers.

request
{
  "carrier": "olva",
  "service": { "delivery": "agency" },
  "origin": { "agency_id": "hq:12" },
  "destination": { "agency_id": "161" },
  "sender": {
    "name": "Tu Tienda SAC",
    "document": { "type": "ruc", "number": "20xxxxxxxxx" },
    "phone": "999999999"
  },
  "receiver": {
    "name": "Ana Quispe",
    "document": { "type": "dni", "number": "0xxxxxxx" },
    "phone": "988888888"
  },
  "parcels": [
    { "weight_kg": 1.2, "content": { "category": "ropa" }, "declared_value": 120 }
  ],
  "payer": "receiver"
}
201 Created · application/json
{
  "id": "shp_01K...",
  "carrier": "olva",
  "status": "pending_payment",
  "tracking": { "number": "2431838-26" },
  "parcels": [{ "index": 0, "tracking_number": "2431838-26", "url": "…/label" }],
  "payment_instructions": "Se paga en destino al recoger."
}

POST/v1/shipments/quote

Cuánto cuesta antes de crearla. Sin efectos.

Cotizar no crea nada ni consume cupo de órdenes.

request
{
  "carrier": "olva",
  "service": { "delivery": "agency" },
  "origin": { "ubigeo": "150101" },
  "destination": { "ubigeo": "110101" },
  "parcels": [{ "weight_kg": 1 }]
}
200 OK · application/json
{
  "carrier": "olva",
  "amount": 8.00,
  "currency": "PEN",
  "delivery": "agency"
}

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.

Cada evento trae los tres: status (canónico), status_raw (el literal del courier) y carrier_code (su código, si hay) — normalizar no significa perder el dato original. Y para saber si un envío TERMINÓ usa los bools terminal/delivered, no el nombre: available_for_pickup no es entrega, y una devolución también es terminal. Segundo factor: algunos carriers piden además el código de orden — pásalo como &code=<código>. Es OBLIGATORIO en los que declaran requires_code en GET /v1/carriers (hoy Shalom: sin él responde 422) y se ignora en el resto.

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",
      "carrier_code": "0",
      "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",
      "carrier_code": "0",
      "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. El code es el 2º factor (código de orden): obligatorio para los carriers con requires_code como Shalom, opcional para el resto.

request
{
  "carrier": "marvisur",
  "number": "V001-0000001",
  "code": "2º factor — requerido si el carrier declara requires_code"
}
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 estados 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 Marvisurliveoperativo193
olvaOlva Couriercontract_onlyoperativo430
urbanoUrbano Expressliveoperativo273
cruzdelsurCruz del Sur Cargoliveoperativo182
shalomShalomliveoperativo565

Qué dice cada courier, y cómo lo traducimos

Cada courier nombra sus estados a su manera y ninguno publica esa lista. Acá está la de los cinco, con la traducción exacta a nuestro vocabulario y —lo más importante— cuánta evidencia tiene cada traducción detrás. No es lo mismo un estado que capturamos en una respuesta real que uno deducido de su nombre, y queremos que puedas notar la diferencia antes de construir lógica encima.

Todo esto sale del API y se actualiza solo: GET /v1/carriers/{carrier}/statuses, sin API key.

live

Visto en vivo

Capturamos una respuesta real con ese estado y lo vimos en su lugar del recorrido.

catalog

Declarado por el courier

El courier lo publica en su propio catálogo, con su significado. No lo vimos en tráfico, pero no lo estamos deduciendo nosotros.

inferred

Inferido

La traducción sale del nombre del estado y de cómo se comportan los que lo rodean. Es razonable y puede estar equivocada: confirmala antes de construir lógica encima.

Olva Courier

olva28 estados · 2 sin traducir

Olva identifica sus estados por LITERAL de texto. Esta tabla combina su catálogo oficial (el que su propio rastreador usa para dibujar el recorrido) con literales que sólo vimos en respuestas reales. NO es exhaustiva en ninguna de las dos direcciones: el catálogo de Olva es lo que su web sabe dibujar —un estado que no esté ahí se cae de su pantalla sin dejar rastro— y nosotros vimos al menos tres literales que no figuran en él. Un estado que no reconozcamos llega igual, con status «unknown» y el literal intacto en status_raw.

Lo que manda Olva CourierLo traducimos aEvidenciaQué significa
REGISTRADOregisteredVisto en vivoOlva registró el envío. Todavía puede no tenerlo físicamente.
TRACKING IMPRESOregisteredDeclarado por el courierEtiqueta generada. Olva lo muestra como «En almacén de».
TRACKING EN GUIAregisteredInferidoEtiqueta pegada a la guía. No está en el catálogo oficial.
RECIBIDO DEL CLIENTEregisteredDeclarado por el courierRecolección: Olva tomó posesión del bulto.
CONFIRMACION RECOJOregisteredDeclarado por el courier«Recepcionado por tienda» — confirmación del recojo inicial.
CONFIRMACION RECOJO OFICINAregisteredVisto en vivoVariante del recojo en oficina. Visto en tráfico real, NO está en el catálogo de Olva.
RECEPCION TIENDAat_originVisto en vivoLa tienda de ORIGEN recibe el bulto. Aparece al inicio del recorrido.
PRE DESPACHOat_originVisto en vivoEn la sede de origen, previo a salir. Visto entre at_origin e in_transit.
PRE VALIJAat_originVisto en vivoClasificación para el despacho (trae «Rampa» y «Oleada»). Mismo sub-paso que PRE DESPACHO.
EN VALIJAin_transitDeclarado por el courierConsolidado en la valija para el traslado.
DESPACHADOin_transitDeclarado por el courier«En camino a» — el bulto se está moviendo entre sedes.
TRACKING EN TRANSPORTEin_transitVisto en vivoExplícitamente en transporte. No está en el catálogo de Olva.
CONFIRMACION GUIAS DESPACHOin_transitDeclarado por el courier«En almacén de» + sede. Olva colapsa acá varias paradas de la ruta.
TRANSITOin_transitDeclarado por el courier«En almacén de» + sede.
CONFIRMACION DE LLEGADA A SEDEin_transitDeclarado por el courier«En almacén de» + sede. Se emite en CADA sede del recorrido (el literal crudo lleva el nombre de la sede pegado), por eso es tránsito y no llegada a destino.
RECEPCION DESPACHOat_destinationVisto en vivoRecepción en la sede de destino. Visto entre in_transit y out_for_delivery.
RECEPCION GUIAat_destinationVisto en vivoLa sede de DESTINO recibe la guía. No está en el catálogo de Olva.
ASIGNADOout_for_deliveryVisto en vivo«En camino a tu destino» — asignado a un repartidor, con su código de operador.
CONFIRMACION EN TIENDAavailable_for_pickupVisto en vivoLlegó al punto de retiro y espera al destinatario. Visto entre out_for_delivery y delivered.
ENTREGADOdeliveredVisto en vivoTerminal.
REZAGADOdelayedDeclarado por el courierQuedó atrás. El envío además queda marcado con el flag «delayed».
MOTIVADOexceptionDeclarado por el courierIncidencia con motivo, típicamente un intento fallido. No mueve el estado global.
SINIESTRADOexceptionDeclarado por el courier«Siniestrado, en proceso de investigación. Nos comunicaremos contigo».
ASIGNADO A DEVOLUCIONreturningDeclarado por el courierAsignado A devolución: el envío sigue vivo y todavía puede terminar entregado. NO es terminal.
DEVUELTOreturnedDeclarado por el courier«¡Devuelto exitosamente!» — el cierre del camino que abre ASIGNADO A DEVOLUCION. Terminal.
ANULADOcancelledDeclarado por el courier«Servicio cancelado». Terminal.
EN COORDINACIONunknowna propósitoDeclarado por el courier«En coordinación por …». Sin traducir a propósito: puede ser una coordinación de entrega normal o el síntoma de un problema, y son estados canónicos opuestos.
INDEMNIZADOunknowna propósitoDeclarado por el courier«Tu envío ha sido indemnizado». Sin traducir a propósito: es una resolución comercial de una pérdida y no encaja en entregado, devuelto ni incidencia abierta.

Urbano Express

urbano16 estados · 1 sin traducir

Urbano usa DOS esquemas de códigos numéricos que NO significan lo mismo. El de los movimientos (chk_id) es el que manda. El del envío (id_proceso) sólo se usa cuando no hay movimientos —un envío recién creado—, y ahí tres códigos son ambiguos entre esquemas y se dejan sin traducir a propósito. El vocabulario NO se conoce completo: sale de un solo envío observado, así que un código nuevo llega como unknown con el literal intacto en status_raw.

Lo que manda Urbano ExpressLo traducimos aEvidenciaQué significa
chk 1 (PU)registeredVisto en vivo«PEDIDO CREADO».
chk 2 (AO)at_originVisto en vivo«RECIBIMOS EN ORIGEN».
chk 4 (DD)in_transitVisto en vivo«DESPACHAMOS A SU CIUDAD».
chk 72 (RI)in_transitVisto en vivoRe-solicitud o reingreso.
chk 5 (AR)at_destinationVisto en vivo«LLEGÓ A LA CIUDAD DESTINO».
chk 6 (ER)out_for_deliveryVisto en vivo«SALIÓ A ENTREGARSE».
chk 7 (CV)exceptionVisto en vivo«INTENTAMOS ENTREGARTE»: intento fallido. NO es terminal — puede haber otro intento.
chk 9 (EN)deliveredVisto en vivo«ENTREGAMOS TU PEDIDO». Terminal.
chk 14 (RV)returningVisto en vivo«DEVOLVIMOS EL PRODUCTO»: volviendo al remitente, todavía no terminal.
chk 15 (AV)returningVisto en vivo«PRODUCTO FUE DEVUELTO».
proceso 1registeredInferidoRespaldo sin movimientos, INFERIDO: no se observó un envío decidido por esta vía. «Envío Registrado»/«Admitido» significan lo mismo en los dos esquemas, que es por qué se confía.
proceso 5deliveredInferidoRespaldo sin movimientos, INFERIDO. Es el único código que significa Entregado en AMBOS esquemas, así que no hay ambigüedad posible.
proceso 6exceptionInferidoRespaldo sin movimientos, INFERIDO del nombre: «No entregado».
proceso 7 / 21returnedInferidoRespaldo sin movimientos, INFERIDO del nombre: «Retorno al origen» / «Devuelto». Terminal.
proceso 23 / 24available_for_pickupInferidoRespaldo sin movimientos, INFERIDO del nombre: «Listo para recoger».
proceso 2 / 3 / 4unknowna propósitoInferidoSin traducir A PROPÓSITO: estos códigos significan cosas distintas en cada esquema y, sin movimientos, no hay forma de desambiguar. Inventar uno sería peor que decir que no se sabe.

Cruz del Sur Cargo

cruzdelsur12 estados

Cruz del Sur trae DOS estados que significan cosas distintas: el del documento (el global del envío) y el de cada hito del historial. Los hitos se deciden por FAMILIA —el prefijo antes del punto, así «DESPA.LOCAL» cae igual que «DESPA.PROVINCIA»—, que es lo que permite tolerar sufijos nuevos. Ninguno de los dos vocabularios se conoce completo: se observó UN envío oficina→oficina, y todo lo demás está marcado como inferido. Un valor no visto llega como unknown con el literal intacto.

Lo que manda Cruz del Sur CargoLo traducimos aEvidenciaQué significa
documento: origenat_originVisto en vivoVisto en vivo: envío recién emitido, con el historial todavía vacío.
documento: entregadodeliveredVisto en vivoVisto en vivo, con historial completo. Terminal.
documento: transito / en transito / viajein_transitInferidoINFERIDO del nombre: no se observó. Se traduce igual porque el sentido es inequívoco, pero no está confirmado.
documento: destino / en destinoat_destinationInferidoINFERIDO del nombre: no se observó.
documento: reparto / en repartoout_for_deliveryInferidoINFERIDO del nombre: no se observó.
documento: devuelto / devolucionreturnedInferidoINFERIDO del nombre: no se observó. Terminal.
evento REGISTRO.*registeredVisto en vivoVisto: REGISTRO.SISTEMA.
evento DESPA.*in_transitVisto en vivoVisto: DESPA.PROVINCIA (despacho = sale a ruta).
evento RECEP.*in_transitVisto en vivoVisto: RECEP.PROVINCIA (recepción en agencia intermedia o de destino).
evento TRANSITO.* / ARRIBO.*in_transitInferidoINFERIDO de la familia: no se observó.
evento REPARTO.*out_for_deliveryInferidoINFERIDO de la familia: no se observó.
evento ENTREGA.*deliveredInferidoINFERIDO de la familia: no se observó.

Expreso Marvisur

marvisur11 estados · 1 sin traducir

Marvisur identifica cada hito con un ID NUMÉRICO. Cuando el ID no alcanza para decidir —o no viene— se usa una heurística sobre el texto del comentario, por subcadena y no por literal exacto; esas filas se listan aparte. El espacio de IDs es chico y conocido, pero el texto del comentario lo escribe una persona y puede traer cualquier cosa: lo que no coincida llega como unknown, con el texto intacto en status_raw.

Lo que manda Expreso MarvisurLo traducimos aEvidenciaQué significa
0registeredVisto en vivoRecepción del bulto.
1in_transitVisto en vivoEn ruta. EXCEPCIÓN: si el comentario del hito menciona «DOMICILIO», se traduce a out_for_delivery — es lo mismo que hace el rastreador de Marvisur, que en ese caso cambia la etiqueta del paso.
2at_destinationInferidoNUNCA observado en una respuesta real: el hito está DECLARADO por Marvisur en su propio rastreador, pero no lo vimos en tráfico. Se traduce igual porque el ID existe en su espacio de hitos.
4deliveredVisto en vivoTerminal.
3unknowna propósitoInferidoExiste en el espacio de IDs (0, 1, 2 y 4 están traducidos) pero no aparece en su rastreador ni se observó nunca. Cae a la heurística por texto y, si tampoco coincide, llega como unknown.
comentario contiene «DOMICILIO»out_for_deliveryVisto en vivoCoincidencia por subcadena, no literal exacto.
comentario contiene «RECEPC»registeredVisto en vivoCoincidencia por subcadena (RECEPCION, RECEPCIONADO…).
comentario contiene «RUTA»in_transitVisto en vivoCoincidencia por subcadena.
comentario contiene «ALMAC»at_originVisto en vivoCoincidencia por subcadena (ALMACEN, ALMACENADO…).
comentario contiene «DESTIN»at_destinationInferidoCoincidencia por subcadena, INFERIDA: a diferencia de las otras, no se observó ningún comentario real que la active.
comentario contiene «ENTREG»deliveredVisto en vivoCoincidencia por subcadena (ENTREGA, ENTREGADO…).

Shalom

shalom8 estados

Shalom no manda literales de estado: manda SIETE HITOS como campos presentes o ausentes, y el estado del envío es el hito más avanzado presente. Por eso el conjunto es cerrado —no hay literales nuevos que puedan sorprender— y esta tabla está completa. Lo que sí puede faltar es información: sin la orden del envío no se sabe si es reparto a domicilio o retiro en agencia, y en ese caso se informa «destino» y se declara el faltante en el campo partial en vez de adivinar.

Lo que manda ShalomLo traducimos aEvidenciaQué significa
registradoregisteredVisto en vivoShalom registró el envío.
origenat_originVisto en vivoEn la agencia de origen.
transitoin_transitVisto en vivoViajando entre agencias.
demoradelayedVisto en vivoDemora declarada. NO es un paso del recorrido: puede convivir con tránsito y destino, y el envío queda además marcado con el flag «delayed».
destinoat_destinationVisto en vivoLlegó a la agencia de destino. Si además sabemos que el envío NO es de reparto a domicilio, se traduce a available_for_pickup: en Perú lo habitual es que el cliente lo retire.
repartoout_for_deliveryVisto en vivoSalió a reparto a domicilio.
entregadodeliveredVisto en vivoTerminal.
devolucion_mercaderiareturningVisto en vivoNo es un hito sino una BANDERA de la respuesta. Si está y el envío todavía no se entregó, el estado es returning (no terminal): un segundo intento todavía puede terminar en entrega.

Un estado que no reconozcamos no se pierde ni se inventa: llega con status: "unknown" y el texto original intacto en status_raw. Tu integración lo ve, nosotros también, y lo mapeamos.

Webhooks

Suscribes un envío y te avisamos cuando cambia de estado, en vez de que preguntes. Ideal cuando sigues varios envíos a la vez: te avisamos cuando cambian, sin que estés consultando uno por uno. Hoy se puede suscribir un envío de Expreso Marvisur, Olva Courier, Urbano Express, Cruz del Sur Cargo y Shalom; 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 de webhook

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 de webhook 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 real de webhook

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 de webhooks

Recibir webhooks no es sólo exponer un endpoint. 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. Y la firma lleva un timestamp (t=<unix>) DENTRO de lo firmado: verifica que sea reciente para rechazar reenvíos viejos (anti-replay).
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 los errores del API 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.
403plan_upgrade_requiredTu plan no incluye la operación: crear envíos empieza en Básico.
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_exceededLlegaste a un tope de tu plan: consultas del mes, envíos creados del mes o envíos vigilados a la vez.
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 referencia todavía no cubre

Preferimos listar los huecos de esta referencia a que los encuentres con un request fallido.

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