# Servicios de envío

La API de servicios de envío permite que una cuenta de cliente reserve los servicios de envío que su proveedor logístico ha configurado y le ha asignado. El sistema propio del cliente lista los servicios que puede usar, carga las reglas de un servicio, cotiza un envío, crea el pedido de envío, lo paga con el saldo de la cuenta y sigue el envío hasta la entrega. Esta guía está dirigida a los desarrolladores que conectan el sistema de un importador, de un comerciante o de un mayorista con el proveedor logístico que le presta servicio.

## 1. Qué puede construir

Todos los ejemplos de esta guía usan un mismo escenario. **Harbourline Imports Inc.**, un importador de té de Toronto, tiene una cuenta de cliente con su proveedor logístico. El proveedor ofrece el servicio `intl_express` (International Express) desde su almacén Toronto Hub (id de almacén `7`). Harbourline deposita dos cajas de muestras de té en el Toronto Hub para un distribuidor de Seattle, con su orden de compra `HLI-PO-1058`.

- **Reserva desde el sistema de órdenes de compra.** Cuando se emite una orden de compra, el sistema de Harbourline cotiza el envío en `intl_express`, crea el pedido de envío con el número de la orden de compra como referencia y lo paga con el saldo prepagado de la cuenta, sin que nadie abra el portal del proveedor.
- **Una comprobación del precio antes del compromiso.** El comprador de Harbourline ve el flete, los recargos, los impuestos y el total de las dos cajas antes de reservar el envío, y un envío que el servicio no puede cotizar se detiene antes de que exista un pedido.
- **Estado del envío dentro del ERP.** El número de seguimiento de cada caja se guarda junto a la orden de compra; los webhooks trasladan el estado del pedido y el historial de seguimiento al ERP, y un proceso nocturno realiza la conciliación con la lista de pedidos.
- **Cambios controlados.** Una reserva no pagada se corrige en el mismo pedido, y una reserva que ya no se necesita se cancela y el importe pagado se devuelve al crédito de la cuenta.

## 2. Qué cubre esta guía

Use esta familia cuando quien llama es un **cliente** de la empresa logística y reserva uno de los servicios de envío propios de la empresa: la empresa define el plan de precios, los almacenes, los recargos y el embalaje, y asigna los servicios al cliente. El cliente solo ve y reserva los servicios que tiene asignados.

Use otra familia en estos casos:

- Quien llama es la propia empresa logística (una cuenta de empresa/cliente) y reserva recogidas y entregas locales o en el mismo día con su propia flota: lea **Recogida y entrega (flota propia)**.
- Quien llama compra etiquetas de transportista (por ejemplo UPS o FedEx) con las tarifas negociadas de la cuenta: lea **Etiquetas de transportista**.
- El cliente almacena mercancía en el almacén del proveedor y la envía desde el inventario: lea **Almacenaje y salida**.

**Uniorder: una API para cada envío** (`/api/v1/uniorder/...`) es el punto de entrada único recomendado para las nuevas integraciones de entrega local y de etiquetas de transportista. Uniorder no cubre los servicios de envío: los pedidos de servicios de envío se crean y gestionan únicamente mediante los endpoints `/api/v1/customer/shipping-orders/...` descritos aquí.

## 3. Antes de empezar

- **Tipo de cuenta.** Una cuenta de **cliente** de la empresa logística, con el **permiso de API** activado por la empresa. Una cuenta de empresa/cliente o de empleado no puede iniciar sesión mediante el login de cliente que se describe a continuación.
- **Asignación de servicios.** La empresa debe asignar al cliente al menos un servicio de envío activo. Un cliente sin servicios asignados recibe una lista de servicios vacía.
- **Datos de prueba.** Acuerde con la empresa un código de servicio de prueba, un almacén de prueba y un pequeño saldo prepagado en la cuenta de prueba. Use una referencia como `HLI-PO-1058` o `DEV-SHIP-001` para que los pedidos de prueba sean fáciles de encontrar y cancelar.
- **Gestión del token.** Llame a la API solo desde su servidor. Mantenga la contraseña y el token de acceso fuera de los navegadores y de los clientes móviles. El token caduca una semana después del login (`expires_at`); vuelva a iniciar sesión antes de que caduque.
- **Marcadores de posición.** Sustituya `YOUR_HOST` por el nombre de host de la empresa logística y `ACCESS_TOKEN` por el token devuelto en el paso de login.
- **Errores en JSON.** Envíe `Accept: application/json` en cada solicitud para que los errores de validación se devuelvan en JSON en lugar de como una redirección.

## 4. Iniciar sesión como cliente

El login intercambia el correo electrónico y la contraseña del cliente por un token Bearer. Todas las llamadas posteriores de esta guía envían ese token.

**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" \
  -H "Accept: application/json" \
  -d '{"email":"logistics@harbourline-imports.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2026-10-05 09:15:00",
  "expires_timestamp": 1791206100,
  "name": "Harbourline Imports Inc."
}
```

- `access_token`: envíelo en cada solicitud como `Authorization: Bearer ACCESS_TOKEN`. GraphQL usa la misma cabecera en `POST /api/graphql`.
- `expires_at` / `expires_timestamp`: programe un nuevo login antes de este momento.

**Verificación:** la respuesta tiene `result: true` y un `access_token`. Una solicitud sin el token devuelve `401`; un login con una cuenta que no es de cliente, o que no tiene permiso de API, también devuelve `401`.

## 5. Listar los servicios asignados al cliente

La lista de servicios indica a la integración qué códigos de servicio puede reservar y si cada servicio acepta la entrega en almacén, la recogida o ambas. Guarde el `service_code`; todas las llamadas posteriores al servicio lo usan.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "services": [
      {
        "id": 12,
        "service_code": "intl_express",
        "name": { "en": "International Express" },
        "offer_pickup": true,
        "allow_warehouse_delivery": true,
        "support_multi_package": true,
        "allow_special_requirements": false,
        "allow_purchase_supplies": true,
        "send_confirmation_email": true,
        "warehouses": [{ "id": 7, "name": "Toronto Hub" }]
      }
    ]
  }
}
```

- `service_code`: el parámetro de ruta de todas las llamadas posteriores al servicio.
- `offer_pickup` / `allow_warehouse_delivery`: los valores permitidos de `origin_type` (`pickup` / `warehouse`).
- `support_multi_package`: si un pedido puede llevar más de una línea de paquetes.
- Un array `services` vacío significa que este cliente no tiene ningún servicio asignado.

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

```graphql
query CustomerShippingOrderServices {
  customerShippingOrderServices
}
```

**Verificación:** la lista contiene al menos un servicio y usted ha guardado su `service_code` (en esta guía: `intl_express`).

## 6. Cargar la configuración del servicio

La configuración devuelve todo lo que necesita el formulario de pedido de un servicio: los almacenes que aceptan entregas, los recargos seleccionables, el catálogo de embalajes y suministros, las unidades y los países desde los que el servicio puede recoger y a los que puede entregar. Valide los datos de su pedido con ella antes de cotizar o crear nada.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [Manual REST](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/config \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "service": {
      "service_code": "intl_express",
      "offer_pickup": true,
      "allow_warehouse_delivery": true,
      "allow_special_requirements": false,
      "allow_purchase_supplies": true,
      "weight_mode": 2,
      "volumetric_factor": 5000
    },
    "warehouses": [
      {
        "id": 7,
        "name": "Toronto Hub",
        "address_1": "250 Dundas St W",
        "city": "Toronto",
        "province": "ON",
        "country": "CA",
        "postcode": "M5T 2Z5",
        "telephone": "4165550100"
      }
    ],
    "surcharges": [],
    "has_surcharges": false,
    "packagings": [],
    "products": [],
    "weight_units": { "2": { "name": "weight_kg", "accuracy": 3 } },
    "dimension_units": { "2": { "name": "dimension_cm", "accuracy": 1 } },
    "delivery_allowed_countries": ["CA", "US"],
    "pickup_allowed_countries": ["CA"]
  }
}
```

- `warehouses[].id`: el `warehouse_id` que se envía cuando `origin_type` es `warehouse`. Un id que no figure en esta lista se rechaza al crear.
- `service.weight_mode`: qué campos de paquete exige el plan de precios: `0` peso real (peso), `1` peso volumétrico (largo, ancho y alto), `2` peso facturable (ambos). `null` significa que el servicio se cotiza manualmente. Envíe el peso y las tres dimensiones para cumplir todos los modos.
- `delivery_allowed_countries` / `pickup_allowed_countries`: rechace un país de destino o de recogida que no figure en estas listas antes de llamar a la estimación.
- `surcharges[].id`, `packagings[].id`, `products[].id`: los id que se usan para los recargos opcionales, el embalaje y la compra de suministros.
- `weight_units` / `dimension_units`: las unidades de los paquetes se envían como números. Envíe `weight_unit: 2` (kg) y `dimension_unit: 2` (cm), como hacen todos los ejemplos de esta guía; ambos son también los valores predeterminados cuando se omiten los campos.

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

```graphql
query CustomerShippingOrderServiceConfig {
  customerShippingOrderServiceConfig(serviceCode: "intl_express")
}
```

**Verificación:** `result` es `true` y, para una entrega en almacén, `warehouses` contiene el almacén que piensa usar. `403` significa que el servicio no está asignado a este cliente; `404` significa que el código de servicio no existe o está inactivo.

## 7. Estimar el precio

La estimación cotiza el envío con el plan de precios del servicio sin escribir nada. Muestre el total al comprador y no cree el pedido cuando la estimación indique un rechazo.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/estimate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_postcode": "98104",
    "delivery_country": "US",
    "packages": [{
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "needs_manual_quote": false,
    "shipping_fee": 118.40,
    "shipping_fee_total": 131.20,
    "fuel_surcharge": 12.80,
    "pickup_fee": null,
    "surcharges_total": 0,
    "sub_total": 131.20,
    "tax": { "total_tax": 17.06 },
    "total": 148.26,
    "currency": "CAD",
    "all_fees_calculated": true,
    "has_items_needing_quote": false,
    "unpriced_items": [],
    "refused": false,
    "refusal_message": null
  }
}
```

- `origin_type`: `warehouse` (el cliente deposita la mercancía en un almacén; envíe `warehouse_id`) o `pickup` (el proveedor la recoge; envíe `pickup_postcode` y `pickup_country`). Use solo un valor que el paso 5 permita.
- `packages`: una línea por cada grupo de paquetes idénticos; `quantity` multiplica la línea.
- `total` y `currency`: el importe que se muestra. `total` es `null` mientras alguna tarifa no esté calculada.
- `needs_manual_quote` / `has_items_needing_quote`: la empresa cotiza el pedido manualmente; el pedido puede crearse y se paga después de que la empresa fije el precio.
- `refused` / `refusal_message`: el servicio rechaza los envíos que no puede cotizar. No cree el pedido; muestre `refusal_message` en su lugar.
- Entradas opcionales: `surcharges`, `products` (un mapa de id de producto a cantidad, que solo se tiene en cuenta cuando `allow_purchase_supplies` es true), `has_special_requirements`, `coupon_code`.

**Verificación:** `result` es `true`, `refused` es `false`, y `total` tiene un valor o `needs_manual_quote` es `true`.

## 8. Crear el pedido de envío

La llamada de creación reserva el envío en el servicio. La integración guarda el `id` devuelto junto a su propia orden de compra; todas las llamadas posteriores usan este id.

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

Envíe un `Idempotency-Key` derivado de su propio id estable (aquí, el número de la orden de compra). Un reintento con la misma clave y el mismo cuerpo devuelve la primera respuesta con `"replayed": true` y la cabecera `Idempotency-Replayed: true`, y no crea un segundo pedido.

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: hli-po-1058" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "note": "Two cartons of sample tea, dock door B",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "price_breakdown": { "total": 148.26, "currency": "CAD" },
    "promotion_id": null,
    "tracking_number": null
  }
}
```

- La clave del cuerpo para los paquetes es `package` en la creación (en la estimación es `packages`). Cada línea con `quantity` N se convierte en N paquetes, y cada paquete recibe su propio número de seguimiento.
- Campos obligatorios: `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight`; además `warehouse_id` para `warehouse`, o `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` para `pickup`.
- Campos opcionales: `reference` (se guarda como `reference_number` del pedido), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (un array de líneas de texto, que solo se tiene en cuenta cuando el servicio lo permite), `products`, `surcharges`, `coupon_code`.
- `id`: guárdelo. `status` `0` es Pendiente (a la espera del pago).
- `total_price`: el importe que cobra el paso 9. Es `0` mientras el pedido espera una cotización manual.
- `tracking_number` a nivel de pedido es `null`; los números de seguimiento están en los paquetes y se leen en el paso 10.
- El endpoint responde HTTP `201` para un pedido nuevo.

**Verificación:** la respuesta tiene `result: true` y un `id`. Repetir la misma solicitud con el mismo `Idempotency-Key` devuelve el mismo `id` con `"replayed": true`.

## 9. Pagar el pedido con el saldo de la cuenta

Los pedidos de envío se pagan íntegramente con el saldo de la cuenta del cliente. Un pedido pagado pasa de Pendiente a Confirmado y el proveedor empieza a gestionarlo.

Primero lea el importe:

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "order_id": 9001,
    "currency": "CAD",
    "total_price": "148.26",
    "paid_amount": "0.00",
    "remaining_balance": "148.26",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "shortfall": 0,
    "payment_options": [
      { "type": "remaining_balance", "amount": 148.26 }
    ]
  }
}
```

Después pague:

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

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

```json
{
  "result": true,
  "message": "Payment of $148.26 processed successfully.",
  "data": {
    "order_id": 9001,
    "status": 1,
    "amount_paid": "148.26"
  }
}
```

- `has_sufficient_balance` / `shortfall`: cuando el saldo no cubre `remaining_balance`, recargue la cuenta antes de pagar.
- `payment_type`: solo se admite `remaining_balance`; siempre se cobra el importe pendiente completo.
- `data.status` `1` es Confirmado.

**Verificación:** la llamada de pago devuelve `result: true` y `status` `1`, y una segunda llamada a `payment-info` devuelve `400` porque el pedido está totalmente pagado. Una llamada de pago sin saldo suficiente devuelve `422` y no cobra nada.

## 10. Consultar el pedido y seguir los paquetes

La llamada de detalle devuelve el estado actual y el número de seguimiento de cada paquete. Guarde los números de seguimiento de los paquetes junto a la orden de compra; el seguimiento público acepta cada uno de ellos.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 1,
    "status_name": "Confirmed",
    "can_edit": false,
    "can_cancel": true,
    "shipping_code": "K7RW2Q",
    "tracking_number": null,
    "reference_number": "HLI-PO-1058",
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_address": { "name": "Cascade Tea Distributors", "city": "Seattle", "country": "US" },
    "package_count": 2,
    "packages": [
      { "id": 55101, "description": "Sample tea carton", "tracking_number": "SR123456789012", "weight": 12, "weight_unit": 2 },
      { "id": 55102, "description": "Sample tea carton", "tracking_number": "SR123456789013", "weight": 12, "weight_unit": 2 }
    ],
    "total_price": 148.26
  }
}
```

- `status`: `0` Pendiente, `1` Confirmado, `2` En tránsito, `3` Enviado, `4` Cancelado, `5` Fallido, `6` Recogido parcialmente, `7` Recogido, `8` En proceso.
- `can_edit` / `can_cancel`: si el paso 12 está permitido en ese momento.
- `packages[].tracking_number`: los números que se guardan y se siguen.
- `shipping_code`: el código que aceptan las pantallas de entrega en almacén; imprímalo en la documentación de entrega.

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

```graphql
query CustomerShippingOrderShow {
  customerShippingOrderShow(id: 9001) {
    result
    message
    data {
      id
      status
      status_name
      can_cancel
      reference_number
      packages {
        id
        tracking_number
        weight
      }
      total_price
    }
  }
}
```

Para conciliar todos los pedidos de un servicio, por ejemplo en un proceso nocturno, lístelos con un filtro. El filtro `id` coincide con el id del pedido, con un número de seguimiento o con la referencia.

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders?id=HLI-PO-1058&created_at_from=2026-09-01&per_page=20" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

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

El seguimiento público no necesita token y devuelve el historial de eventos de un paquete:

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

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012 \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 1,
      "otep_status": "received",
      "description": "Received at warehouse",
      "location_city": "Toronto",
      "updated_at": "2026-09-29 10:42:00"
    }
  ]
}
```

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

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

**Verificación:** el detalle devuelve el pedido de este cliente con un número de seguimiento por paquete, y el seguimiento público devuelve `result: true` para el número de seguimiento de un paquete. El id de pedido de otro cliente devuelve `404`.

## 11. Recibir webhooks

Los webhooks entregan a su servidor la creación de pedidos, los cambios de estado y los eventos de seguimiento, de modo que la integración no necesita consultar periódicamente. La cuenta de cliente configura sus propias URL de webhook y su secreto de firma.

Cuando se crea un pedido de envío, el proveedor crea además un pedido de recogida vinculado para su equipo de despacho. Los webhooks se envían para ese pedido vinculado: su `ref` es `Shipping-Pickup-{shipping order id}` (por ejemplo `Shipping-Pickup-9001`), y cada uno de sus paquetes lleva el número de seguimiento del paquete de envío en `external_tracking_number`. Asocie los eventos entrantes mediante estos dos campos.

| Ajuste | Evento | Qué hace la integración |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Vincula el evento con el pedido de envío mediante `ref` y `packages[].external_tracking_number` |
| `tracking_event_webhook_url` | `tracking.event` | Añade el evento al historial del paquete |
| `order_status_change_webhook_url` | `order.status_change` | Actualiza el estado que se muestra en su sistema |

**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" \
  -H "Accept: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.harbourline-imports.example/hooks/order-created",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking",
    "order_status_change_webhook_url": "https://erp.harbourline-imports.example/hooks/status",
    "webhook_sign_secret": "hli-webhook-secret-7f2c9a1e5b"
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "tracking_event_webhook_url",
    "order_status_change_webhook_url",
    "webhook_sign_secret"
  ],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "*************************1e5b",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking"
  }
}
```

- Solo cambian las claves enviadas; una cadena vacía borra una URL. `webhook_sign_secret` debe tener entre 16 y 255 caracteres, y no se envía ningún webhook mientras el secreto esté vacío.
- `recipient_type` es `customer` para una cuenta de cliente.

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

```graphql
mutation WebhookSettingsUpdate {
  webhookSettingsUpdate(
    order_status_change_webhook_url: "https://erp.harbourline-imports.example/hooks/status"
    tracking_event_webhook_url: "https://erp.harbourline-imports.example/hooks/tracking"
  )
}
```

Verifique la firma **v2** sobre el cuerpo sin procesar: `HMAC_SHA256(timestamp + "." + raw_body, secret)` frente a `X-Webhook-Signature-V2`. Elimine duplicados por `X-Webhook-Event-Id`. Responda **2xx en menos de 3 segundos** y procese el evento después.

```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:** tras la llamada de ajustes, una creación de prueba produce un evento `order.created` cuyo `ref` es `Shipping-Pickup-{id}` para el id del nuevo pedido de envío, y la comprobación de la firma se supera.

## 12. Modificar o cancelar un pedido

Un pedido puede corregirse mientras está Pendiente (antes del pago) y cancelarse mientras está Pendiente o Confirmado. La cancelación de un pedido pagado devuelve el importe pagado al crédito de la cuenta.

Para modificarlo, envíe de nuevo el pedido completo con los mismos campos que en el paso 8. El precio se recalcula.

**REST:** `PUT /api/v1/customer/shipping-orders/{id}` — [Manual REST](/api/documentation#/paths/v1-customer-shipping-orders-id/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_address_2": "Suite 210",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "promotion_id": null,
    "coupon_code": null,
    "promotion_discount": null
  }
}
```

Para cancelarlo:

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

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

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 4,
    "refund_amount": 148.26
  },
  "message": "Order cancelled. $148.26 refunded to your credit."
}
```

- `status` `4` es Cancelado. El pedido de recogida vinculado se elimina.
- `refund_amount`: el importe devuelto al crédito de la cuenta; `0` para un pedido no pagado.
- Lea `can_edit` y `can_cancel` del paso 10 antes de ofrecer estas acciones a los usuarios.

**Verificación:** la cancelación devuelve `status` `4` y el detalle muestra `status_name` `Cancelled`. Una segunda cancelación, o la cancelación de un pedido que está En tránsito o en un estado posterior, devuelve `403` con el mensaje `This order can no longer be cancelled.`; la modificación de un pedido pagado devuelve `403`.

## 13. Gestión de errores

| Situación | Estado HTTP | Código | Qué hace la integración |
|---|---|---|---|
| Token ausente, caducado o no válido; login con una cuenta que no es de cliente o sin permiso de API | `401` | — | Vuelve a iniciar sesión; si el propio login falla, pide a la empresa que compruebe el tipo de cuenta y el permiso de API |
| El servicio no está asignado a este cliente | `403` | — | Vuelve a leer la lista de servicios (paso 5) y reserva solo los servicios asignados |
| Se llama a la API desde una sesión de una app de la plataforma que tiene desactivados los pedidos de envío | `403` | `APP_CAPABILITY_DISABLED` | Pide a la empresa que active los pedidos de envío para la app |
| Código de servicio desconocido o inactivo; id de pedido no encontrado para este cliente | `404` | — | Actualiza la lista de servicios; comprueba el id de pedido guardado |
| Campo obligatorio ausente o no válido | `422` | — | Lee `errors` en el cuerpo, corrige los campos y vuelve a enviar |
| Tipo de origen no ofrecido por el servicio, o almacén que no figura en la lista del servicio | `422` | — | Usa un `origin_type` y un `warehouse_id` de los pasos 5 y 6 |
| El servicio no puede cotizar el envío y rechaza los envíos sin precio | `422` | `unpriced_refused` | No se ha creado nada; muestra `message` y no reintenta sin cambios |
| Se piden suministros sin existencias | `422` | — | Lee `stock_shortages`, reduce las cantidades y vuelve a enviar |
| El mismo `Idempotency-Key` con un cuerpo distinto | `409` | `IDEMPOTENCY_CONFLICT` | Usa una clave nueva para un pedido nuevo; nunca reutiliza una clave para un contenido distinto |
| Un reintento mientras la primera solicitud con esa clave aún se está procesando | `409` | `IDEMPOTENCY_IN_PROGRESS` | Espera los segundos indicados en `Retry-After` y reintenta con la misma clave y el mismo cuerpo |
| Pago sin saldo suficiente | `422` | — | Recarga la cuenta y vuelve a pagar |
| Información de pago o pago de un pedido totalmente pagado | `400` | — | Trata el pedido como pagado; lee el detalle |
| Cancelación después de que el pedido haya salido de Pendiente o Confirmado | `403` | — | Muestra que el pedido ya no se puede cancelar; contacta con la empresa |
| Modificación después del pago | `403` | — | Cancela y crea un pedido nuevo, o contacta con la empresa |
| Error del servidor durante la estimación, la creación, el pago o la cancelación | `500` | — | Reintenta una vez; para la creación, reintenta con el mismo `Idempotency-Key` |

## Lista de pruebas

Utilice una referencia de prueba como `DEV-SHIP-001` o `HLI-PO-1058`:

- [ ] El login de cliente devuelve `access_token`; una solicitud sin el token devuelve `401`.
- [ ] La lista de servicios no está vacía y ha guardado un `service_code`.
- [ ] La configuración devuelve los almacenes, las unidades y los países permitidos de ese servicio, y su formulario los usa.
- [ ] La estimación devuelve un `total` (o `needs_manual_quote: true`), y un envío rechazado no se crea.
- [ ] La creación devuelve un `id`; el mismo `Idempotency-Key` con el mismo cuerpo devuelve el mismo `id` con `"replayed": true`.
- [ ] El pago se realiza y el estado pasa a Confirmado, o ha comprobado que un saldo insuficiente devuelve `422` y no cobra nada.
- [ ] El detalle muestra el pedido de este cliente con un número de seguimiento por paquete, y el seguimiento público encuentra cada paquete.
- [ ] Los webhooks están configurados con un secreto de firma; una creación de prueba produce `order.created` con `ref` `Shipping-Pickup-{id}` y la comprobación de la firma se supera.
- [ ] La cancelación del pedido de prueba devuelve `status` `4` y el `refund_amount` esperado; una segunda cancelación devuelve `403`.
