# Almacenaje y salida

La API de almacenaje y salida permite que una cuenta de cliente de una empresa de almacenes ingrese mercancías en almacenaje, pague el periodo de almacenaje y, más adelante, envíe los paquetes almacenados a sus propios compradores. Está pensada para comerciantes y plataformas que mantienen inventario en un almacén de terceros (3PL) y necesitan automatizar desde sus propios sistemas la reserva de almacenaje, la consulta de existencias y los envíos de salida. Todas las llamadas se ejecutan como la cuenta de cliente, nunca como la empresa de almacenes.

## 1. Qué puede construir

Los ejemplos de esta guía siguen un mismo escenario. **Northwind Outdoor**, un vendedor en línea de temporada de equipamiento de invierno, almacena sus existencias de invierno en el **Toronto Hub** (almacén `7`) de su 3PL del 1 de noviembre de 2026 al 31 de marzo de 2027. Cuando un comprador pide una caja de chaquetas aislantes, Northwind envía esa caja desde las existencias al comprador en Ottawa.

- **Reserva de almacenaje de temporada.** El sistema administrativo del vendedor cotiza y reserva un periodo de almacenaje para cada caja entrante antes de que la mercancía salga del proveedor, y paga la tarifa de almacenaje con el saldo de su cuenta.
- **Consulta de existencias en tiempo real.** La tienda o el ERP del vendedor lista los paquetes que el almacén ha recibido realmente y que siguen disponibles para enviar, de modo que solo se ofrecen existencias reales para el cumplimiento de pedidos.
- **Cumplimiento de pedidos desde existencias.** Cuando un comprador hace un pedido, el sistema del vendedor calcula el precio del envío de salida, crea una solicitud de salida para los paquetes almacenados, la paga y registra el número de seguimiento para el comprador.
- **Seguimiento del estado y corrección.** El sistema del vendedor lee el estado de cada pedido de almacenaje y de cada salida, sigue el envío mediante el seguimiento público y cancela una salida que ya no se necesita mientras todavía esté permitido.

## 2. Qué cubre esta guía

Utilice esta guía cuando la mercancía ya esté, o vaya a estar, almacenada en el almacén de la empresa y el envío parta de esas existencias. El flujo es: iniciar sesión → leer la configuración de almacenaje → cotizar el almacenaje → crear el pedido de almacenaje → pagar → listar los paquetes en existencias → listar los servicios y estimar la salida → crear la salida → pagar → consultar y seguir → webhooks → cancelar.

Otras guías corresponden a otros casos:

- **Uniorder: una API para cada envío** — el punto de entrada único recomendado (`/api/v1/uniorder/...`) para nuevas integraciones que reservan entregas locales o etiquetas de transportista. Uniorder **no** cubre el almacenaje ni la salida; los pedidos de almacenaje y las salidas se crean únicamente mediante los endpoints de cliente de esta guía.
- **Servicios de envío** — un cliente envía mercancías que no están almacenadas, utilizando los servicios de envío de la empresa.
- **Etiquetas de transportista** — una empresa compra etiquetas de transportista directamente para sus propios paquetes.
- **Recogida y entrega (flota propia)** — una empresa reserva recogidas y entregas con su propia flota.

## 3. Antes de empezar

- **Tipo de cuenta.** Una cuenta de **cliente** de la empresa de almacenes (la empresa que opera el almacén es el proveedor del servicio). Un token de cuenta de empresa (client) no funciona en los endpoints `/api/v1/customer/...`.
- **Permisos.** La cuenta de cliente necesita acceso a la API. Los endpoints de almacenaje requieren además la capacidad de almacenaje; los endpoints de salida requieren que la empresa haya activado la salida (o la consolidación) para este cliente; de lo contrario, responden `403`.
- **Saldo.** Los pagos de almacenaje y de salida se descuentan del saldo de la cuenta del cliente. Para una prueba, pida a la empresa que acredite saldo al cliente de prueba.
- **Datos de prueba.** Un `id` de almacén, al menos un `id` de embalaje si no se permiten paquetes personalizados, y al menos un servicio de envío activo disponible desde ese almacén. La salida solo funciona después de que el almacén haya **recibido** los paquetes almacenados; en una prueba, pida al personal del almacén que reciba el pedido de almacenaje de prueba.
- **Gestión del token.** Inicie sesión desde su servidor, conserve el token en el servidor y nunca lo incluya en código de navegador o de aplicación móvil.
- **Marcadores de posición.** Sustituya `YOUR_HOST` por el host de su plataforma y `ACCESS_TOKEN` por el token del paso 4.

## 4. Iniciar sesión como cliente

Cada llamada posterior se autoriza con un token Bearer de cliente. Su integración inicia sesión una vez, guarda el token en el servidor y lo renueva antes de `expires_at`.

**REST:** `POST /api/v1/user/customer/login` — [Manual REST](/api/documentation#/paths/v1-user-customer-login/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/user/customer/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2027-09-28 10:15:00",
  "expires_timestamp": 1822040100,
  "name": "Northwind Outdoor"
}
```

- `access_token` — envíelo en cada solicitud con la cabecera siguiente.
- `expires_at` / `expires_timestamp` — vuelva a iniciar sesión antes de este momento.

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL usa la misma cabecera en `POST /api/graphql`.

**Verificación:** el login devuelve `access_token`. Las solicitudes posteriores sin este token devuelven `401`.

## 5. Leer la configuración de almacenaje

El paquete de configuración lista los almacenes que el cliente puede utilizar, el catálogo de embalajes, las unidades y los recargos. Su integración lo lee una vez por sesión para elegir el almacén y construir líneas de paquete válidas.

**REST:** `GET /api/v1/customer/storage-orders/config` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/config \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [
      { "id": 7, "name": "Toronto Hub", "address": "10 Main St", "city": "Toronto", "province": "ON", "postcode": "M5V 2T6" }
    ],
    "packagings": [
      { "id": 1, "name": "Large Carton", "type": "Box", "length": 60, "width": 40, "height": 40, "dimension_unit": 2 }
    ],
    "dimension_units": { "1": { "name": "dimension_mm" }, "2": { "name": "dimension_cm" }, "3": { "name": "dimension_m" }, "4": { "name": "dimension_inch" } },
    "weight_units": { "1": { "name": "weight_g" }, "2": { "name": "weight_kg" }, "3": { "name": "weight_oz" }, "4": { "name": "weight_lb" } },
    "allow_custom_package": true,
    "surcharges": [],
    "form_bindings": []
  }
}
```

- `warehouses[].id` — el `warehouse_id` de todas las llamadas posteriores.
- `allow_custom_package` — cuando es `false`, cada artículo de almacenaje debe llevar un `packaging_id` de `packagings[]`; cuando es `true`, los artículos pueden describirse solo con dimensiones.
- `dimension_units` / `weight_units` — los códigos enteros utilizados en las líneas de paquete (`2` = cm, `2` = kg).
- `form_bindings` — formularios que la empresa exige en un pedido de almacenaje; envíe sus respuestas como `form_data` en el paso 7.

**GraphQL:** `customerStorageOrderConfig` ([Manual GraphQL](/api/graphql/documentation#/customer/customerStorageOrderConfig))

```graphql
query {
  customerStorageOrderConfig
}
```

**Verificación:** ha capturado un `id` de almacén y, si el catálogo no está vacío, un `id` de embalaje.

## 6. Cotizar el periodo de almacenaje

La cotización calcula el precio del periodo de almacenaje para los paquetes previstos antes de reservar nada. Su integración muestra o comprueba este precio y después crea el pedido con los mismos datos.

**REST:** `POST /api/v1/customer/storage-orders/calculate-price` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-calculate-price/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/calculate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "items": [{
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2
    }]
  }'
```

```json
{
  "success": true,
  "price": {
    "total_price": "186.45",
    "currency": "CAD",
    "shipping_price": 186.45
  }
}
```

- `success` — `true` cuando se ha calculado un precio.
- `price.total_price` / `price.currency` — el precio de almacenaje del periodo, impuestos incluidos.
- `promotion` — presente solo cuando se aplica una promoción.

**Verificación:** `success` o `result` es true y tiene un precio. La falta de `warehouse_id` / fechas devuelve `400`.

## 7. Crear el pedido de almacenaje

El pedido de almacenaje anuncia al almacén los paquetes entrantes y fija el periodo de almacenaje. Su integración guarda el id devuelto; se necesita para pagar, consultar el pedido y cancelarlo.

**REST:** `POST /api/v1/customer/storage-orders` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-winter-2026-po-4471" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "notes": "Winter 2026 stock, PO 4471",
    "items": [{
      "description": "Insulated jackets, carton of 12",
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2,
      "value": 1800
    }]
  }'
```

```json
{
  "result": true,
  "message": "Storage order created",
  "data": { "id": 1024, "status": "pending payment" }
}
```

- `data.id` — el id del pedido de almacenaje. Guárdelo junto con su orden de compra.
- `data.status` — `pending payment` hasta que se pague el pedido.
- `Idempotency-Key` — derívelo de su propio id estable. Una clave repetida con el mismo cuerpo reproduce la primera respuesta (`replayed: true`); la misma clave con un cuerpo distinto se rechaza con `409 IDEMPOTENCY_CONFLICT`.
- Campos obligatorios: `warehouse_id`, `start_date`, `end_date` (posterior a `start_date`) y `items[]` con `qty`, `length`, `width`, `height`, `dimension_unit`. Añada `items[].packaging_id` cuando `allow_custom_package` sea `false`.

**Verificación:** la respuesta tiene `data.id`. Guarde ese id de pedido de almacenaje.

## 8. Pagar el almacenaje

El pago confirma el pedido de almacenaje. Su integración puede leer primero el importe pendiente y después paga con el saldo del cliente.

**REST:** `GET /api/v1/customer/storage-orders/{id}/payment-info` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-id--payment-info/get) (opcional)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "success": true,
  "result": true,
  "data": {
    "order_id": 1024,
    "currency": "CAD",
    "total_price": "186.45",
    "paid_amount": "0.00",
    "remaining_balance": "186.45",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "minimum_payment": "186.45"
  }
}
```

**REST:** `POST /api/v1/customer/storage-orders/{id}/pay` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/1024/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"payment_type": "full_balance"}'
```

```json
{
  "success": true,
  "result": true,
  "message": "Payment of $186.45 processed successfully. Your storage order is now confirmed.",
  "new_balance": 313.55,
  "paid_amount": 186.45,
  "charge_amount": 186.45,
  "is_fully_paid": true
}
```

- `payment_type` — `full_balance` (valor predeterminado, paga el importe restante), `minimum_payment` (paga el mínimo que exige la empresa) o `custom` junto con `custom_amount`.
- `is_fully_paid` — `true` cuando no queda nada por pagar.
- Un saldo insuficiente responde `400` con `customer_balance`; recargue el saldo y vuelva a intentarlo.

Consulte el pedido para confirmar su estado y, más adelante, qué paquetes ha recibido el almacén.

**REST:** `GET /api/v1/customer/storage-orders/{id}` — [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-id/get)

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "id": 1024,
    "status": "confirmed",
    "can_cancel": true,
    "store_from": "2026-11-01",
    "store_to": "2027-03-31",
    "warehouse_id": 7,
    "packages": [
      { "id": 5001, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false },
      { "id": 5002, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false }
    ],
    "total_price": 186.45,
    "currency": "CAD"
  }
}
```

- `status` — `confirmed` después del pago; más tarde `partial received` / `storage in progress` a medida que llega la mercancía.
- `packages[].received` — `true` cuando el almacén ha recibido ese paquete.
- `can_cancel` — indica si el pedido de almacenaje todavía puede cancelarse.

**GraphQL:** `customerStorageOrderShow` ([Manual GraphQL](/api/graphql/documentation#/customer/customerStorageOrderShow)); la lista de todos los pedidos de almacenaje es `customerStorageOrders` ([Manual GraphQL](/api/graphql/documentation#/customer/customerStorageOrders)).

```graphql
query {
  customerStorageOrderShow(id: 1024) {
    result
    data {
      id
      status
      can_cancel
      packages { id description received }
    }
  }
}
```

**Verificación:** el pedido de almacenaje está pagado / confirmado. Un `400` con `customer_balance` significa que debe recargar el saldo y volver a intentarlo.

La salida descrita a continuación solo funciona después de que los paquetes se hayan **recibido** en el almacén. En una prueba, espere hasta que el personal (o una recepción de prueba) los haya marcado como recibidos y entonces continúe.

## 9. Listar los artículos aún en existencias

Esta lista son las existencias que su integración puede enviar. Contiene solo los paquetes que el almacén ha recibido y que no están ya bloqueados en otra salida.

**REST:** `GET /api/v1/customer/shipout-orders/available-items` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-available-items/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/available-items?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [{ "id": 7, "name": "Toronto Hub", "available_count": 2 }],
    "storage_orders": [{
      "id": 1024,
      "warehouse_id": 7,
      "packages": [
        { "id": 5001, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 },
        { "id": 5002, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 }
      ]
    }]
  }
}
```

- `storage_orders[].packages[].id` — los `storage_package_ids` que se enviarán en el paso 10.
- `warehouses[].available_count` — el número de paquetes disponibles por almacén.

**GraphQL:** `customerShipoutAvailableItems` ([Manual GraphQL](/api/graphql/documentation#/storage-shipout/customerShipoutAvailableItems))

```graphql
query {
  customerShipoutAvailableItems(warehouse_id: 7) {
    result
    data {
      warehouses { id name available_count }
      storage_orders { id warehouse_id packages { id description weight length width height } }
    }
  }
}
```

**Verificación:** ha capturado uno o más `storage_package_ids` (ejemplo `5001`). Una lista vacía significa que aún no se ha recibido nada — no cree una salida. `403` significa que la salida está desactivada para este cliente.

## 10. Estimar y crear la salida

Una salida se tarifica con un servicio de envío de la empresa. Su integración lista los servicios disponibles desde el almacén, estima el precio para el destino del comprador y después crea la salida para los paquetes seleccionados.

**REST:** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-services/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/services?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "services": [
      { "id": 5, "service_code": "intl_express", "name": { "en": "Express" }, "pricing_method": 1, "pricing_method_name": "Shipping Price Plan", "support_multi_package": true }
    ]
  }
}
```

Capture un `service_code`.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/estimate` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-services-serviceCode--estimate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/estimate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "delivery_postcode": "K2P1L4",
    "delivery_country": "CA",
    "packages": [{
      "weight": 14,
      "length": 60,
      "width": 40,
      "height": 40,
      "weight_unit": 2,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "shipping_fee": 38.5,
    "fuel_surcharge": 4.2,
    "handling_fee": 0,
    "sub_total": 42.7,
    "tax": 5.55,
    "total": 48.25,
    "currency": "CAD",
    "has_items_needing_quote": false,
    "refused": false
  }
}
```

- `total` / `currency` — el precio estimado para este destino.
- `has_items_needing_quote` — `true` cuando el servicio se tarifica manualmente; el almacén fija el precio después de crear la salida y el pago espera a ese precio.
- `refused` / `refusal_message` — el servicio no aceptará este envío porque no puede tarificarlo.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/orders` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-services-serviceCode--orders/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-order-NW-20931" \
  -d '{
    "warehouse_id": 7,
    "storage_package_ids": [5001],
    "delivery_name": "Maya Chen",
    "delivery_telephone": "6135550142",
    "delivery_email": "maya.chen@example.com",
    "delivery_address_1": "150 Elgin St",
    "delivery_city": "Ottawa",
    "delivery_province": "ON",
    "delivery_country": "CA",
    "delivery_postcode": "K2P1L4",
    "note": "Web order NW-20931"
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 0,
    "is_storage_shipout": true,
    "total_price": "48.25",
    "price_breakdown": { "total": 48.25, "has_items_needing_quote": false },
    "has_items_needing_quote": false,
    "storage_package_ids": [5001]
  }
}
```

- `data.id` — el id de la salida. Guárdelo junto con el pedido del comprador.
- `data.status` — `0` = pendiente (en espera de pago), `1` = confirmada, `2` = en tránsito, `3` = enviada, `4` = cancelada, `5` = fallida.
- `storage_package_ids` — estos paquetes quedan bloqueados en esta salida y ya no aparecen en el paso 9.
- Campos obligatorios: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Todos los paquetes deben proceder del mismo almacén.

**GraphQL:** `customerCreateShipoutOrder` ([Manual GraphQL](/api/graphql/documentation#/customer/customerCreateShipoutOrder))

```graphql
mutation {
  customerCreateShipoutOrder(
    service_code: "intl_express"
    warehouse_id: 7
    storage_package_ids: [5001]
    delivery_name: "Maya Chen"
    delivery_telephone: "6135550142"
    delivery_email: "maya.chen@example.com"
    delivery_address_1: "150 Elgin St"
    delivery_city: "Ottawa"
    delivery_province: "ON"
    delivery_country: "CA"
    delivery_postcode: "K2P1L4"
    note: "Web order NW-20931"
  ) {
    result
    message
    data { id status total_price has_items_needing_quote storage_package_ids }
  }
}
```

**Verificación:** la respuesta tiene un `id` de salida. Los paquetes de almacenaje seleccionados quedan bloqueados en esta solicitud.

## 11. Pagar la salida

El almacén procesa una salida una vez pagada. Su integración paga el importe restante con el saldo de la cuenta del cliente; omita `amount` para pagarlo en su totalidad.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/pay` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "result": true,
  "data": {
    "transaction_id": 9001,
    "amount": "48.25",
    "order_status": 1,
    "paid_amount": 48.25,
    "remaining_balance": 0
  }
}
```

- `amount` (solicitud, opcional) — un importe parcial; de forma predeterminada, el importe restante completo.
- `order_status` — `1` (confirmada) después del pago completo.
- `remaining_balance` — `0` cuando está pagada por completo.

**GraphQL:** `customerPayShipout` ([Manual GraphQL](/api/graphql/documentation#/storage-shipout/customerPayShipout))

```graphql
mutation {
  customerPayShipout(id: 8001) {
    result
    message
    data { transaction_id amount order_status paid_amount remaining_balance }
  }
}
```

**Verificación:** el pago registra un importe (o `402` / `422` con un motivo claro). `402` significa que el saldo es insuficiente; `422` significa que el pedido todavía no se puede pagar (por ejemplo, sigue esperando una cotización manual) o que el importe no es válido.

## 12. Consultar y seguir la salida

Su integración consulta la salida para conocer su estado y, una vez que el almacén la ha enviado, sigue el envío mediante su número de seguimiento.

**REST:** `GET /api/v1/customer/shipout-orders/{id}` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-id/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipout-orders/8001 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 3,
    "status_label": "Shipped",
    "warehouse": { "id": 7, "name": "Toronto Hub" },
    "shipping_service": { "id": 5, "service_code": "intl_express" },
    "total_price": "48.25",
    "paid_amount": 48.25,
    "remaining_balance": 0,
    "can_be_paid": false,
    "can_be_cancelled": false,
    "storage_packages": [{ "id": 5001, "storage_order_id": 1024, "description": "Insulated jackets, carton of 12" }]
  }
}
```

- `status` / `status_label` — el estado actual de la salida.
- `can_be_paid` / `can_be_cancelled` — indica si el paso 11 o el paso 14 están permitidos en este momento.

Cuando existe un número de seguimiento:

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [Manual REST](/api/documentation#/paths/v1-tracking-trackingNumber/get)

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` — los eventos de seguimiento en orden cronológico.
- `deliveried` — `true` una vez entregado el envío.

**GraphQL:** `trackingPublic` ([Manual GraphQL](/api/graphql/documentation#/tracking/trackingPublic))

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    data { tracking_event_status_id description location_city updated_at }
  }
}
```

**Verificación:** la consulta de la salida devuelve el `status` esperado. El seguimiento público encuentra el envío cuando existe un número.

## 13. Suscribirse a webhooks

Los webhooks envían a su servidor los cambios de seguimiento y de estado sin necesidad de consultas periódicas. Una cuenta de cliente configura sus propias URL de webhook y su secreto de firma; la configuración se guarda en la cuenta de cliente, no en la empresa.

**REST:** `PUT /api/v1/webhook-settings` — [Manual REST](/api/documentation#/paths/v1-webhook-settings/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

```json
{
  "result": true,
  "changed_keys": ["tracking_event_webhook_url", "order_status_change_webhook_url", "webhook_sign_secret"],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "************6789",
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status"
  }
}
```

- Solo cambian las claves enviadas; una clave desconocida o una URL no válida responde `400`.
- `recipient_type` — `customer` confirma que la configuración pertenece a la cuenta de cliente.
- `webhook_sign_secret` — de 16 a 255 caracteres; guárdelo en su servidor para verificar las firmas.

**GraphQL:** `webhookSettingsUpdate` ([Manual GraphQL](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

Verifique **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` contra `X-Webhook-Signature-V2`. Deduplique con `X-Webhook-Event-Id`. Responda **2xx en menos de 3 segundos**.

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $sharedSecret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
```

**Verificación:** la actualización devuelve `changed_keys` con las claves enviadas, y un evento de prueba recibido en su URL supera la verificación de firma anterior.

## 14. Cancelar una salida o un pedido de almacenaje

La cancelación libera lo que se había reservado. Cancelar una salida devuelve sus paquetes a las existencias; cancelar un pedido de almacenaje detiene una reserva cuya mercancía aún no se ha recibido.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/cancel` — [Manual REST](/api/documentation#/paths/v1-customer-shipout-orders-id--cancel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Buyer cancelled web order NW-20931"}'
```

```json
{ "result": true, "message": "Shipout cancelled." }
```

- `reason` (opcional) — se registra con la cancelación.
- Una salida solo puede cancelarse mientras esté pendiente (`0`) o confirmada (`1`).

**GraphQL:** `customerCancelShipout` ([Manual GraphQL](/api/graphql/documentation#/storage-shipout/customerCancelShipout))

```graphql
mutation {
  customerCancelShipout(id: 8001, reason: "Buyer cancelled web order NW-20931") {
    result
    message
  }
}
```

Esto libera el bloqueo de los paquetes de almacenaje. El almacenaje en sí se cancela con `POST /api/v1/customer/storage-orders/{id}/cancel` mientras aún esté permitido (estado `pending payment`, `confirmed`, `waiting for pickup` o `awaiting dropoff`; [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/1024/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{ "result": true, "message": "Storage order cancelled", "data": { "id": 1024, "status": "cancelled" } }
```

- El importe ya pagado por el pedido de almacenaje se abona de nuevo en el saldo del cliente.
- Un pedido de almacenaje en cualquier otro estado responde `403`.

**Verificación:** `422` significa que este estado no se puede cancelar. Tras una cancelación de salida correcta, el paso 9 vuelve a listar los paquetes.

## 15. Gestión de errores

| Situación | Estado HTTP | Código | Qué hace la integración |
|---|---|---|---|
| Token ausente o caducado, o tipo de cuenta incorrecto | 401 | — | Vuelva a iniciar sesión como cliente (paso 4). |
| Cotización de almacenaje sin `warehouse_id` o sin fechas | 400 | — | Envíe `warehouse_id`, `start_date` y `end_date`. |
| Falló la validación del pedido de almacenaje (faltan dimensiones del artículo, `end_date` no es posterior a `start_date`, falta `packaging_id`) | 422 | — | Lea `errors`, corrija los campos y vuelva a enviar. |
| Pago de almacenaje con saldo insuficiente, o pedido ya pagado por completo | 400 | — | Recargue el saldo (la respuesta incluye `customer_balance`) o deténgase si ya está pagado. |
| Pago de almacenaje con `custom_amount` fuera del rango permitido | 422 | — | Pague un importe entre el mínimo y el importe restante. |
| El pedido de almacenaje no se puede cancelar en su estado actual | 403 | — | Pida al almacén que gestione el pedido; no lo reintente. |
| Salida desactivada para este cliente | 403 | — | Pida a la empresa que active la salida para la cuenta de cliente. |
| Código de servicio desconocido, o salida / pedido de almacenaje no encontrado | 404 | — | Vuelva a leer la lista de servicios o compruebe el id guardado. |
| Paquete no disponible, paquetes de almacenes distintos, o servicio no ofrecido desde el almacén | 422 | — | Vuelva a leer el paso 9 y seleccione paquetes disponibles de un solo almacén. |
| El servicio de envío no puede tarificar el envío y lo rechaza | 422 | `unpriced_refused` | Elija otro servicio u otro destino; no se ha creado nada. |
| Pago de la salida con saldo insuficiente | 402 | — | Recargue el saldo y vuelva a intentar el paso 11. |
| Salida aún no pagable (en espera de una cotización manual) o importe no válido | 422 | — | Espere el precio, vuelva a consultar la salida y después pague. |
| La salida no se puede cancelar en su estado actual | 422 | — | El envío ya está en curso; no lo reintente. |
| Mismo `Idempotency-Key` enviado con un cuerpo distinto | 409 | `IDEMPOTENCY_CONFLICT` | Utilice una clave nueva para una solicitud distinta. |
| La solicitud original con el mismo `Idempotency-Key` aún se está procesando | 409 | `IDEMPOTENCY_IN_PROGRESS` | Espere los segundos indicados en `Retry-After` y vuelva a enviar la misma solicitud. |

## Lista de pruebas

- [ ] La configuración de almacenaje devuelve un `id` de almacén.
- [ ] La cotización de almacenaje devuelve un precio, y la creación del almacenaje devuelve `data.id`.
- [ ] El pago del almacenaje se completa, **o** ha confirmado que hay que recargar el saldo.
- [ ] Artículos disponibles lista los paquetes recibidos (`storage_package_ids`).
- [ ] La estimación de la salida devuelve un precio o `has_items_needing_quote`, y la creación de la salida devuelve un `id` y bloquea esos paquetes.
- [ ] El pago de la salida se completa (o se entiende `402` / `422`).
- [ ] El seguimiento público encuentra el envío cuando existe un número de seguimiento.
- [ ] La cancelación de la salida libera los paquetes, **o** este estado no se puede cancelar.
- [ ] Repetir una creación con el mismo `Idempotency-Key` y el mismo cuerpo devuelve `replayed: true` y no crea un segundo pedido.
- [ ] La configuración de webhooks devuelve `recipient_type: customer`, y un evento recibido supera la verificación de firma v2.
