API Docs
Documentación para desarrolladores

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.

Entrada → API REST

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.

Salida → Webhooks

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.

En dos palabras

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.

api_key

Identifica

Público (prefijo tms_live_). Identifica tu credencial en cada petición. Viaja en el header X-TMS-Api-Key.

api_secret

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.

whsec_…

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.

Importante

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.

Opcional: el token Bearer de tu receptor

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.

Un juego por entorno

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.

EntornoBase URLCuá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.
Las credenciales no se comparten entre entornos

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.

Pasar a producción

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.

API · Entrada

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

HeaderDescripción
X-TMS-Api-KeyTu API key pública (tms_live_…).
X-TMS-TimestampUnix timestamp en segundos (10 dígitos, no milisegundos) del momento de la petición.
X-TMS-Signaturesha256=<hex(firma)> — ver fórmula abajo.
Idempotency-KeyObligatorio 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-Typeapplication/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.

fórmula
# 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 un GET lo envía, entra en la firma igual que en un POST).
  • 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 constante e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Ejemplo: crear un manifiesto firmado

terminal
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:

  1. El mismo key con otro cuerpo u otro endpoint — sin Retry-After. Genera una clave nueva (o corrige el payload) antes de reintentar.
  2. La petición original todavía en curso — con Retry-After: 5.
  3. 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.

respuesta
{ "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ímiteValorAl superarlo
Peticiones por credencial120 por minuto429 con Retry-After
Peticiones por IP300 por minuto (antes de autenticar)429 con Retry-After
Tamaño del cuerpo5 MB413
Guías por manifiesto1000422 — parte el lote
Página de listadoshasta 100 elementosse 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:

1

Valida tus credenciales

GET /me — confirma que tu firma funciona y devuelve la identidad de tu cuenta (id, name, code, center_id, timezone).

2

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

3

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.

4

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.

5

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.

6

Cancela si hace falta

POST /shipments/:tracking/cancel — cancela una guía mientras no esté en un estado final. Dispara shipment.cancellation_requested.

Reconciliación

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

typecross_docking_modeRequiere
pickup_and_delivery—Nada extra: recogemos y entregamos. warehouse_code es opcional (origen de la recolección).
cross_dockingpickupwarehouse_code (el código de tu almacén de origen). El centro de destino lo asigna Corhex.
cross_dockingdropoffNada 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[])

CampoReq.Descripción
recipient_nameSíNombre del destinatario.
recipient_phoneSíTeléfono del destinatario.
delivery_addressSí*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

201 Created — data
{
  "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.

Seguridad multi-tenant

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.

EndpointQué hace
POST /shipmentsAgrega 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/openAbre 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_code debe ser el código de un almacén activo de tu cuenta.
  • flow: pickup_and_delivery (recogemos en tu sucursal) o cd_pickup (cross-docking con recolección). Si lo omites se usa el flujo por defecto de tu cuenta; sin ninguno, 422.
  • Vigila manifest_code y outcome: un warehouse_code o un SLA distinto abre un casillero nuevo, y así lo detectas el mismo día.
Idempotency-Key en /manifests/open

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.

EndpointDevuelve
GET /slaTus tipos de entrega activos: code, name, sla_hours, category y aliases. Cualquiera de code, name o un alias vale en el campo sla.
GET /warehousesTus almacenes: su code es el valor de warehouse_code. Incluye name, address e is_active (también lista los inactivos).
GET /pudo-pointsTus puntos PUDO activos (hasta 100): client_code y internal_code — los dos válidos en pudo_point_code —, nombre y dirección.
GET /centersEl 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 /meIdentidad de tu cuenta (id, name, code, center_id, timezone) y prueba de firma.

Consultar y cancelar

Consultar

  • GET /manifests/:code — el manifiesto y, en shipments[], 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_size hasta 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. :tracking es el tracking TMS (el de shipments[] y de los webhooks), no tu tracking_number ni tu client_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_pickup y vuelve a origen (luego recibes shipment.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étodoRutaQué hace
POST/manifestsCrea manifiestos + guías (atómico).
GET/manifestsLista paginada de tus manifiestos.
GET/manifests/:codeEstado de un manifiesto y sus guías.
POST/manifests/openAbre el casillero de una sucursal (modo casillero).
POST/shipmentsAgrega una guía al casillero de su sucursal (modo casillero).
GET/shipments/:trackingEstado de una guía puntual.
POST/shipments/:tracking/cancelCancela una guía que no esté en un estado final.
GET/sla · /warehouses · /pudo-points · /centers · /meDatos de referencia (ver arriba).
GET/intl/catalogCatálogo internacional: corredores, niveles de servicio y cajas.
POST/intl/shipmentsRegistra un envío internacional.
GET/intl/shipmentsLista filtrable de tus envíos internacionales.
GET/intl/shipments/:codeDetalle y seguimiento de un envío internacional.
POST/intl/shipments/:code/pickupPide la recolección del envío.
POST/intl/shipments/:code/cancel-pickupCancela esa recolección.
GET/intl/shipments/:code/documents/commercial-invoiceFactura comercial o proforma (PDF).
Internacional

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.

Requisito: cuenta habilitada como exportador

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.

1

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.

2

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

3

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.

4

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.

5

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 responde 409 client_reference_taken con data.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 último updated_at que viste, con unos segundos de solapamiento.

Los campos, filtros y códigos de error completos están en la referencia OpenAPI ↗.

Webhooks · Salida

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

Tu endpoint debe

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.

Un solo formato para todos los clientes

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.

El orden no está garantizado

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

typeSe dispara cuandodata.previous.status
shipment.expectedSe registra la guía de un manifiesto: queda esperando su recolección o recepción física—
shipment.createdSe registra una guía que no espera recepción física (poco habitual en esta API)—
shipment.assignedEntra a una rutareceived
shipment.in_transitEl conductor sale a entregarassigned
shipment.deliveredEntrega confirmada (con prueba de entrega)in_transit
shipment.failedCada intento de entrega fallido (no sólo el último)in_transit
shipment.return_pendingMarcada para devoluciónfailed
shipment.returnedDevuelta al almacén/clientereturn_pending
shipment.on_holdGuía retenidavariable
shipment.address_issueProblema con la direcciónvariable
shipment.out_of_coverageFuera de la zona de coberturavariable
shipment.cancellation_requestedLa guía fue cancelada — ver abajovariable
shipment.address_changedSe aprueba una solicitud de cambio de dirección/teléfono, o nuestro equipo corrige la dirección de entrega—*
  • shipment.cancellation_requested: revisa data.object.status. cancelled si la guía no se había recolectado; cancelled_after_pickup si ya estaba en nuestra custodia, y en ese caso luego recibes shipment.returned.
  • Estados que comparten evento: una visita que agotó sus intentos (visit_not_completed) llega como shipment.failed, y una cancelada ya devuelta (cancelled_returned) como shipment.returned. El estado crudo viaja en data.object.status.
  • La recepción en nuestro almacén (received) no genera evento.
* shipment.address_changed

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

typeSe dispara cuando
manifest.createdSe crea el manifiesto (en el modo casillero, con su primera guía)
manifest.assignedSe asigna a un conductor para la recolección (sólo manifiestos con recolección)
manifest.in_progressEmpieza la recolección (sólo manifiestos con recolección)
manifest.collectedRecolección o recepción física completada
manifest.completedTodas sus guías llegaron a un estado final (entregadas, devueltas o canceladas), o se cerró la recepción sin faltantes
manifest.completed_with_discrepancySe cerró la recepción con faltantes o sobrantes
manifest.cancelledSe 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.

typeSe dispara cuandodata.object
subsanacion.approvedSe aprueba la evidencia certificada de la auditoría de un puntoid, client_id, pudo_point_id, rejection_cause, status, content_hash, server_timestamp, reviewed_by
visit.closedSe cierra el formulario de una visitaid, 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.

envelope
{
  "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

data.object — 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.code es el code de GET /sla, no el alias que enviaste.
  • failure_reason es un texto legible (motivo, intento, conductor), no un código estable: muéstralo, no lo compares. Para decidir, usa failure_code.
  • failure_code es el motivo que eligió el conductor, como código estable (tabla abajo). Igual que failure_reason, sólo trae valor en una guía fallida (shipment.failed) y es null en el resto.
  • proof_of_delivery sólo viene en shipment.delivered; en los demás eventos la clave no aparece. recipient.email, recipient.document y destination.district se 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_codeMotivo
recipient_absentDestinatario ausente
wrong_addressDirección incorrecta
access_deniedSin acceso al lugar
security_issueProblemas de seguridad
business_closedNegocio cerrado (entregas a punto/comercio)
refused_deliveryEl destinatario rechazó la entrega
damaged_packagePaquete dañado
out_of_coverageFuera de cobertura
incorrect_itemArtí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.

shipment.address_changed
{
  "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

data.object — 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.

HeaderSignificado
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-IdUUID de la entrega (un evento en una suscripción). Se mantiene en los reintentos y reenvíos.
X-TMS-Signaturet=<unix>,v1=<hex(firma)>
AuthorizationBearer <tu token>. Sólo si nos diste un token Bearer para esa suscripción (ver abajo); si no, el header no viene.
User-AgentTMS-Mitter-Webhooks/1.0

Fórmula

fórmula
# firma = HMAC_SHA256(whsec, "{t}.{body_crudo}")
# header = X-TMS-Signature: t=<unix>,v1=<hex(firma)>
Clave

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 401 si no coincide. Ojo: un 401 se 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

receiver.mjs
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 "", 200
t, 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 confirma

Reintentos 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):

backoff (6 intentos)
inmediato → 30s → 2m → 10m → 1h → 6h
  • Sólo 2xx confirma la entrega. No seguimos redirecciones: un 3xx cuenta como fallo. Un 4xx también se reintenta — incluido el 401 por 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-Id y el mismo X-TMS-Delivery-Id.
Buena práctica

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í:

Tú nos enví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 whsec en lugar del que generamos, entre 16 y 256 caracteres.
Corhex hace

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

  1. Implementa el receptor: verificación de firma sobre el body crudo, token Bearer si aplica, idempotencia por X-TMS-Event-Id, respuesta 2xx en menos de 10 s, y traducción de nuestros eventos a tus estados.
  2. 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.
  3. Recibe y guarda el whsec_ de pruebas en tu gestor de secretos.
  4. Evento de prueba: lo enviamos a tu URL y debe responder 2xx. Un 401 casi siempre es el token Bearer o el whsec_ equivocados.
  5. Prueba de punta a punta: crea guías en pruebas y comprueba que cada evento llega y se refleja en tu sistema.
  6. 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.

Cambios y rotació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.

Evento de prueba

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

HTTPSignificadoCausa típica
400Bad requestFaltan 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).
401UnauthorizedAPI key inválida, vencida o revocada; firma incorrecta; o timestamp fuera de ventana.
404Not foundRecurso inexistente o de otra cuenta (no se distingue, por seguridad). En /intl, también una cuenta no habilitada como exportador.
409ConflictIdempotency-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.
413Payload too largeEl cuerpo supera 5 MB. Parte el lote.
422UnprocessableValidación: el lote se rechazó entero (atómico). error trae el motivo en texto.
429Too many requestsRate limit excedido — dos límites independientes, por IP y por credencial. Respeta Retry-After.
500Internal errorError nuestro. Reintenta con la misma Idempotency-Key.
503Service unavailablePico 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_secret y whsec_ 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-Key nueva en cada POST, 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 /me y consulté GET /sla para 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-Signature sobre el body crudo y rechaza con 401 un 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-Id y respondo 2xx rápido.
  • ☐ Traduzco type / data.object.status (y failure_code en los fallos) a los estados de mi sistema, e ignoro (con 2xx) los eventos que no uso.
  • ☐ Mi endpoint respondió 2xx al evento de prueba en pruebas y en producción.
  • ☐ Reconcilio contra mi sistema usando client_tracking_id.
  • ☐ Respeto Retry-After en los 429 y 409.
  • ☐ Validé el flujo completo en pruebas y tengo la base URL en configuración para cambiar a producción.