# EnruTek API 1.0.0

> Creá y rastreá guías en EnruTek desde cualquier sistema.

Dirección base: `https://api.enrutek.com/v1`. Contrato: https://api.enrutek.com/openapi.json y https://api.enrutek.com/openapi.yaml.

## Empezá en 5 pasos

Todo va a `https://api.enrutek.com/v1`, con JSON y con tu llave en la cabecera `Authorization`.

**1. Pedí tu llave.** La crea tu operador logístico en EnruTek y se muestra una sola vez. Guardala en el servidor de tu sistema, nunca en una página web, una app ni un repositorio.

**2. Mirá qué podés usar.** `/catalog` devuelve tus tipos de carga con su precio y tus sucursales. El `code` de cada tipo de carga es lo que va en `cargo_type`.

```bash
curl https://api.enrutek.com/v1/catalog \
  -H "Authorization: Bearer $ENRUTEK_KEY"
```

**3. Creá una guía.** Responde `201` con el número de guía y el total calculado con tu tarifa.

```bash
curl -X POST https://api.enrutek.com/v1/shipments \
  -H "Authorization: Bearer $ENRUTEK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: factura-379671" \
  -d '{
    "external_reference": "379671",
    "recipient": { "name": "Farmacia Plaza Flamingo", "phone": "+50626545524" },
    "destination": {
      "country": "CR",
      "admin_area_1": "Guanacaste",
      "admin_area_2": "Santa Cruz",
      "address_line": "Playa Potrero, 200 metros sur del Hotel Bahía Esmeralda"
    },
    "packages": [{ "cargo_type": "seco", "quantity": 2 }]
  }'
```

**4. Consultala.** Trae el estado, el historial y, una vez entregada, la prueba de entrega. Es para cuando alguien la necesita ver, no para preguntar cada rato si cambió: para eso está el paso 5.

```bash
curl https://api.enrutek.com/v1/shipments/TF-261024ZXWPH2S \
  -H "Authorization: Bearer $ENRUTEK_KEY"
```

**5. Enterate de los cambios.** Suscribí una URL de tu sistema con `POST /webhooks` para no tener que estar preguntando.

## Probar en Sandbox

Antes de conectar tu sistema a la operación real, probá en **Sandbox**, una empresa de pruebas dentro de EnruTek. Lo que creés ahí no le llega a ningún operador ni sale a la calle.

- **Pedí acceso** a tu operador logístico o a Alfa Tek. Te dan una llave de prueba para un cliente de pruebas propio: nadie más ve tus guías.
- **Es la misma dirección y el mismo contrato:** `https://api.enrutek.com/v1`, con los mismos campos, errores y límites. Solo cambia la llave.
- **Las guías nacen con el prefijo `SB-`**, así se distinguen de las reales.
- **Nadie las recolecta ni las entrega:** se quedan en `created` salvo que las canceles. Para probar tu receptor de webhooks usá `POST /webhooks/{id}/test`.
- **Para pasar a producción,** tu operador te crea la llave real y la cambiás en tu sistema. No hay que cambiar nada más.

Rigen las mismas reglas de uso responsable que en producción.

## Uso responsable

El API comparte la base de datos con la operación de bodega y de los choferes. Por eso:

- **Crear guías no tiene horario.** Mandalas en el momento en que nacen en tu sistema; si son varias, en un lote con `/shipments/batch`.
- **Para saber el estado, usá webhooks.** EnruTek te avisa cada cambio y no tenés que preguntar.
- **Si no podés recibir webhooks, consultá a lo sumo una vez por hora**, con una sola llamada a `GET /shipments?updated_since=<fecha>` que trae todo lo que cambió desde la última vez.
- **No consultés guía por guía en un ciclo**, ni cada pocos segundos o minutos. Consultar una guía suelta está bien cuando una persona lo pide.

El límite técnico por llave (`RateLimit-Limit`) existe para absorber lotes y reintentos; no es una invitación a consultar todo el tiempo. EnruTek revisa el registro de llamadas de cada llave: un uso que afecte la operación puede llevar a que se baje el límite de la llave o se revoque, con aviso previo salvo que el daño sea inmediato.

## Crear varias guías de una vez

Si un botón o un proceso de tu sistema manda varias guías juntas, usá `POST /shipments/batch`: una sola llamada, hasta 500 guías y una sola `Idempotency-Key` para todo el lote.

```bash
curl -X POST https://api.enrutek.com/v1/shipments/batch \
  -H "Authorization: Bearer $ENRUTEK_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: despacho-2026-11-10-tarde" \
  -d '{ "shipments": [ { "external_reference": "379671", "...": "..." },
                       { "external_reference": "379672", "...": "..." } ] }'
```

Cada guía se procesa por aparte: una inválida no detiene a las demás.

**El lote responde `200` aunque alguna guía haya fallado.** Revisá siempre `failed` y recorré `results`, que viene en el mismo orden en que mandaste las guías:

```json
{
  "created": 1,
  "failed": 1,
  "results": [
    { "index": 0, "ok": true, "shipment": { "tracking_number": "TF-261024ZXWPH2S" } },
    { "index": 1, "ok": false, "error": { "code": "unknown_cargo_type", "message": "…", "field": "packages.0.cargo_type" } }
  ]
}
```

Si `failed` es mayor que cero, corregí esas guías y mandá solamente esas en un lote nuevo, con otra `Idempotency-Key`.

## Reintentar sin duplicar

Las redes fallan. Hay dos protecciones y conviene usar las dos:

| Situación | Qué mandar | Qué pasa |
|---|---|---|
| Se cortó la conexión y no sabés si llegó | Lo mismo, con la **misma** `Idempotency-Key` | Recibís el resultado original y no se crea nada nuevo |
| Alguien vuelve a tocar el botón más tarde | Otra `Idempotency-Key`, la misma `external_reference` | `409 duplicate_reference`, con el número de la guía que ya existe en `details` |
| Misma `Idempotency-Key`, cuerpo distinto | No lo hagás | `409 idempotency_conflict` |

Las `Idempotency-Key` se guardan 24 horas. La `external_reference` es única por cliente entre las guías activas: al cancelar una guía, su referencia se puede volver a usar.

## Recibir webhooks

EnruTek le manda un `POST` a tu URL cada vez que una guía cambia. Para probar tu receptor sin mover ninguna guía, usá `POST /webhooks/{id}/test`: el evento llega con `"test": true`.

Tu receptor tiene que:

1. Validar la firma con el **cuerpo crudo**, tal como llegó, antes de convertirlo a JSON.
2. Rechazar eventos con más de 5 minutos.
3. Responder cualquier `2xx` en menos de 10 segundos y hacer el trabajo pesado después.
4. Descartar repetidos con `event.id`: un evento puede llegar más de una vez.
5. Ordenar con `occurred_at`, porque pueden llegar en desorden.

Si tu sistema no responde `2xx`, EnruTek reintenta con espera creciente hasta por 24 horas. Una suscripción que lleva 24 horas fallando se apaga sola.

La cabecera `EnruTek-Signature` trae `t=<segundos unix>,v1=<firma>`. La firma es HMAC-SHA256 en hexadecimal de `<t>.<cuerpo crudo>`, con el secreto `whsec_…` que recibiste al crear la suscripción.

**Node.js**

```js
import { createHmac, timingSafeEqual } from "node:crypto"

export function firmaValida(cuerpoCrudo, cabecera, secreto) {
  const partes = Object.fromEntries(cabecera.split(",").map((p) => p.split("=")))
  if (!partes.t || !partes.v1) return false
  if (Math.abs(Date.now() / 1000 - Number(partes.t)) > 300) return false
  const esperada = createHmac("sha256", secreto).update(`${partes.t}.${cuerpoCrudo}`).digest()
  const recibida = Buffer.from(partes.v1, "hex")
  return recibida.length === esperada.length && timingSafeEqual(recibida, esperada)
}
```

**Python**

```python
import hashlib, hmac, time

def firma_valida(cuerpo_crudo: bytes, cabecera: str, secreto: str) -> bool:
    partes = dict(p.split("=", 1) for p in cabecera.split(","))
    if "t" not in partes or "v1" not in partes:
        return False
    if abs(time.time() - int(partes["t"])) > 300:
        return False
    mensaje = partes["t"].encode() + b"." + cuerpo_crudo
    esperada = hmac.new(secreto.encode(), mensaje, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, partes["v1"])
```

**PHP**

```php
function firma_valida(string $cuerpoCrudo, string $cabecera, string $secreto): bool {
    parse_str(str_replace(',', '&', $cabecera), $partes);
    if (empty($partes['t']) || empty($partes['v1'])) return false;
    if (abs(time() - (int) $partes['t']) > 300) return false;
    $esperada = hash_hmac('sha256', $partes['t'] . '.' . $cuerpoCrudo, $secreto);
    return hash_equals($esperada, $partes['v1']);
}
```

## Qué hacer con cada error

Todos los errores traen `error.code`, `error.message`, `error.field` y `error.details`. Programá contra `code`, que no cambia; `message` es para mostrárselo a una persona.

| `code` | Qué significa | Qué hacer |
|---|---|---|
| `invalid_request` | El pedido no se pudo procesar | Revisá el JSON y la ruta |
| `invalid_field` | Un campo no es válido | Corregí el campo que indica `field` |
| `invalid_address` | La dirección no coincide con la división oficial | Usá los nombres de `/catalog/admin-areas` o un `postal_code` válido |
| `unknown_cargo_type` | Ese tipo de carga no existe para este cliente | Usá un `code` de `/catalog` |
| `no_rate_for_cargo_type` | Ese tipo de carga no tiene tarifa | Pedile al operador que la configure |
| `customer_required` | Una llave de operador tiene que indicar el cliente | Mandá `customer_id` |
| `customer_not_allowed` | Una llave de cliente no puede mandar `customer_id` | Quitá ese campo |
| `customer_not_found` | Ese cliente no es del operador | Revisá el `customer_id` |
| `branch_not_found` | Esa sucursal no es del cliente | Usá un `id` de las sucursales de `/catalog` |
| `duplicate_reference` | Ya hay una guía activa con esa referencia | No la vuelvas a crear; el número está en `details.tracking_number` |
| `not_found` | No existe o tu llave no la puede ver | Revisá el número de guía y la llave |
| `invalid_status` | El estado de la guía no permite esa operación | Consultá la guía; por ejemplo, solo se cancela en `created` |
| `unauthorized` | Falta la llave, no existe o fue revocada | Revisá la cabecera `Authorization` |
| `forbidden` | Esa llave no puede hacer esto | Hablá con tu operador |
| `module_disabled` | El API no está activo para este operador | Hablá con tu operador |
| `idempotency_conflict` | Esa `Idempotency-Key` se usó con otro cuerpo, o ese pedido sigue en curso | Usá una llave nueva si es otra operación; si es la misma, esperá y reintentá |
| `batch_too_large` | Más de 500 guías, o más de 2 MB | Partí el lote |
| `invalid_webhook_url` | La URL no cumple las reglas | Usá `https` y un nombre de host público |
| `rate_limited` | Pasaste el límite de la llave | Esperá los segundos de `Retry-After` |
| `not_implemented` | Todavía no está disponible | Hoy aplica a la etiqueta: imprimila desde EnruTek |
| `internal_error` | Falló de nuestro lado | Reintentá con la misma `Idempotency-Key`; si sigue, indicá el `request_id` |

## Si sos una IA ayudando a integrar

Estas reglas evitan los errores más comunes:

- Trabajá contra este documento y `https://api.enrutek.com/openapi.json`. No inventés rutas ni campos: los objetos de entrada rechazan los campos desconocidos.
- Probá primero con una llave de Sandbox (guías `SB-`): mismo contrato y misma dirección que producción.
- No le pidás a la persona que pegue su llave en el chat. Usá una variable de entorno o el almacén de secretos de su sistema.
- La llave va solo en el servidor; nunca en código que corra en un navegador o en una app.
- No intentés fijar el precio: lo calcula EnruTek con la tarifa del cliente.
- Antes de crear guías, llamá a `/catalog` para conocer los `cargo_type` y las sucursales reales.
- Con una llave de cliente no mandés `customer_id`; con una de operador es obligatorio al crear.
- Usá `external_reference` con el identificador del sistema del cliente y `Idempotency-Key` en cada `POST`.
- Para los cambios de estado, proponé webhooks. Si no se puede, una sola consulta con `updated_since` a lo sumo cada hora. Nunca escribás un ciclo que consulte guía por guía ni cada pocos segundos o minutos: la llave se puede limitar o revocar.
- Con varias guías a la vez, usá `/shipments/batch` y revisá `failed` y `results`, aunque la respuesta sea `200`.
- La etiqueta por API todavía responde `501`. Por ahora solo se aceptan direcciones de Costa Rica (`country: CR`).
- Los traslados entre sucursales de un mismo cliente no se crean por API.

## Reglas generales

> Los nombres del API (rutas, campos, parámetros, estados y códigos de
> error) van en inglés; las explicaciones, en español.

EnruTek es una plataforma de logística para operadores logísticos (en el
API, *operator*: la empresa que usa EnruTek). Este API le permite a
cualquier sistema externo (un ERP como SAP, una tienda en línea, un sistema
de bodega) crear guías, darles seguimiento y recibir sus cambios de estado.

### Autenticación
Cada solicitud lleva una llave (API key) en la cabecera `Authorization`:

    Authorization: Bearer ek_live_3Fh8...

Las llaves se crean en EnruTek y se muestran una sola vez. Hay dos tipos:

- **Llave de cliente**: pertenece a un cliente del operador. Solo puede
  crear y leer las guías de ese cliente. Nunca se manda `customer_id`.
- **Llave de operador**: pertenece al operador. Puede trabajar para
  cualquiera de los clientes del operador; al crear, `customer_id` es
  obligatorio.

### Sandbox
Para probar sin tocar la operación real hay una empresa de pruebas,
Sandbox: misma dirección, mismo contrato y mismos límites; solo cambia la
llave, que se pide al operador o a Alfa Tek. Sus guías nacen con el
prefijo `SB-`, nadie las recolecta ni las entrega, y para probar los
webhooks está `POST /webhooks/{id}/test`.

### Convenciones
- JSON de entrada y de salida, en UTF-8. Nombres de campos en `snake_case`.
- Fechas y horas en ISO 8601 con desfase (`2026-10-24T14:05:00-06:00`).
- Teléfonos en E.164 (`+50688887777`).
- Montos como `{ "amount": "3000.00", "currency": "CRC" }` (ISO 4217; el
  monto es un texto decimal para evitar redondeos).
- Las direcciones no dependen del país: `country` (ISO 3166-1 alfa-2) y
  hasta tres niveles administrativos. En Costa Rica: `admin_area_1` =
  provincia, `admin_area_2` = cantón, `admin_area_3` = distrito.
- El precio siempre lo calcula EnruTek con la tarifa del cliente; no se
  puede fijar por el API.

### Idempotencia
Mandá una cabecera `Idempotency-Key` (cualquier texto único de hasta 100
caracteres) en las solicitudes `POST`. Si reintentás con la misma llave,
recibís el resultado original en vez de crear una segunda guía. Las llaves
se guardan 24 horas. Además, `external_reference` es única por cliente
entre las guías activas: una segunda guía con la misma referencia se
rechaza con `duplicate_reference` y el número de guía que ya existe.

### Errores
Los errores traen un `code` estable, para máquinas (en inglés), y un
`message` para personas. El mensaje viene en español por defecto; mandá
`Accept-Language: en` para recibirlo en inglés.

### Límites de uso
Cada llave tiene un máximo de solicitudes por minuto (rate limit). Cada
respuesta trae `RateLimit-Limit`, `RateLimit-Remaining` y
`RateLimit-Reset`. Si se pasa del límite, el API responde `429` con
`Retry-After`.

### Uso responsable
El API comparte la base con la operación. Crear guías no tiene horario
(varias juntas, en `/shipments/batch`). Para los cambios de estado usá
webhooks; si no podés recibirlos, consultá a lo sumo una vez por hora con
una sola llamada a `GET /shipments?updated_since=…`. No consultés guía
por guía en un ciclo ni cada pocos segundos o minutos. El límite por
llave existe para lotes y reintentos, no para consultar todo el tiempo:
un uso que afecte la operación puede llevar a que se baje el límite de la
llave o se revoque, con aviso previo salvo que el daño sea inmediato.

### Webhooks
EnruTek puede avisarle a tu sistema cuando una guía cambia de estado (por
webhook). Cada envío es un `POST` con un `WebhookEvent` en JSON y la
cabecera `EnruTek-Signature: t=<segundos unix>,v1=<HMAC-SHA256 en hex>`,
calculada sobre `<t>.<cuerpo tal cual>` con el secreto de la suscripción.
Rechazá los eventos de hace más de 5 minutos. Respondé cualquier `2xx` en
menos de 10 segundos; si no, EnruTek reintenta con espera exponencial
durante hasta 24 horas. Un evento puede llegar más de una vez y en
desorden: usá `event.id` para descartar repetidos y `occurred_at` para
ordenarlos.

## Referencia de rutas

### Guías

Crear, consultar y cancelar guías.

#### GET /shipments

**Listar guías.**

Las más recientes primero, paginadas por cursor. Para sincronizar, usá
`updated_since` con el `updated_at` de la última guía que viste.
Sin webhooks, sincronizá a lo sumo una vez por hora (ver "Uso
responsable").

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |
| `status` | query | string | no | Uno o varios estados, separados por coma. |
| `external_reference` | query | string | no | Tu referencia de la guía (`external_reference`). |
| `customer_id` | query | string (uuid) | no | Solo con llaves de operador. |
| `created_since` | query | string (date-time) | no | Solo guías creadas desde esta fecha y hora. |
| `updated_since` | query | string (date-time) | no | Solo guías que cambiaron desde esta fecha y hora. |
| `limit` | query | integer | no | Cantidad de guías por página. |
| `cursor` | query | string | no | El `next_cursor` de la página anterior. |

Respuestas:

- `200`: Una página de guías (sin `events`; pedí la guía individual para ver su historial).
- `400`: El cuerpo o un parámetro no es válido (`invalid_request`, `invalid_field`).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `429`: Se pasó del límite de uso (`rate_limited`). Ver `Retry-After`.

#### POST /shipments

**Crear una guía.**

Crea una guía. Nace en `created` (un chofer la recoge en la sucursal de
origen). La respuesta trae el número de guía, el precio calculado con
la tarifa y el enlace a la etiqueta 100×100.

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Texto único por operación; un reintento con la misma llave devuelve el resultado original (24 horas). |
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |

Cuerpo (JSON):

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `customer_id` | string (uuid) | no | Obligatorio con llaves de operador; no se permite con llaves de cliente. |
| `origin_branch_id` | string (uuid) | no | Sucursal del cliente donde se recoge. Por defecto, la sucursal principal. |
| `external_reference` | string | no | Tu identificador de esta guía (factura, número de entrega). Única por cliente entre las guías activas. Si el cliente escanea sus propias etiquetas, es el código de barras. |
| `recipient` | object | sí |  |
| `recipient.name` | string | sí | Nombre del destinatario. |
| `recipient.phone` | string | no | E.164. |
| `recipient.email` | string (email) | no | Para avisarle de la entrega al destinatario. |
| `destination` | object | sí | Los niveles administrativos se comparan sin tildes ni mayúsculas contra la lista oficial (`/catalog/admin-areas`). En Costa Rica, `admin_area_1` y `admin_area_2` son obligatorios salvo que `postal_code` los resuelva; `admin_area_3` es opcional. |
| `destination.country` | string | sí | ISO 3166-1 alfa-2. Por ahora solo CR. |
| `destination.admin_area_1` | string | no | CR: provincia. |
| `destination.admin_area_2` | string | no | CR: cantón. |
| `destination.admin_area_3` | string | no | CR: distrito. |
| `destination.postal_code` | string | no | CR: 5 dígitos; si viene y es válido, llena los niveles administrativos. |
| `destination.address_line` | string | sí | Otras señas. |
| `destination.lat` | number | no | Latitud del punto exacto (opcional). |
| `destination.lng` | number | no | Longitud del punto exacto (opcional). |
| `notes` | string | no | Indicaciones para el chofer (horario, por quién preguntar). El chofer las ve. |
| `packages` | lista de object | sí | Los bultos, agrupados por tipo de carga. |
| `packages[].cargo_type` | string | sí | Un `code` de `/catalog` (p. ej. `seco`, `frio`). |
| `packages[].quantity` | integer | sí | Cantidad de bultos de este tipo de carga. |

Ejemplo:

```json
{
  "external_reference": "379671",
  "recipient": {
    "name": "Farmacia Plaza Flamingo",
    "phone": "+50626545524"
  },
  "destination": {
    "country": "CR",
    "admin_area_1": "Guanacaste",
    "admin_area_2": "Santa Cruz",
    "admin_area_3": "Tempate",
    "postal_code": "50304",
    "address_line": "Playa Potrero, 200 metros sur del Hotel Bahía Esmeralda"
  },
  "notes": "Entregar en horario de oficina",
  "packages": [
    {
      "cargo_type": "seco",
      "quantity": 1
    }
  ]
}
```

Respuestas:

- `200`: Misma `Idempotency-Key` que una solicitud anterior; se devuelve la guía original.
- `201`: Creada.
- `400`: El cuerpo o un parámetro no es válido (`invalid_request`, `invalid_field`).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `403`: La llave no puede hacer esto (`forbidden`, `customer_not_allowed`, `module_disabled`).
- `409`: `duplicate_reference`, `invalid_status` o `idempotency_conflict` (misma llave, cuerpo distinto).
- `422`: JSON válido que EnruTek no puede usar (`invalid_address`, `unknown_cargo_type`, `no_rate_for_cargo_type`, `branch_not_found`, `invalid_webhook_url`).
- `429`: Se pasó del límite de uso (`rate_limited`). Ver `Retry-After`.

#### POST /shipments/batch

**Crear hasta 500 guías.**

Cada elemento se procesa por aparte: uno inválido no detiene a los
demás. La respuesta trae un resultado por elemento, en el mismo orden.
Usá una `Idempotency-Key` para todo el lote; al reintentar recibís los
mismos resultados.

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | no | Texto único por operación; un reintento con la misma llave devuelve el resultado original (24 horas). |
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |

Cuerpo (JSON):

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `shipments` | lista de object | sí |  |
| `shipments[].customer_id` | string (uuid) | no | Obligatorio con llaves de operador; no se permite con llaves de cliente. |
| `shipments[].origin_branch_id` | string (uuid) | no | Sucursal del cliente donde se recoge. Por defecto, la sucursal principal. |
| `shipments[].external_reference` | string | no | Tu identificador de esta guía (factura, número de entrega). Única por cliente entre las guías activas. Si el cliente escanea sus propias etiquetas, es el código de barras. |
| `shipments[].recipient` | object | sí |  |
| `shipments[].recipient.name` | string | sí | Nombre del destinatario. |
| `shipments[].recipient.phone` | string | no | E.164. |
| `shipments[].recipient.email` | string (email) | no | Para avisarle de la entrega al destinatario. |
| `shipments[].destination` | object | sí | Los niveles administrativos se comparan sin tildes ni mayúsculas contra la lista oficial (`/catalog/admin-areas`). En Costa Rica, `admin_area_1` y `admin_area_2` son obligatorios salvo que `postal_code` los resuelva; `admin_area_3` es opcional. |
| `shipments[].destination.country` | string | sí | ISO 3166-1 alfa-2. Por ahora solo CR. |
| `shipments[].destination.admin_area_1` | string | no | CR: provincia. |
| `shipments[].destination.admin_area_2` | string | no | CR: cantón. |
| `shipments[].destination.admin_area_3` | string | no | CR: distrito. |
| `shipments[].destination.postal_code` | string | no | CR: 5 dígitos; si viene y es válido, llena los niveles administrativos. |
| `shipments[].destination.address_line` | string | sí | Otras señas. |
| `shipments[].destination.lat` | number | no | Latitud del punto exacto (opcional). |
| `shipments[].destination.lng` | number | no | Longitud del punto exacto (opcional). |
| `shipments[].notes` | string | no | Indicaciones para el chofer (horario, por quién preguntar). El chofer las ve. |
| `shipments[].packages` | lista de object | sí | Los bultos, agrupados por tipo de carga. |
| `shipments[].packages[].cargo_type` | string | sí | Un `code` de `/catalog` (p. ej. `seco`, `frio`). |
| `shipments[].packages[].quantity` | integer | sí | Cantidad de bultos de este tipo de carga. |

Respuestas:

- `200`: Un resultado por elemento.
- `400`: El cuerpo o un parámetro no es válido (`invalid_request`, `invalid_field`).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `413`: Más de 500 elementos.
- `429`: Se pasó del límite de uso (`rate_limited`). Ver `Retry-After`.

#### GET /shipments/{tracking_number}

**Consultar una guía con su historial.**

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `tracking_number` | path | string | sí | El número de guía de EnruTek, p. ej. `TF-261024ZXWPH2S`. |
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |

Respuestas:

- `200`: La guía, con `events` y, una vez entregada, `proof_of_delivery` (prueba de entrega).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `404`: No existe, o esta llave no lo puede ver (`not_found`).
- `429`: Se pasó del límite de uso (`rate_limited`). Ver `Retry-After`.

#### POST /shipments/{tracking_number}/cancel

**Cancelar una guía.**

Solo mientras esté en `created` (nadie la ha recogido).

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `tracking_number` | path | string | sí | El número de guía de EnruTek, p. ej. `TF-261024ZXWPH2S`. |
| `Idempotency-Key` | header | string | no | Texto único por operación; un reintento con la misma llave devuelve el resultado original (24 horas). |
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |

Cuerpo (JSON):

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `reason` | string | no | Motivo de la cancelación. |

Respuestas:

- `200`: Cancelada (o ya estaba cancelada).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `404`: No existe, o esta llave no lo puede ver (`not_found`).
- `409`: `duplicate_reference`, `invalid_status` o `idempotency_conflict` (misma llave, cuerpo distinto).
- `429`: Se pasó del límite de uso (`rate_limited`). Ver `Retry-After`.

#### GET /shipments/{tracking_number}/label

**Obtener la etiqueta 100×100.**

Una página por bulto. Por ahora en PDF; más adelante, ZPL para
impresoras térmicas. Todavía no está disponible: responde
`501 not_implemented` (después de revisar que la llave puede ver la
guía). Mientras tanto, imprimí las etiquetas desde EnruTek.

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `tracking_number` | path | string | sí | El número de guía de EnruTek, p. ej. `TF-261024ZXWPH2S`. |
| `format` | query | `pdf` | no | Formato de la etiqueta. |

Respuestas:

- `200`: La etiqueta.
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `404`: No existe, o esta llave no lo puede ver (`not_found`).
- `501`: Todavía no está disponible (`not_implemented`).

### Catálogo

Tipos de carga, sucursales y división territorial que puede usar una guía.

#### GET /catalog

**Lo que puede usar una guía.**

Los tipos de carga con el precio del cliente y las sucursales del
cliente (orígenes). Con llaves de operador, `customer_id` es
obligatorio.

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `customer_id` | query | string (uuid) | no | Solo con llaves de operador (y ahí es obligatorio). |
| `Accept-Language` | header | `es`, `en` | no | Idioma de los mensajes de error y de los nombres de estado. `es` (por defecto) o `en`. |

Respuestas:

- `200`: El catálogo.
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).

#### GET /catalog/admin-areas

**División territorial de un país.**

Los valores válidos de `admin_area_1..3`, en forma de árbol.

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `country` | query | string | sí | País, en ISO 3166-1 alfa-2. |

Respuestas:

- `200`: El árbol.

### Webhooks

Suscripciones para recibir los cambios de estado de las guías.

#### GET /webhooks

**Listar las suscripciones de webhook de esta llave.**

Respuestas:

- `200`: Las suscripciones (sin el secreto).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).

#### POST /webhooks

**Suscribir una URL a los eventos de las guías.**

El `secret` se devuelve solo en esta respuesta.

**Alcance.** Una llave de cliente recibe solo los eventos de sus propias
guías. Una llave de operador puede indicar `customer_id` para recibir
solo los eventos de ese cliente; sin él, recibe los eventos de todas
las guías del operador. `customer_id` tiene que ser un cliente del
operador de la llave (si no, `422 customer_not_found`), y si una llave
de cliente lo manda se rechaza (`403 customer_not_allowed`): nunca
amplía lo que una llave puede ver. El alcance no se puede cambiar
después.

**Reglas de la URL** (se revisan al crear y otra vez en cada envío):
solo `https`, sin usuario ni contraseña en la URL, un nombre de host
(no una IP literal) que resuelva solo a direcciones públicas (se
rechazan loopback, privadas RFC 1918, link-local 169.254.0.0/16, CGNAT
100.64.0.0/10, loopback/ULA/link-local de IPv6 y las direcciones de
metadatos de la nube). No se siguen redirecciones. Una URL que no
cumple estas reglas se rechaza con `invalid_webhook_url`.

Cuerpo (JSON):

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `url` | string (uri) | sí | Ver las reglas de la URL arriba. |
| `customer_id` | string (uuid) | no | Solo con llaves de operador: limita la suscripción a un cliente. |
| `events` | lista de `shipment.created`, `shipment.picked_up`, `shipment.received_at_cedi`, `shipment.assigned`, `shipment.out_for_delivery`, `shipment.kept_on_route`, `shipment.back_to_cedi`, `shipment.delivered`, `shipment.rejected`, `shipment.cancelled` | sí |  |
| `description` | string | no |  |

Respuestas:

- `201`: Creada.
- `400`: El cuerpo o un parámetro no es válido (`invalid_request`, `invalid_field`).
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `403`: La llave no puede hacer esto (`forbidden`, `customer_not_allowed`, `module_disabled`).
- `422`: JSON válido que EnruTek no puede usar (`invalid_address`, `unknown_cargo_type`, `no_rate_for_cargo_type`, `branch_not_found`, `invalid_webhook_url`).

#### DELETE /webhooks/{id}

**Detener una suscripción.**

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `id` | path | string (uuid) | sí |  |

Respuestas:

- `204`: Detenida.
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `404`: No existe, o esta llave no lo puede ver (`not_found`).

#### POST /webhooks/{id}/test

**Enviar un evento de prueba.**

Parámetros:

| Nombre | Dónde | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `id` | path | string (uuid) | sí |  |

Respuestas:

- `202`: En cola.
- `401`: Falta la llave, no existe o fue revocada (`unauthorized`).
- `404`: No existe, o esta llave no lo puede ver (`not_found`).

## Evento de webhook

Lo que EnruTek le manda a la URL suscrita.

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `id` | string (uuid) | sí | Único por evento; usalo para descartar repetidos. |
| `type` | `shipment.created`, `shipment.picked_up`, `shipment.received_at_cedi`, `shipment.assigned`, `shipment.out_for_delivery`, `shipment.kept_on_route`, `shipment.back_to_cedi`, `shipment.delivered`, `shipment.rejected`, `shipment.cancelled` | sí |  |
| `occurred_at` | string (date-time) | sí |  |
| `shipment` | object | sí | La guía como estaba en el momento del evento. `proof_of_delivery` viene sin `photo_url` ni `signature_url` (los enlaces de corta vida no deben quedar en bitácoras durante los reintentos): pedí la guía para obtenerlos. |
| `shipment.id` | string (uuid) | sí |  |
| `shipment.tracking_number` | string | sí |  |
| `shipment.tracking_url` | string (uri) | no | Página pública de rastreo. |
| `shipment.label_url` | string (uri) | no | `/shipments/{tracking_number}/label`. |
| `shipment.status` | `created`, `at_reception`, `picked_up`, `received_at_cedi`, `loaded`, `out_for_delivery`, `kept_on_route`, `back_to_cedi`, `delivered`, `rejected`, `cancelled` | sí | Dónde está físicamente el bulto. Finales: `delivered`, `rejected`, `cancelled`. |
| `shipment.status_label` | string | sí | Nombre del estado para personas, en el idioma pedido; p. ej. "En CEDI". |
| `shipment.external_reference` | string | no |  |
| `shipment.customer` | object | sí |  |
| `shipment.customer.id` | string (uuid) | sí |  |
| `shipment.customer.name` | string | sí |  |
| `shipment.origin` | object | sí |  |
| `shipment.origin.branch_id` | string (uuid) | no |  |
| `shipment.origin.name` | string | no |  |
| `shipment.origin.address` | object | no | Los niveles administrativos se comparan sin tildes ni mayúsculas contra la lista oficial (`/catalog/admin-areas`). En Costa Rica, `admin_area_1` y `admin_area_2` son obligatorios salvo que `postal_code` los resuelva; `admin_area_3` es opcional. |
| `shipment.origin.address.country` | string | sí | ISO 3166-1 alfa-2. Por ahora solo CR. |
| `shipment.origin.address.admin_area_1` | string | no | CR: provincia. |
| `shipment.origin.address.admin_area_2` | string | no | CR: cantón. |
| `shipment.origin.address.admin_area_3` | string | no | CR: distrito. |
| `shipment.origin.address.postal_code` | string | no | CR: 5 dígitos; si viene y es válido, llena los niveles administrativos. |
| `shipment.origin.address.address_line` | string | sí | Otras señas. |
| `shipment.origin.address.lat` | number | no | Latitud del punto exacto (opcional). |
| `shipment.origin.address.lng` | number | no | Longitud del punto exacto (opcional). |
| `shipment.recipient` | object | sí |  |
| `shipment.recipient.name` | string | sí | Nombre del destinatario. |
| `shipment.recipient.phone` | string | no | E.164. |
| `shipment.recipient.email` | string (email) | no | Para avisarle de la entrega al destinatario. |
| `shipment.destination` | object | sí | Los niveles administrativos se comparan sin tildes ni mayúsculas contra la lista oficial (`/catalog/admin-areas`). En Costa Rica, `admin_area_1` y `admin_area_2` son obligatorios salvo que `postal_code` los resuelva; `admin_area_3` es opcional. |
| `shipment.destination.country` | string | sí | ISO 3166-1 alfa-2. Por ahora solo CR. |
| `shipment.destination.admin_area_1` | string | no | CR: provincia. |
| `shipment.destination.admin_area_2` | string | no | CR: cantón. |
| `shipment.destination.admin_area_3` | string | no | CR: distrito. |
| `shipment.destination.postal_code` | string | no | CR: 5 dígitos; si viene y es válido, llena los niveles administrativos. |
| `shipment.destination.address_line` | string | sí | Otras señas. |
| `shipment.destination.lat` | number | no | Latitud del punto exacto (opcional). |
| `shipment.destination.lng` | number | no | Longitud del punto exacto (opcional). |
| `shipment.notes` | string | no |  |
| `shipment.packages` | lista de object | sí |  |
| `shipment.packages[].cargo_type` | string | sí |  |
| `shipment.packages[].cargo_type_name` | string | no |  |
| `shipment.packages[].quantity` | integer | sí |  |
| `shipment.packages[].unit_price` | object | sí |  |
| `shipment.packages[].unit_price.amount` | string | sí |  |
| `shipment.packages[].unit_price.currency` | string | sí |  |
| `shipment.total` | object | sí | Total con IVA, tal como se va a facturar. |
| `shipment.total.amount` | string | sí |  |
| `shipment.total.currency` | string | sí |  |
| `shipment.scheduled` | object | no | El despacho al que está asignada, si lo hay. |
| `shipment.scheduled.date` | string (date) | no |  |
| `shipment.scheduled.route_code` | string | no |  |
| `shipment.proof_of_delivery` | object | no |  |
| `shipment.proof_of_delivery.delivered_at` | string (date-time) | no |  |
| `shipment.proof_of_delivery.received_by` | string | no |  |
| `shipment.proof_of_delivery.lat` | number | no |  |
| `shipment.proof_of_delivery.lng` | number | no |  |
| `shipment.events` | lista de object | no | Historial, de lo más viejo a lo más nuevo. Solo en `GET /shipments/{tracking_number}` y en los webhooks. |
| `shipment.events[].type` | `created`, `edited`, `picked_up`, `at_reception`, `received_at_cedi`, `assigned`, `unassigned`, `loaded`, `out_for_delivery`, `kept_on_route`, `back_to_cedi`, `delivered`, `rejected`, `cancelled` | sí |  |
| `shipment.events[].label` | string | no |  |
| `shipment.events[].occurred_at` | string (date-time) | sí |  |
| `shipment.events[].facility` | string | no | Nombre del CEDI (centro de distribución), p. ej. "GAM". |
| `shipment.events[].comment` | string | no |  |
| `shipment.created_at` | string (date-time) | sí |  |
| `shipment.updated_at` | string (date-time) | sí |  |
