Integra tu sistema con
Corhex Global
Conecta tu ERP, e-commerce o WMS directamente con nuestra plataforma de última milla: crea manifiestos y guías, consulta su estado, cancela y recibe notificaciones en tiempo real de cada cambio — sin pasar por el panel ni por sesiones de usuario. Si exportas, también puedes registrar envíos internacionales.
Tú nos envías
Tu sistema firma peticiones HTTPS con tus credenciales para crear manifiestos y guías, consultarlas por tracking y cancelarlas. Base: /api/v1/external.
Nosotros te avisamos
Cada cambio de estado (registrada, en ruta, entregada, fallida…) se entrega a tu endpoint con una firma HMAC verificable y reintentos automáticos.
La API es el pull/push que tú inicias; los webhooks son el push que iniciamos nosotros. Juntos cubren todo el ciclo de vida de un envío. Toda comunicación es sobre HTTPS y autenticada con HMAC-SHA256.
Conceptos clave
La integración usa tres secretos distintos. No los confundas: cada uno cumple un rol diferente y se entrega/usa de forma distinta.
Identifica
Público (prefijo tms_live_). Identifica tu credencial en cada petición. Viaja en el header X-TMS-Api-Key.
Firma tus peticiones
Secreto, sin prefijo. Con él calculas la firma HMAC de cada request: úsalo tal cual, sin decodificarlo. Se muestra una sola vez al crearse. Nunca lo expongas en el cliente/navegador.
Verifica los webhooks
Secreto por suscripción. Con él tu receptor verifica que cada webhook viene de Corhex. También se muestra una sola vez.
El api_secret y el whsec_ se muestran una única vez al generarlos. Guárdalos en tu gestor de secretos. Si los pierdes, no se pueden recuperar: hay que rotar/regenerar la credencial.
Si tu endpoint de webhooks exige su propia credencial, nos das un token Bearer y lo enviamos en cada entrega como Authorization: Bearer <token>, además de la firma. Ese token lo emites y lo controlas tú, no Corhex. Ver Configurar un webhook.
Obtener credenciales
Las credenciales las provisiona el equipo de Corhex Global desde el panel de administración. Como cliente recibes:
- Tu par
api_key+api_secret(para la API). - Un
whsec_por cada endpoint de webhook que registres (para verificar).
Para solicitarlas o registrar/cambiar la URL de tus webhooks, contacta a tu representante o escribe a [email protected]. La rotación de credenciales de la API no genera downtime: tu cuenta puede tener varias credenciales activas a la vez, así que se emite la nueva, la adoptas y se revoca la anterior.
Recibes credenciales distintas para pruebas y para producción — ver Entornos. Empieza siempre por pruebas. Sincroniza además tu reloj por NTP: la firma incluye un timestamp con ventana de ±5 minutos.
Entornos
Exponemos dos entornos independientes con la misma API: mismas rutas, mismos headers, misma firma y mismos payloads. Lo único que cambia entre uno y otro es el host.
| Entorno | Base URL | Cuándo usarlo |
|---|---|---|
| pruebas | https://api-dev.corhex.app/api/v1/external |
Desarrollo y QA de tu integración. Tiene su propia base de datos: nada de lo que crees ahí llega a la operación real. |
| producción | https://api.corhex.app/api/v1/external |
Operación real. Las guías que crees entran al flujo logístico y se entregan. |
Cada entorno tiene su propia base de datos y su propio juego de credenciales: recibes un api_key (prefijo tms_live_ en ambos) y un api_secret por entorno, y registras los webhooks por separado en cada uno. Usar una credencial de pruebas contra producción — o al revés — responde 401.
Mantén la base en una variable de configuración (por ejemplo TMS_API_BASE) y el salto a producción será cambiar ese valor y las credenciales, nada más. Los ejemplos de esta guía usan la base de producción; para probar, sustituye el host por el de pruebas.
Autenticación: firma HMAC
Cada petición se autentica con tu api_key y una firma HMAC-SHA256 calculada sobre el request entero — método, ruta, clave de idempotencia, timestamp y cuerpo —, no solo sobre el timestamp y el body. No usamos tokens de sesión.
Headers requeridos
| Header | Descripción |
|---|---|
X-TMS-Api-Key | Tu API key pública (tms_live_…). |
X-TMS-Timestamp | Unix timestamp en segundos (10 dígitos, no milisegundos) del momento de la petición. |
X-TMS-Signature | sha256=<hex(firma)> — ver fórmula abajo. |
Idempotency-Key | Obligatorio en todo POST. Identificador único de la operación (ej. un UUID). También forma parte de la firma (ver abajo): reintentar con el mismo key devuelve la respuesta original, pero cambiarlo invalida la firma calculada con el anterior. |
Content-Type | application/json en peticiones con cuerpo. |
Cómo se calcula la firma
La firma cubre el request entero, no solo el cuerpo. Se concatenan cinco campos separados por salto de línea (\n) en una cadena canónica que siempre tiene los cinco — ninguno se omite, aunque esté vacío — y se firma con HMAC-SHA256, usando tu api_secret tal cual como clave.
# canonical = "{MÉTODO}\n{request-target}\n{idempotency-key}\n{timestamp}\n{hex(sha256(body_crudo))}"
# firma = HMAC_SHA256(api_secret, canonical)
# header = X-TMS-Signature: sha256=<hex(firma)>
- request-target: la ruta más el query tal como viajan en la línea de petición (ej.
/api/v1/external/manifests, o/api/v1/external/manifests?page=2). Firma exactamente los bytes que envías: mismo orden de parámetros y misma codificación. - idempotency-key: el valor del header
Idempotency-Key; cadena vacía cuando el header está ausente — independiente del método (si unGETlo envía, entra en la firma igual que en unPOST). - hex(sha256(body_crudo)): el hash SHA-256 del body crudo (los bytes exactos que envías, nunca un JSON re-serializado). Con cuerpo vacío — como en
GET— es siempre la constantee3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Ejemplo: crear un manifiesto firmado
API_KEY="tms_live_xxx"
API_SECRET="xxxxxxxx"
METHOD="POST"
# Producción. Para pruebas: https://api-dev.corhex.app/api/v1/external
HOST="https://api.corhex.app"
TARGET="/api/v1/external/manifests"
TS=$(date +%s)
IDEM=$(uuidgen)
BODY='{"type":"pickup_and_delivery","items":[{"recipient_name":"Juan Pérez","recipient_phone":"+51999999999","delivery_address":"Av. Javier Prado 123, San Isidro","sla":"next-day"}]}'
# $NF (último campo) funciona con OpenSSL y con el LibreSSL de macOS,
# que imprime sólo el hash, sin el prefijo "SHA2-256(stdin)= ".
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')
CANONICAL=$(printf '%s\n%s\n%s\n%s\n%s' "$METHOD" "$TARGET" "$IDEM" "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')
curl -sS "$HOST$TARGET" \
-X "$METHOD" \
-H "X-TMS-Api-Key: $API_KEY" \
-H "X-TMS-Timestamp: $TS" \
-H "X-TMS-Signature: sha256=$SIG" \
-H "Idempotency-Key: $IDEM" \
-H "Content-Type: application/json" \
-d "$BODY"import crypto from 'node:crypto';
const API_KEY = process.env.TMS_API_KEY;
const API_SECRET = process.env.TMS_API_SECRET;
// Producción. Para pruebas: https://api-dev.corhex.app/api/v1/external
const HOST = process.env.TMS_API_HOST ?? 'https://api.corhex.app';
const BASE_PATH = '/api/v1/external';
function sign(method, target, idempotencyKey, ts, body) {
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const canonical = [method, target, idempotencyKey, ts, bodyHash].join('\n');
return 'sha256=' + crypto.createHmac('sha256', API_SECRET).update(canonical).digest('hex');
}
async function call(method, path, bodyObj) {
const target = BASE_PATH + path;
const body = bodyObj ? JSON.stringify(bodyObj) : '';
const ts = Math.floor(Date.now() / 1000).toString();
const idempotencyKey = method === 'POST' ? crypto.randomUUID() : '';
const headers = {
'X-TMS-Api-Key': API_KEY,
'X-TMS-Timestamp': ts,
'X-TMS-Signature': sign(method, target, idempotencyKey, ts, body),
};
if (method === 'POST') {
headers['Idempotency-Key'] = idempotencyKey;
headers['Content-Type'] = 'application/json';
}
const res = await fetch(HOST + target, { method, headers, body: method === 'POST' ? body : undefined });
return res.json();
}
await call('POST', '/manifests', {
type: 'pickup_and_delivery',
items: [{ recipient_name: 'Juan Pérez', recipient_phone: '+51999999999',
delivery_address: 'Av. Javier Prado 123', sla: 'next-day' }],
});import hashlib, hmac, json, os, time, uuid, requests
API_KEY = "tms_live_xxx"
API_SECRET = "xxxxxxxx"
# Producción. Para pruebas: https://api-dev.corhex.app/api/v1/external
HOST = os.getenv("TMS_API_HOST", "https://api.corhex.app")
BASE_PATH = "/api/v1/external"
def sign(method, target, idempotency_key, ts, body):
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join([method, target, idempotency_key, ts, body_hash])
return "sha256=" + hmac.new(API_SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
def call(method, path, body_obj=None):
target = BASE_PATH + path
# Firma y envía exactamente los mismos bytes.
body = json.dumps(body_obj, separators=(",", ":")).encode() if body_obj is not None else b""
ts = str(int(time.time()))
idempotency_key = str(uuid.uuid4()) if method == "POST" else ""
headers = {
"X-TMS-Api-Key": API_KEY,
"X-TMS-Timestamp": ts,
"X-TMS-Signature": sign(method, target, idempotency_key, ts, body),
}
if method == "POST":
headers["Idempotency-Key"] = idempotency_key
headers["Content-Type"] = "application/json"
data = body if method == "POST" else None
return requests.request(method, HOST + target, data=data, headers=headers).json()
call("POST", "/manifests", {
"type": "pickup_and_delivery",
"items": [{"recipient_name": "Juan Pérez", "recipient_phone": "+51999999999",
"delivery_address": "Av. Javier Prado 123", "sla": "next-day"}],
})Anatomía de una petición
Más allá de la firma, dos mecanismos hacen la integración robusta:
Idempotencia (POST)
Todo POST exige Idempotency-Key (sin él: 400). Si reintentas con el mismo key, el mismo endpoint y el mismo cuerpo, te devolvemos la respuesta original: no se crea nada dos veces. Guardamos sólo las respuestas 2xx, durante 7 días; si la respuesta original no fue 2xx, la clave se libera y puedes reusarla tras corregir el cuerpo. La clave es única por cuenta, compartida entre todas tus credenciales: genera un UUID nuevo por operación.
El 409 Conflict tiene tres motivos:
- El mismo key con otro cuerpo u otro endpoint — sin
Retry-After. Genera una clave nueva (o corrige el payload) antes de reintentar. - La petición original todavía en curso — con
Retry-After: 5. - Un conflicto transitorio interno que se resuelve solo — también con
Retry-After: 5.
Regla práctica: con Retry-After, reintenta con la misma clave; sin él, algo del pedido cambió.
Ventana anti-replay
El X-TMS-Timestamp debe estar dentro de ±5 minutos del reloj del servidor. Fuera de ventana — o un timestamp en milisegundos — → 401. Mantén tu servidor sincronizado por NTP.
Envoltura de respuesta
Las respuestas JSON usan el sobre estándar. En éxito el campo error no viene; en error no viene data y error es un texto.
{ "success": true, "data": { /* … */ } }
{ "success": false, "error": "items[0]: recipient_name is required" }
Dos excepciones, ambas en /intl: sus errores agregan data.error_code (un código estable para decidir en tu código), y la factura comercial se descarga como PDF crudo, sin sobre.
Límites de uso
| Límite | Valor | Al superarlo |
|---|---|---|
| Peticiones por credencial | 120 por minuto | 429 con Retry-After |
| Peticiones por IP | 300 por minuto (antes de autenticar) | 429 con Retry-After |
| Tamaño del cuerpo | 5 MB | 413 |
| Guías por manifiesto | 1000 | 422 — parte el lote |
| Página de listados | hasta 100 elementos | se recorta a 100 |
Las respuestas llevan X-RateLimit-Limit y X-RateLimit-Remaining. Ante un 429, espera los segundos de Retry-After antes de reintentar.
Flujo end-to-end
El recorrido típico de una integración, de principio a fin:
Valida tus credenciales
GET /me — confirma que tu firma funciona y devuelve la identidad de tu cuenta (id, name, code, center_id, timezone).
Descubre tus datos de referencia
GET /sla · /warehouses · /pudo-points · /centers — los códigos válidos para construir tus guías (tipos de entrega, almacenes, puntos PUDO, centro de destino).
Crea un manifiesto con sus guías
POST /manifests — envías el lote (atómico). Te devolvemos los manifiestos creados — uno por cada SLA distinto del lote — y en shipments el tracking TMS de cada guía. Dispara un manifest.created por manifiesto y un shipment.expected por guía.
Consulta el estado cuando lo necesites
GET /manifests/:code y GET /shipments/:tracking — el pull puntual que complementa al push de los webhooks. :tracking es el tracking TMS.
Recibe los cambios de estado por webhook
A medida que la operación avanza, tu endpoint recibe shipment.expected → assigned → in_transit → delivered (o failed), cada uno firmado. No necesitas hacer polling.
Cancela si hace falta
POST /shipments/:tracking/cancel — cancela una guía mientras no esté en un estado final. Dispara shipment.cancellation_requested.
Usa tu propio client_tracking_id al crear cada guía (ej. tu número de orden). Vuelve en shipments[] de la respuesta y en cada webhook de guía dentro de data.object.client_tracking_id, así reconcilias contra tu sistema. Debe ser único en tu cuenta: uno ya registrado rechaza el lote con 422 nombrando los repetidos. Guarda también el tracking TMS: es la llave para consultar y cancelar.
Crear un manifiesto
Un manifiesto agrupa una o más guías (hasta 1000). La creación es atómica: si algo falla, no se crea nada. Un JSON mal formado, o sin type/items, responde 400; un error de validación responde 422 con un mensaje de texto. Los SLA inválidos se informan todos juntos (fila N, desde 1); el resto de las validaciones informa el primer error encontrado (items[N], desde 0). Los campos desconocidos se ignoran sin error: revisa bien los nombres.
El lote se agrupa por SLA: se crea un manifiesto por cada SLA distinto, todos en la misma transacción. Si tu cuenta tiene horas de corte configuradas, los manifiestos se activan solos en la próxima.
Tipos de manifiesto
| type | cross_docking_mode | Requiere |
|---|---|---|
pickup_and_delivery | — | Nada extra: recogemos y entregamos. warehouse_code es opcional (origen de la recolección). |
cross_docking | pickup | warehouse_code (el código de tu almacén de origen). El centro de destino lo asigna Corhex. |
cross_docking | dropoff | Nada en el payload: el destino es siempre el centro asignado a tu cuenta (ver GET /centers). Sin centro asignado, 422. |
A nivel de manifiesto también puedes enviar notes (texto libre).
Campos por guía (items[])
| Campo | Req. | Descripción |
|---|---|---|
recipient_name | Sí | Nombre del destinatario. |
recipient_phone | Sí | Teléfono del destinatario. |
delivery_address | Sí* | Dirección de entrega. *No hace falta en una guía pudo con pudo_point_code: la dirección la pone el punto. |
recipient_email · recipient_document | — | Datos opcionales del destinatario. |
delivery_comuna | — | Distrito/comuna. |
delivery_lat · delivery_lng | — | Coordenadas si ya las conoces. |
weight_kg · volume_cm3 | — | Peso y volumen del paquete. |
declared_value | — | Valor declarado. |
description | — | Descripción del contenido. |
indicaciones | — | Indicaciones para el repartidor. |
preferred_time_from · preferred_time_to | — | Franja en que se puede recibir, HH:MM 24 h. Van las dos o ninguna, y el fin debe ser posterior al inicio (sin cruzar la medianoche). |
sla | — | Tipo de entrega: un alias, el código o el nombre de GET /sla, sin distinguir mayúsculas. Vacío = on-demand. |
shipment_type | — | ooh (domicilio, por defecto) o pudo (punto de retiro). Otro valor: 422. |
pudo_point_code | — | Para shipment_type=pudo. Con código (el tuyo o el TMS PUDO-…, ver GET /pudo-points) la guía toma del punto la dirección, comuna y coordenadas. Sin código, buscamos o creamos el punto a partir de recipient_name y delivery_address. |
tracking_number | — | Tu código de barras del bulto (el que escaneamos al recibirlo). Único en tu cuenta. No es el tracking de consulta: el tracking TMS lo generamos siempre y te lo devolvemos en shipments[]. |
client_tracking_id | — | Tu referencia (ej. nº de orden) para reconciliar. Única en tu cuenta. |
Respuesta
{
"codes": ["MAN-20260929-AB12CD34"], // uno por SLA distinto
"manifests": [ { /* manifiesto completo */ } ],
"shipments": [
{
"tracking_number": "CHX-20260929-7KQ2M9XA", // tracking TMS: para consultar y cancelar
"barcode": "EXT-0001", // el tracking_number que enviaste
"client_tracking_id": "ORD-99821",
"manifest_code": "MAN-20260929-AB12CD34"
}
]
}
shipments[] no sigue el orden de items[]: empareja cada guía con tu ítem por barcode (tu tracking_number) o por client_tracking_id. Si vas a necesitar el tracking TMS, envía al menos uno de los dos.
El client_id se toma siempre de tu credencial, nunca del payload. No puedes crear ni leer datos de otra cuenta; un recurso de otro cliente responde 404 (no 403).
Modo casillero abierto
Si prefieres enviar las guías de a una, a medida que se generan tus órdenes, Corhex puede activar en tu cuenta el modo casillero abierto. Cada sucursal tiene un manifiesto abierto (su «casillero») por combinación de sucursal, SLA y flujo; las guías se van acumulando en él y el casillero se cierra solo a tu hora de corte. Sin el modo activo, estos dos endpoints responden 422.
| Endpoint | Qué hace |
|---|---|
POST /shipments | Agrega una guía al casillero de su sucursal. El cuerpo lleva los mismos campos de un items[], más warehouse_code (obligatorio), flow y sla. Aquí tracking_number es obligatorio: es la referencia idempotente de la guía. Responde 201 con tracking_number (el tracking TMS), manifest_code, outcome (created si abrió un casillero nuevo, appended si se sumó a uno abierto) y cutoff_at. |
POST /manifests/open | Abre por adelantado el casillero vacío de una sucursal ({ warehouse_code, sla?, flow? }). Responde 201 con outcome=created, o 200 con outcome=existing si ya estaba abierto: puedes llamarlo a ciegas al empezar el día. No dispara manifest.created; lo dispara la primera guía. |
warehouse_codedebe ser el código de un almacén activo de tu cuenta.flow:pickup_and_delivery(recogemos en tu sucursal) ocd_pickup(cross-docking con recolección). Si lo omites se usa el flujo por defecto de tu cuenta; sin ninguno,422.- Vigila
manifest_codeyoutcome: unwarehouse_codeo un SLA distinto abre un casillero nuevo, y así lo detectas el mismo día.
Las respuestas 2xx se guardan 7 días: si reusas la misma clave cada mañana, recibes el 201 de ayer, con un casillero que ya cerró. Incluye la fecha y la sucursal en la clave, por ejemplo open-SUC-01-2026-09-29.
Datos de referencia
Antes de crear guías, consulta los códigos válidos para tu cuenta. Todos van firmados con GET (body vacío) y están scoped a tu cliente.
| Endpoint | Devuelve |
|---|---|
GET /sla | Tus tipos de entrega activos: code, name, sla_hours, category y aliases. Cualquiera de code, name o un alias vale en el campo sla. |
GET /warehouses | Tus almacenes: su code es el valor de warehouse_code. Incluye name, address e is_active (también lista los inactivos). |
GET /pudo-points | Tus puntos PUDO activos (hasta 100): client_code y internal_code — los dos válidos en pudo_point_code —, nombre y dirección. |
GET /centers | El centro de Corhex asignado a tu cuenta, a donde llevas la carga en un cross-docking dropoff. Si aún no tienes uno, lista los centros activos como referencia: pide a tu representante que te asigne uno. |
GET /me | Identidad de tu cuenta (id, name, code, center_id, timezone) y prueba de firma. |
Consultar y cancelar
Consultar
- GET
/manifests/:code— el manifiesto y, enshipments[], el tracking TMS de cada una de sus guías. - GET
/manifests?page=1&page_size=20— tus manifiestos, del más reciente al más antiguo.page_sizehasta 100; la respuesta ({ items, total, page, page_size }) devuelve los valores aplicados. El listado no trae las guías de cada manifiesto. - GET
/shipments/:tracking— el estado de una guía.:trackinges el tracking TMS (el deshipments[]y de los webhooks), no tutracking_numberni tuclient_tracking_id.
Cancelar
POST /shipments/:tracking/cancel, con cuerpo opcional { "notes": "…" } e Idempotency-Key como todo POST. Cancela la guía mientras no esté en un estado final (entregada, devuelta, fallida o ya cancelada):
- Si aún no fue recolectada, queda
cancelled. - Si ya estaba en nuestra custodia o en ruta, queda
cancelled_after_pickupy vuelve a origen (luego recibesshipment.returned).
Responde 200 con la solicitud de cancelación. Revisa items[0].status: una guía que ya no se puede cancelar vuelve como rejected con rejection_reason: "ineligible_status" — no es un error HTTP. Una cancelación aceptada dispara shipment.cancellation_requested.
Endpoints de la API
Todos cuelgan de la base de tu entorno — producción https://api.corhex.app/api/v1/external, pruebas https://api-dev.corhex.app/api/v1/external. Para esquemas de request/response completos, parámetros y ejemplos por endpoint, consulta la referencia OpenAPI ↗.
| Método | Ruta | Qué hace |
|---|---|---|
| POST | /manifests | Crea manifiestos + guías (atómico). |
| GET | /manifests | Lista paginada de tus manifiestos. |
| GET | /manifests/:code | Estado de un manifiesto y sus guías. |
| POST | /manifests/open | Abre el casillero de una sucursal (modo casillero). |
| POST | /shipments | Agrega una guía al casillero de su sucursal (modo casillero). |
| GET | /shipments/:tracking | Estado de una guía puntual. |
| POST | /shipments/:tracking/cancel | Cancela una guía que no esté en un estado final. |
| GET | /sla · /warehouses · /pudo-points · /centers · /me | Datos de referencia (ver arriba). |
| GET | /intl/catalog | Catálogo internacional: corredores, niveles de servicio y cajas. |
| POST | /intl/shipments | Registra un envío internacional. |
| GET | /intl/shipments | Lista filtrable de tus envíos internacionales. |
| GET | /intl/shipments/:code | Detalle y seguimiento de un envío internacional. |
| POST | /intl/shipments/:code/pickup | Pide la recolección del envío. |
| POST | /intl/shipments/:code/cancel-pickup | Cancela esa recolección. |
| GET | /intl/shipments/:code/documents/commercial-invoice | Factura comercial o proforma (PDF). |
Envíos internacionales
Si exportas con Corhex, /intl/* te permite registrar y seguir tus envíos internacionales desde tu sistema. Usa la misma credencial, la misma firma, la misma cuota y la misma Idempotency-Key que el resto de la API.
Corhex debe habilitar en tu cuenta la modalidad internacional como exportador. Si GET /me responde 200 pero GET /intl/catalog responde 404, tu cuenta no está habilitada: no es la firma ni la URL. Toda la ruta /intl responde 404 a una cuenta no habilitada.
Consulta el catálogo
GET /intl/catalog — corredores (code, países, is_default, supports_pickup, supports_hub_pickup), sus niveles de servicio y tus cajas. Todo se referencia por código, nunca por UUID.
Registra el envío
POST /intl/shipments — responde 201 con code (ej. RSC-INT-20260928-8F2K4Q1Z), status, stage y tracking_url. Sin corridor_code se usa tu corredor por defecto; sin shipper, el remitente sale de los datos de tu cuenta. tax_payer: receiver (DAP, por defecto) o shipper (DDP).
Pide la recolección (opcional)
Con pickup_warehouse_code en el alta, o después con POST /intl/shipments/:code/pickup { "warehouse_code": "…" }. Exige el envío en registered, última milla contratada y un almacén activo. Se cancela con POST …/cancel-pickup mientras siga en registered. Si la recolección falla dentro del alta, el alta igual responde 201 con pickup.error_code = pickup_failed.
Descarga los documentos
GET /intl/shipments/:code/documents/commercial-invoice — el PDF crudo de la factura comercial o proforma, sin sobre JSON. Se genera en cada descarga: archiva la copia que uses como comprobante.
Sigue el envío
GET /intl/shipments/:code trae el detalle, con los hitos como claves (steps, events). GET /intl/shipments es el listado filtrable (stage, status, client_reference, from/to, updated_since; 20 por página, máx. 100).
- Evita duplicados con
client_reference: es tu número de orden, único de por vida en tu cuenta. Una repetida responde409 client_reference_takencondata.existing_code, así un reintento no crea un segundo envío. - Decide por
stage, que es cerrado:registered,transit,customs,attention,closed.status(16 valores, puede crecer) es para mostrar. - Decide los errores por
data.error_code. Un error sin él viene de la autenticación, de la habilitación de la cuenta (404) o de la infraestructura. - No hay webhooks internacionales todavía. Para sincronizar, consulta el listado con
?updated_since=el últimoupdated_atque viste, con unos segundos de solapamiento.
Los campos, filtros y códigos de error completos están en la referencia OpenAPI ↗.
Cómo funcionan los webhooks
Cuando el estado de una guía o manifiesto cambia, hacemos un POST con el evento a la URL que registraste. Es la forma recomendada de mantener tu sistema sincronizado — sin polling.
Firmados
Cada entrega incluye una firma HMAC-SHA256 que debes verificar antes de confiar en el contenido. Si tu receptor lo exige, también enviamos tu token Bearer.
Confiables
Si tu endpoint no responde 2xx, reintentamos con backoff exponencial (hasta 6 intentos).
Idempotentes
El mismo evento puede llegar más de una vez. Deduplica por id (header X-TMS-Event-Id).
1) Verificar la firma. 2) Responder 2xx rápido (antes de 10 s; procesa en segundo plano si hace falta). 3) Ser idempotente por event.id.
Todas las integraciones reciben exactamente el mismo payload (envelope), los mismos headers y la misma firma. No adaptamos el formato ni los nombres de estado a cada sistema receptor: tu receptor lee el envelope y traduce type / data.object.status (y, en los fallos, data.object.failure_code) a tus propios estados, y ubica el pedido en tu sistema con data.object.client_tracking_id (la referencia que nos enviaste al crear la guía). Lo único configurable por suscripción es la URL, los eventos y, si lo necesitas, tu token Bearer.
data.object es una foto del recurso al momento de enviar el evento: si la guía cambió dos veces muy seguido, un shipment.assigned puede llegar con object.status: "in_transit". Enruta por type, toma data.object.status y data.object.updated_at como la verdad más reciente, y ante dudas consulta GET /shipments/:tracking.
Catálogo de eventos
Te suscribes evento por evento (ver Configurar un webhook). La columna data.previous.status muestra el estado anterior más habitual; puede variar según el recorrido de la guía.
Guías — shipment.*
| type | Se dispara cuando | data.previous.status |
|---|---|---|
shipment.expected | Se registra la guía de un manifiesto: queda esperando su recolección o recepción física | — |
shipment.created | Se registra una guía que no espera recepción física (poco habitual en esta API) | — |
shipment.assigned | Entra a una ruta | received |
shipment.in_transit | El conductor sale a entregar | assigned |
shipment.delivered | Entrega confirmada (con prueba de entrega) | in_transit |
shipment.failed | Cada intento de entrega fallido (no sólo el último) | in_transit |
shipment.return_pending | Marcada para devolución | failed |
shipment.returned | Devuelta al almacén/cliente | return_pending |
shipment.on_hold | Guía retenida | variable |
shipment.address_issue | Problema con la dirección | variable |
shipment.out_of_coverage | Fuera de la zona de cobertura | variable |
shipment.cancellation_requested | La guía fue cancelada — ver abajo | variable |
shipment.address_changed | Se aprueba una solicitud de cambio de dirección/teléfono, o nuestro equipo corrige la dirección de entrega | —* |
shipment.cancellation_requested: revisadata.object.status.cancelledsi la guía no se había recolectado;cancelled_after_pickupsi ya estaba en nuestra custodia, y en ese caso luego recibesshipment.returned.- Estados que comparten evento: una visita que agotó sus intentos (
visit_not_completed) llega comoshipment.failed, y una cancelada ya devuelta (cancelled_returned) comoshipment.returned. El estado crudo viaja endata.object.status. - La recepción en nuestro almacén (
received) no genera evento.
No es un cambio de estado: la guía conserva su status. En este evento data.previous trae los datos de entrega anteriores en vez de status — delivery_address (siempre) y recipient_phone (sólo si cambió). El data.object ya refleja la dirección/teléfono nuevos. Ver Estructura del payload.
Manifiestos — manifest.*
| type | Se dispara cuando |
|---|---|
manifest.created | Se crea el manifiesto (en el modo casillero, con su primera guía) |
manifest.assigned | Se asigna a un conductor para la recolección (sólo manifiestos con recolección) |
manifest.in_progress | Empieza la recolección (sólo manifiestos con recolección) |
manifest.collected | Recolección o recepción física completada |
manifest.completed | Todas sus guías llegaron a un estado final (entregadas, devueltas o canceladas), o se cerró la recepción sin faltantes |
manifest.completed_with_discrepancy | Se cerró la recepción con faltantes o sobrantes |
manifest.cancelled | Se cancela el manifiesto, o se cancelan todas sus guías |
Servicios adicionales
Sólo aplican si tienes contratado el servicio de auditoría de puntos o de visitas; si no, no te suscribas a ellos.
| type | Se dispara cuando | data.object |
|---|---|---|
subsanacion.approved | Se aprueba la evidencia certificada de la auditoría de un punto | id, client_id, pudo_point_id, rejection_cause, status, content_hash, server_timestamp, reviewed_by |
visit.closed | Se cierra el formulario de una visita | id, client_id, pudo_point_id, shipment_id, route_stop_id, form_key, status, geofence_overridden, is_partial, content_hash, server_timestamp, reviewed_by |
Estructura del payload
Todos los eventos comparten un sobre (envelope) estilo Stripe: el recurso completo en data.object y, en las transiciones de guía, el estado anterior en data.previous.
{
"id": "evt_3f2a9c0e8b1d4e7fa2c65b0d9e4f1a73", // idempotencia (= X-TMS-Event-Id); opaco, no lo ordenes
"type": "shipment.delivered",
"api_version": "2026-06-07",
"occurred_at": "2026-06-07T17:42:10.482Z", // cuándo se emitió el evento, RFC3339 UTC
"data": {
"object": { /* recurso completo: shipment o manifest */ },
"previous": { "status": "in_transit" } // sólo en transiciones de guía
}
}
data.previous es informativo: puede faltar (en la primera transición de la guía, y en los eventos de manifiesto nunca viene). No lo uses para reconstruir el orden de los eventos.
Recurso shipment
{
"id": "5f3c2b1a-...",
"object": "shipment",
"tracking_number": "CHX-20260605-7KQ2M9XA", // tracking TMS
"client_tracking_id": "ORD-99821", // tu referencia, o null
"status": "delivered",
"shipment_type": "ooh",
"sla": { "code": "next_day", "name": "Día siguiente" }, // null si es on-demand
"recipient": { "name": "María Pérez", "phone": "+51987654321",
"email": "[email protected]", "document": "12345678" },
"destination": { "address": "Av. Javier Prado 123", "district": "San Isidro",
"lat": -12.0931, "lng": -77.0465 },
"package": { "weight_kg": 2.5, "volume_cm3": 18000, "quantity": 1, "declared_value": 99.9 },
"delivery_attempts": 1,
"failure_reason": null, // texto legible, sólo en failed
"failure_code": null, // código estable del motivo, sólo en failed
"proof_of_delivery": { // sólo en delivered
"received_by": "María Pérez",
"notes": "Recibió en portería",
"photo_url": "https://.../pod/photo-1.jpg", // la primera foto
"photo_urls": ["https://.../pod/photo-1.jpg", "https://.../pod/photo-2.jpg"],
"signature_url": "https://.../pod/sign.png",
"delivered_at": "2026-06-07T17:42:09Z"
},
"created_at": "2026-06-05T09:00:00Z",
"updated_at": "2026-06-07T17:42:10Z"
}
sla.codees elcodedeGET /sla, no el alias que enviaste.failure_reasones un texto legible (motivo, intento, conductor), no un código estable: muéstralo, no lo compares. Para decidir, usafailure_code.failure_codees el motivo que eligió el conductor, como código estable (tabla abajo). Igual quefailure_reason, sólo trae valor en una guía fallida (shipment.failed) y esnullen el resto.proof_of_deliverysólo viene enshipment.delivered; en los demás eventos la clave no aparece.recipient.email,recipient.documentydestination.districtse omiten si están vacíos.- Las URLs de la prueba de entrega son enlaces permanentes: descárgalas y guárdalas en tu sistema.
Motivos de fallo — failure_code
| failure_code | Motivo |
|---|---|
recipient_absent | Destinatario ausente |
wrong_address | Dirección incorrecta |
access_denied | Sin acceso al lugar |
security_issue | Problemas de seguridad |
business_closed | Negocio cerrado (entregas a punto/comercio) |
refused_delivery | El destinatario rechazó la entrega |
damaged_package | Paquete dañado |
out_of_coverage | Fuera de cobertura |
incorrect_item | Artículo incorrecto |
El catálogo puede crecer: trata un código que no conoces como un fallo genérico, no como un error (responde 2xx igual).
Ejemplo shipment.address_changed
data.previous lleva los datos anteriores; data.object ya refleja los nuevos. lat/lng pueden venir null si la nueva dirección quedó pendiente de re-geocodificación.
{
"type": "shipment.address_changed",
"data": {
"object": {
"object": "shipment",
"tracking_number": "CHX-20260605-7KQ2M9XA",
"status": "assigned", // el estado NO cambia
"destination": { "address": "Av. Arequipa 4500, Miraflores", "lat": null, "lng": null },
"recipient": { "name": "María Pérez", "phone": "+51999111222" }
},
"previous": { // datos anteriores, no status
"delivery_address": "Av. Javier Prado 123, San Isidro",
"recipient_phone": "+51987654321"
}
}
}
Recurso manifest
{
"id": "9a8b7c6d-...",
"object": "manifest",
"code": "MAN-20260607-AB12CD34",
"client_id": "1c2d3e4f-...",
"type": "pickup_and_delivery",
"status": "completed_with_discrepancy",
"warehouse": { "id": "3333-...", "name": "Almacén Lima Norte" }, // null si no tiene
"sla": { "code": "same_day", "name": "Mismo día" },
"counts": { "expected": 50, "received": 48, "driver_scanned": 48 },
"discrepancy_flag": true,
"created_at": "2026-06-07T06:00:00Z",
"updated_at": "2026-06-07T11:30:00Z",
"completed_at": "2026-06-07T11:30:00Z"
}
Verificar la firma
Cada entrega llega con estos headers (además de Content-Type: application/json). Verifica siempre X-TMS-Signature antes de procesar.
| Header | Significado |
|---|---|
X-TMS-Event-Id | = envelope.id. Tu clave de idempotencia. Si tienes dos suscripciones al mismo evento, las dos reciben el mismo id: deduplica por suscripción. |
X-TMS-Event-Type | = envelope.type. Permite enrutar sin parsear el body. |
X-TMS-Delivery-Id | UUID de la entrega (un evento en una suscripción). Se mantiene en los reintentos y reenvíos. |
X-TMS-Signature | t=<unix>,v1=<hex(firma)> |
Authorization | Bearer <tu token>. Sólo si nos diste un token Bearer para esa suscripción (ver abajo); si no, el header no viene. |
User-Agent | TMS-Mitter-Webhooks/1.0 |
Fórmula
# firma = HMAC_SHA256(whsec, "{t}.{body_crudo}")
# header = X-TMS-Signature: t=<unix>,v1=<hex(firma)>
Firma sobre el body crudo (los bytes recibidos), antes de cualquier parseo JSON. Un re-encode cambia los bytes y rompe la verificación. Compara en tiempo constante. t y la firma se recalculan en cada intento: no deduplices por firma.
Token Bearer (opcional)
Si tu infraestructura exige autenticar cada POST entrante (un API gateway, un listener compartido), registramos tu token en la suscripción y lo enviamos en Authorization: Bearer <token> en cada entrega, en los reintentos, en los reenvíos manuales y en el evento de prueba.
- No reemplaza la firma. El token prueba que quien llama lo conoce; la firma prueba además que el body no se alteró y que no es un reenvío viejo. Valida el token si tu gateway lo exige, y verifica la firma igual.
- Compáralo en tiempo constante y responde
401si no coincide. Ojo: un401se reintenta como cualquier no-2xx(ver Reintentos), así que un token mal configurado termina en dead-letter: valídalo con el evento de prueba antes de salir a producción. - Formato: entre 8 y 4096 caracteres, sin espacios. Puedes pasárnoslo con o sin el prefijo
Bearer.
Receptor que verifica
import express from 'express';
import crypto from 'node:crypto';
const WHSEC = process.env.TMS_WEBHOOK_SECRET; // whsec_...
const app = express();
app.use(express.raw({ type: 'application/json' })); // ¡body crudo (Buffer)!
app.post('/webhooks/corhex', (req, res) => {
const header = req.get('X-TMS-Signature') || '';
const p = Object.fromEntries(header.split(',').map(x => x.split('=')));
const t = Number(p.t), v1 = p.v1 || '';
// (opcional) si nos diste un token Bearer, valida req.get('Authorization') aquí
// 0) header bien formado (si no, timingSafeEqual lanzaría y responderías 500)
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(v1)) return res.sendStatus(401);
// 1) ventana anti-replay (5 min)
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return res.sendStatus(401);
// 2) recomputar sobre los bytes crudos y comparar en tiempo constante
const expected = crypto.createHmac('sha256', WHSEC)
.update(`${p.t}.`).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(401);
const event = JSON.parse(req.body);
// 3) idempotencia por event.id; procesa event.type / event.data.object
// (webhook.test es el evento de prueba: sólo responde 2xx)
// (guarda y responde rápido; procesa pesado en segundo plano)
res.sendStatus(200); // 2xx confirma la entrega
});
app.listen(4000);import hmac, hashlib, time
from flask import Flask, request, abort
WHSEC = "whsec_..."
app = Flask(__name__)
@app.post("/webhooks/corhex")
def corhex():
# (opcional) si nos diste un token Bearer, valida request.headers["Authorization"] aquí
header = request.headers.get("X-TMS-Signature", "")
try:
p = dict(x.split("=", 1) for x in header.split(","))
t, v1 = int(p["t"]), p["v1"]
except (ValueError, KeyError):
abort(401) # header malformado
# 1) ventana anti-replay (5 min)
if abs(int(time.time()) - t) > 300:
abort(401)
# 2) recomputar sobre el body crudo + comparar en tiempo constante
raw = request.get_data()
expected = hmac.new(WHSEC.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
event = request.get_json()
# 3) idempotencia por event["id"]; procesa event["type"] / event["data"]["object"]
# (webhook.test es el evento de prueba: sólo responde 2xx)
return "", 200t, v1 = parse("t=...,v1=...") # del header X-TMS-Signature (malformado → 401)
esperado = HMAC_SHA256(whsec, t + "." + body_crudo)
assert tiempo_constante_igual(esperado, v1) # firma válida
assert abs(ahora() - t) <= 300 # ventana anti-replay (5 min)
assert no_visto_antes(event.id) # idempotencia
responder 200 # 2xx confirmaReintentos y entrega
Si tu endpoint no responde 2xx (o no responde en 10 s), reintentamos automáticamente con backoff exponencial. Cada espera se cuenta desde el intento anterior y es aproximada (±15 s):
inmediato → 30s → 2m → 10m → 1h → 6h
- Sólo
2xxconfirma la entrega. No seguimos redirecciones: un3xxcuenta como fallo. Un4xxtambién se reintenta — incluido el401por firma o token Bearer inválidos. - Tras agotar los intentos, la entrega queda en dead-letter y el equipo de Corhex puede reenviarla manualmente desde el panel, con el mismo
X-TMS-Event-Idy el mismoX-TMS-Delivery-Id.
Responde 2xx apenas verifiques la firma y persistas el evento; haz el procesamiento pesado de forma asíncrona. Así evitas timeouts y reintentos innecesarios.
Configurar un webhook
Las suscripciones las registra el equipo de Corhex desde su panel; tú no llamas a ningún endpoint para darlas de alta. La configuración se reparte así:
Por cada entorno
- URL del receptor: pública y alcanzable desde internet. Recomendamos
https://; no aceptamos IPs privadas ni locales y no seguimos redirecciones, así que debe ser la URL final. - Eventos: la lista explícita del catálogo. No hay comodín «todos».
- Token Bearer (opcional): sólo si tu receptor lo exige.
- Secreto de firma propio (opcional): si prefieres fijar tú el
whsecen lugar del que generamos, entre 16 y 256 caracteres.
En el panel
- Crea la suscripción con tu URL, eventos y token.
- Te entrega el
whsec_generado, una sola vez (si no fijaste uno propio). - Envía el evento de prueba y te confirma el código HTTP que respondió tu endpoint.
- Revisa el historial de entregas y reenvía las que quedaron en dead-letter.
Paso a paso
- Implementa el receptor: verificación de firma sobre el body crudo, token Bearer si aplica, idempotencia por
X-TMS-Event-Id, respuesta2xxen menos de 10 s, y traducción de nuestros eventos a tus estados. - Envíanos los datos de pruebas (URL, eventos y, si aplica, token) por un canal seguro — no pegues tokens en un correo en texto plano.
- Recibe y guarda el
whsec_de pruebas en tu gestor de secretos. - Evento de prueba: lo enviamos a tu URL y debe responder
2xx. Un401casi siempre es el token Bearer o elwhsec_equivocados. - Prueba de punta a punta: crea guías en pruebas y comprueba que cada evento llega y se refleja en tu sistema.
- Producción: repite los pasos 2–4 con la URL, el token y el
whsec_de producción. Cada entorno tiene sus propias suscripciones y secretos.
Puedes tener varias suscripciones por entorno (ej. una por sistema o por equipo), cada una con su propio secreto, token y lista de eventos. Si agregamos eventos nuevos al catálogo, pide sumarlos a tu suscripción.
La rotación del whsec_ y el cambio del token Bearer se aplican al instante, sin período de gracia: coordínalos con nosotros y acepta ambos valores durante la ventana del cambio. Si cambias la URL de una suscripción que tiene token, vuelve a enviarnos el token: por seguridad no se traslada solo a un destino nuevo. Mientras una suscripción está pausada, los eventos de ese período no se encolan.
Llega con type: "webhook.test" (y X-TMS-Event-Type: webhook.test), firmado igual que los reales y con tu token Bearer si lo configuraste. Su data.object no es una guía ("object": "webhook_test"): responde 2xx sin procesarlo. No se reintenta. Para depurar, registra el X-TMS-Event-Id y el resultado de la verificación de cada entrega.
Errores y códigos
| HTTP | Significado | Causa típica |
|---|---|---|
400 | Bad request | Faltan headers de firma, falta Idempotency-Key en un POST, o el JSON es inválido (mal formado, sin type/items, o un campo con el tipo equivocado). |
401 | Unauthorized | API key inválida, vencida o revocada; firma incorrecta; o timestamp fuera de ventana. |
404 | Not found | Recurso inexistente o de otra cuenta (no se distingue, por seguridad). En /intl, también una cuenta no habilitada como exportador. |
409 | Conflict | Idempotency-Key reusado con otro cuerpo u otro endpoint; la petición original todavía en curso (Retry-After: 5); o un conflicto transitorio interno (Retry-After: 5). En /intl, además, estados del envío que no permiten la operación. |
413 | Payload too large | El cuerpo supera 5 MB. Parte el lote. |
422 | Unprocessable | Validación: el lote se rechazó entero (atómico). error trae el motivo en texto. |
429 | Too many requests | Rate limit excedido — dos límites independientes, por IP y por credencial. Respeta Retry-After. |
500 | Internal error | Error nuestro. Reintenta con la misma Idempotency-Key. |
503 | Service unavailable | Pico de tráfico en el borde (la respuesta puede no ser JSON). Reintenta con backoff. |
En /intl, los errores de negocio traen además data.error_code (ej. client_reference_taken, pickup_not_allowed): decide por ese código. La lista completa por endpoint está en la referencia OpenAPI ↗.
Checklist de integración
- ☐ Guardé
api_key,api_secretywhsec_en mi gestor de secretos. - ☐ Firmo cada petición con
HMAC_SHA256(api_secret, canonical)sobre la cadena canónica de 5 campos (método, request-target, idempotency-key, timestamp, hash del body) y mando los 3 headers de autenticación. - ☐ Envío una
Idempotency-Keynueva en cadaPOST, y reuso la misma sólo para reintentar esa operación. - ☐ Mi servidor está sincronizado por NTP (ventana ±5 min) y mando el timestamp en segundos.
- ☐ Validé la firma con
GET /mey consultéGET /slapara mis tipos de entrega. - ☐ Guardo el tracking TMS de cada guía (de
shipments[]o del webhook): es el que uso para consultar y cancelar. - ☐ Mi receptor de webhooks verifica
X-TMS-Signaturesobre el body crudo y rechaza con401un header malformado. - ☐ Si mi receptor exige token Bearer, se lo di a Corhex por canal seguro y lo valido en tiempo constante (sin dejar de verificar la firma).
- ☐ Deduplico eventos por
X-TMS-Event-Idy respondo2xxrápido. - ☐ Traduzco
type/data.object.status(yfailure_codeen los fallos) a los estados de mi sistema, e ignoro (con2xx) los eventos que no uso. - ☐ Mi endpoint respondió
2xxal evento de prueba en pruebas y en producción. - ☐ Reconcilio contra mi sistema usando
client_tracking_id. - ☐ Respeto
Retry-Afteren los429y409. - ☐ Validé el flujo completo en pruebas y tengo la base URL en configuración para cambiar a producción.