# Recogida y entrega (flota propia)

Este manual describe la API de entrega local de una cuenta de empresa: pedidos que los conductores propios de la empresa entregan a un destinatario (`type` `D`) o recogen de un remitente (`type` `P`). Un único conjunto de endpoints cotiza, crea, etiqueta, sigue y cancela ambos tipos de parada, y los webhooks notifican cada cambio a su sistema. Está dirigido a desarrolladores de sistemas de gestión de pedidos, ERP y tiendas en línea que asignan trabajo a la flota propia de la empresa.

## 1. Qué puede construir

Los ejemplos siguientes corresponden a una sola empresa: **Farine & Fils**, un proveedor de panadería con un almacén en 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), que entrega pedidos mayoristas en toda la isla de Montreal y recoge las cajas de pan vacías que devuelven sus clientes. Una entrega típica es una pila de cajas de 12 kg y 60 × 40 × 30 cm para Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), con el pedido mayorista `WHS-20931`. Una recogida típica es una pila de cajas vacías de 4 kg en Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), con la referencia `CRT-20931`.

- **Pedidos mayoristas enviados a despacho desde el ERP.** Cada pedido mayorista confirmado se convierte en un pedido de entrega con la franja de entrega matinal del café, y el ERP guarda el número de seguimiento devuelto en la línea del pedido.
- **Recogidas de devolución de cajas.** Cuando un cliente notifica cajas vacías, el ERP crea un pedido de recogida para la dirección del cliente, y un conductor recoge las cajas en la siguiente ruta.
- **Impresión de etiquetas en el almacén.** El ERP descarga el PDF de la etiqueta de cada pedido y lo imprime en el muelle de carga, de modo que cada pila de cajas lleva su código de barras de seguimiento.
- **Un portal de clientes con estado en tiempo real.** Cada café consulta el estado de sus entregas y recogidas, con la prueba de entrega, alimentado por webhooks en lugar de consultas periódicas.

## 2. Qué cubre este manual

Utilice este manual cuando los conductores propios de la empresa transportan el pedido: entregas desde el almacén y recogidas en la dirección de un cliente, creadas de una en una o por lotes mediante los endpoints `/api/v1/client/...` y `/api/v1/orders/...`.

Para integraciones nuevas, Uniorder (`/api/v1/uniorder/...`) es el punto de entrada único recomendado: ofrece las mismas entregas con flota propia mediante una sola API, junto con las etiquetas de transportista, a partir de una sola cotización. Consulte **Uniorder: una API para cada envío** para la visión general y **Cotización y pedido en un solo flujo** para sus peticiones paso a paso. Los endpoints de este manual siguen disponibles y sin cambios para las integraciones construidas sobre ellos.

Utilice **Etiquetas de transportista** cuando un paquete lo envía un transportista externo con una etiqueta comprada a través de la plataforma. Utilice **Servicios de envío** para los pedidos que una cuenta de cliente reserva con los servicios de una empresa, y **Almacenaje y salida** para mercancías guardadas en un almacén y enviadas bajo petición; Uniorder no se aplica a estos dos.

## 3. Antes de empezar

- **Cuenta.** Utilice una cuenta de empresa (cliente), o una cuenta de empleado de la empresa, con permiso de API. Crear pedidos requiere además el permiso de realizar pedidos; sin él, `POST /api/v1/client/orderCreate` devuelve `401`.
- **Zona de servicio.** La dirección de entrega o de recogida debe estar dentro de una región activa de la empresa. Para las pruebas, utilice direcciones dentro de la zona, como las de este manual.
- **Datos de prueba.** Utilice referencias de prueba como `WHS-20931` y `CRT-20931`, y cancele los pedidos de prueba al final (paso 12).
- **Tokens.** Solicite el token de acceso desde su servidor y consérvelo allí. No lo envíe nunca a un navegador ni a una aplicación móvil.
- **Marcadores de posición.** Sustituya `YOUR_HOST` por el host de la API de su entorno y `ACCESS_TOKEN` por el token del paso 4.
- **Unidades.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Ambas tienen `1` como valor predeterminado.

## 4. Autenticación

Todas las llamadas de este manual, excepto el seguimiento público, se realizan en nombre de la cuenta de empresa. Inicie sesión una vez desde su servidor, guarde el token devuelto y envíelo en cada petición.

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

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

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
}
```

- `access_token`: colóquelo en la cabecera de cada petición posterior:

```
Authorization: Bearer ACCESS_TOKEN
```

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

**GraphQL:** `userLogin` ([Manual GraphQL](/api/graphql/documentation#/user/userLogin))

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

## 5. Cotizar una entrega o una recogida (opcional)

Una cotización muestra el precio de una parada antes de que exista el pedido, por ejemplo para mostrar el cargo de entrega en una factura mayorista. No crea nada, y crear un pedido no requiere una cotización previa. Establezca `type` en `D` (entrega) o `P` (recogida); `to_postcode` es el código postal de la parada.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H4R2N6",
    "from_country": "CA",
    "to_postcode": "H2V4G7",
    "to_country": "CA",
    "packages": [{
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "18.50",
    "tax_details": [
      { "tax_name": "GST", "tax_rate": "5.00", "tax": "0.93" },
      { "tax_name": "QST", "tax_rate": "9.975", "tax": "1.85" }
    ]
  }
}
```

- `shipping_price`: el precio de la parada sin impuestos. Un precio vacío significa que el código postal no está en una región activa o que la tarifa no tiene ninguna fila para él.
- `price_details.tax_details`: los impuestos que llevará el pedido; muéstrelos en la línea de la factura.
- `currency`: la moneda de todos los importes de la respuesta.

Para cotizar la recogida de cajas, envíe la misma petición con `"type": "P"`, `"to_postcode": "H4G1V5"` y el peso y las dimensiones de la pila de cajas.

**GraphQL:** `ordersRate` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersRate)). El resultado es un escalar JSON y no admite selection set.

```graphql
mutation {
  ordersRate(
    type: "P"
    from_postcode: "H4R2N6"
    from_country: "CA"
    to_postcode: "H4G1V5"
    to_country: "CA"
    packages: [{ weight: 4, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Verificación:** `result` es `true` y `shipping_price` es un número tanto para `type` `D` como para `type` `P`. La creación de un pedido no depende de este paso.

## 6. Crear un pedido de entrega

Cada pedido mayorista confirmado se convierte en un pedido de entrega. El ERP guarda el `id` y el `tracking_number` devueltos en su línea de pedido; todas las llamadas posteriores utilizan uno de ellos.

**REST:** `POST /api/v1/client/orderCreate` — [Manual REST](/api/documentation#/paths/v1-client-orderCreate/post)

Envíe una cabecera `Idempotency-Key`, única por pedido mayorista, para que un reintento tras un tiempo de espera agotado no pueda crear un segundo pedido.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whs-20931-delivery" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "WHS-20931",
    "name": "Marie Tremblay",
    "company_name": "Café Lumière",
    "telephone": "5145550142",
    "email": "commandes@cafelumiere.example",
    "address_1": "5400 Avenue du Parc",
    "city": "Montréal",
    "province": "QC",
    "postcode": "H2V4G7",
    "country": "Canada",
    "schedule_date": "2026-10-02",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "packages": 1,
    "packagesDetail": [{
      "ref": "WHS-20931-1",
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Deliver to the back door on Rue Saint-Viateur"
  }'
```

```json
{
  "result": true,
  "id": 12345,
  "ref": "WHS-20931",
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": { "shipping_fee": "18.50" },
  "tracking_number": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012", "external_tracking_number": "" }
  ]
}
```

| Campo | Significado |
|---|---|
| `type` | `D` entrega o `P` recogida |
| `need_pick_up` | `0` — la mercancía ya está en el almacén. `1` — un conductor debe recoger el paquete |
| `ref` | Referencia externa para búsqueda y conciliación |
| `name` / dirección | Entrega: destinatario. Recogida: punto de recogida |
| `schedule_date`, `time_window_start`, `time_window_end` | Fecha de entrega (`Y-m-d`) y franja en la que debe atenderse la parada (`Y-m-d H:i:s`) |
| `packagesDetail` | Una entrada por paquete; `ref` identifica el paquete en su sistema |
| `auto_deduplication` | `1` rechaza un segundo paquete con la misma `ref` de paquete |

En la respuesta:

- `id`: el id del pedido; guárdelo para el detalle del pedido y la llamada de cancelación.
- `tracking_number`: un número de seguimiento por paquete; imprima y siga con estos números.
- `warning`: presente cuando el pedido se creó con un aviso, por ejemplo una dirección fuera de la zona de entrega que la empresa conserva o retiene. Un pedido fuera de zona que se conserva puede devolver `shipping_price: null`.

**GraphQL:** `clientOrderCreate` ([Manual GraphQL](/api/graphql/documentation#/client/clientOrderCreate)). El resultado es un escalar JSON con el mismo cuerpo que la respuesta REST.

```graphql
mutation {
  clientOrderCreate(
    type: "D"
    need_pick_up: 0
    ref: "WHS-20931"
    name: "Marie Tremblay"
    company_name: "Café Lumière"
    telephone: "5145550142"
    address_1: "5400 Avenue du Parc"
    city: "Montréal"
    province: "QC"
    postcode: "H2V4G7"
    country: "Canada"
    packages: 1
    packagesDetail: [{ ref: "WHS-20931-1", weight: 12, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Verificación:** envíe de nuevo el mismo cuerpo con el mismo `Idempotency-Key`. La respuesta contiene el mismo `id` y no se crea un segundo pedido.

## 7. Crear un pedido de recogida

Un pedido de recogida envía a un conductor a recoger mercancía en una dirección; en este caso, las cajas vacías de Épicerie Wellington. Utiliza el mismo endpoint que una entrega: la dirección es el punto de recogida, `type` es `P` y `need_pick_up` es `1`.

**REST:** `POST /api/v1/client/orderCreate` — [Manual REST](/api/documentation#/paths/v1-client-orderCreate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crt-20931-pickup" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "CRT-20931",
    "name": "Luc Gagnon",
    "company_name": "Épicerie Wellington",
    "telephone": "5145550187",
    "email": "luc@epiceriewellington.example",
    "address_1": "4100 Rue Wellington",
    "city": "Verdun",
    "province": "QC",
    "postcode": "H4G1V5",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "CRT-20931-1",
      "weight": 4,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Empty crates are stacked at the loading door"
  }'
```

```json
{
  "result": true,
  "id": 12346,
  "ref": "CRT-20931",
  "shipping_price": "12.00",
  "currency": "CAD",
  "tracking_number": ["SR123456789029"],
  "packages": [
    { "id": 67891, "ref": "CRT-20931-1", "tracking_number": "SR123456789029", "external_tracking_number": "" }
  ]
}
```

- `id` y `tracking_number`: guárdelos asociados a la devolución de cajas, igual que para una entrega.
- `pickup_instruction`: se muestra al conductor en el punto de recogida; `delivery_instruction` es su equivalente en una entrega.

**Verificación:** el detalle del pedido (paso 8) muestra `type` `P` y `need_pickup` `1` para este pedido.

## 8. Consultar el pedido

El detalle del pedido confirma lo que se ha guardado y devuelve el estado actual; el endpoint de lista permite al ERP conciliar sus propios registros con la plataforma.

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

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

```json
{
  "business_name": "Farine & Fils",
  "order": {
    "id": 12345,
    "ref": "WHS-20931",
    "type": "D",
    "need_pickup": 0,
    "orders_status_id": 2,
    "name": "Marie Tremblay",
    "address_1": "5400 Avenue du Parc",
    "postcode": "H2V4G7",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "shipping_price": "18.50"
  },
  "tracking_numbers": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012" }
  ]
}
```

- `order.orders_status_id`: el estado del pedido; `2` es Nuevo, `12` es Cancelado.
- `order.type` y `order.need_pickup`: confirman que la parada se guardó como entrega o como recogida.
- `tracking_numbers`: los números de seguimiento de los paquetes del pedido.

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

```bash
curl "https://YOUR_HOST/api/v1/orders/list?page=1&per_page=50" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

La lista devuelve todos los pedidos de la cuenta, los más recientes primero, cada uno con sus paquetes y sus líneas de artículos. Envíe `page` y `per_page` juntos para paginar (`per_page` máximo 1000); sin ellos se devuelven los 1000 pedidos más recientes con un indicador `truncated`.

**GraphQL:** `orders` ([Manual GraphQL](/api/graphql/documentation#/orders/orders)) para un pedido y `ordersList` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersList)) para la lista. Ambos devuelven un escalar JSON.

```graphql
query {
  orders(orderId: "12345")
}
```

**Verificación:** el pedido pertenece a la cuenta autenticada, `ref` coincide con el valor enviado al crearlo y `tracking_numbers` coincide con la respuesta de creación.

## 9. Imprimir la etiqueta local

La etiqueta lleva el código de barras de seguimiento que el conductor escanea en el almacén y en la parada. Imprima una etiqueta por paquete y péguela en la pila de cajas.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [Manual REST](/api/documentation#/paths/v1-shipping-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

```json
"JVBERi0xLjcKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwo..."
```

- `type`: cómo se interpreta `id`: `TRACKING_NUMBER` (predeterminado), `ORDER_ID` o `REF`.
- `base64`: `0` (predeterminado) envía el PDF directamente. `1` hace que todo el cuerpo de la respuesta sea una cadena JSON de nivel superior que contiene el PDF en base64, no un objeto con un campo `pdf_data`. Llame en su lugar a `POST /api/v2/shipping/getShippingLabel` — [Manual REST](/api/documentation#/paths/v2-shipping-getShippingLabel/post) para recibir la etiqueta dentro de un objeto JSON normal.
- `packages`: opcional; el número de etiquetas que se imprimen. Un valor distinto del número de paquetes del pedido actualiza el pedido.
- `hide_sender_address` / `hide_receiver_address`: `1` deja esa dirección en blanco en la etiqueta.

**GraphQL:** `shippingGetShippingLabel` ([Manual GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([Manual GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) siempre devuelve JSON (`pdf_data`).

**Verificación:** el PDF decodificado se abre. La etiqueta de entrega muestra la dirección de Café Lumière; la etiqueta de recogida muestra la dirección de Épicerie Wellington. Una dirección oculta aparece en blanco en la etiqueta.

## 10. Seguir el pedido

El seguimiento público devuelve la línea de tiempo de eventos de un paquete. No requiere token de acceso, por lo que un portal de clientes puede mostrarla directamente; la prueba de entrega o de recogida se incluye con ella.

**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,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

La misma URL acepta su `ref` cuando se guardó como número externo.

Bifurque por `tracking_event_status_id`, no por `description`; esa cadena sigue `Accept-Language`.

| `tracking_event_status_id` | Lado | Significado |
|---|---|---|
| `100` | ambos | Pedido recibido |
| `300` / `301` | entrega | En instalación |
| `450` | entrega | En reparto |
| `500` | entrega | Entregado |
| `501` | entrega | Entrega fallida, hace falta un plan nuevo |
| `460` | recogida | En recogida |
| `510` | recogida | Recogido |
| `512` | recogida | Recogida fallida, intentar más tarde |
| `513` | recogida | Problema de recogida |

- `data`: los más recientes primero; la primera fila es el estado actual.
- `deliveried`: `true` después de `500`.
- `proofs[]`: en `500` o `510`, puede llevar `type` `1` (firma) o `2` (foto), con `file_id` y `signed_url`. Una foto subida después de ese evento no está en esta carga útil; suscríbase a `pod.files_updated` (paso 11).

**GraphQL:** `trackingPublic` ([Manual GraphQL](/api/graphql/documentation#/tracking/trackingPublic)). El resultado es tipado y necesita un selection set.

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

**Verificación:** justo después de la creación, el evento más reciente es `100` y `deliveried` es `false`. Un número desconocido devuelve `result: false` con `404`; muestre un estado de no encontrado y no genere eventos de seguimiento propios.

## 11. Recibir webhooks

Los webhooks envían cada cambio a su servidor, de modo que el ERP y el portal de clientes se mantienen actualizados sin consultas periódicas. Registre las URL de retrollamada que requiere este flujo:

| Ajuste | Evento | Uso |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Guardar `id` y `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Estado visible para el cliente |
| `tracking_event_webhook_url` | `tracking.event` | Línea de tiempo de recogida o entrega |
| `pod_files_webhook_url` | `pod.files_updated` | Foto o firma después de la recogida o de la entrega |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Una cancelación que usted envió fue rechazada |
| `order_create_async_postback_url` | `order.create_async` | Resultado de un lote asíncrono (paso 13) |

**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 '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "order_status_change_webhook_url",
    "tracking_event_webhook_url",
    "pod_files_webhook_url",
    "order_cancel_failed_webhook_url",
    "webhook_sign_secret"
  ],
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_verify_ssl": 1
  }
}
```

- `changed_keys`: los ajustes que ha modificado esta llamada.
- `settings.webhook_sign_secret`: se devuelve enmascarado; conserve el valor completo solo en su servidor.

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

En el lado receptor, verifique la firma **v2** sobre el cuerpo sin procesar: `HMAC_SHA256(timestamp + "." + raw_body, secret)` comparado con `X-Webhook-Signature-V2`, donde la marca de tiempo es `X-Webhook-Timestamp`. Deduplique 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:** cree un pedido de prueba y reciba `order.created` con el mismo `id` y `tracking_number`. El receptor rechaza una firma no válida con `401`, y una segunda entrega del mismo `X-Webhook-Event-Id` no se procesa dos veces.

## 12. Cancelar un pedido

Cancele un pedido cuando se retire el pedido mayorista o ya no se necesite la recogida de cajas. La llamada es idempotente: cancelar un pedido que ya está cancelado vuelve a tener éxito.

**REST:** `POST /api/v1/orders/cancel` — [Manual REST](/api/documentation#/paths/v1-orders-cancel/post) — envíe exactamente uno de `order_id`, `tracking_number`, `external_tracking_number`.

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

```json
{
  "result": true,
  "id": 12345,
  "message": "Order 12345 has been cancelled successful.",
  "already_cancelled": false
}
```

- `result`: `true` cuando el pedido queda cancelado.
- `already_cancelled`: `true` cuando el pedido se canceló antes de esta llamada; trátelo como un éxito.
- `code`: presente cuando la cancelación se rechaza; consulte el paso 14.

**GraphQL:** `ordersCancel` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersCancel)). El resultado es tipado y necesita un selection set.

```graphql
query {
  ordersCancel(tracking_number: "SR123456789012") {
    result
    id
    message
    already_cancelled
    code
  }
}
```

**Verificación:** el detalle del pedido muestra `orders_status_id` `12`, y la misma cancelación devuelve `already_cancelled: true`. Cuando se rechaza una cancelación, se envía `order.cancel_failed` a `order_cancel_failed_webhook_url`.

## 13. Crear pedidos por lotes (opcional)

El ERP puede enviar los pedidos mayoristas y las recogidas de cajas del día en una sola petición. Cada fila admite los mismos campos que los pasos 6 y 7 y puede ser `type` `D` o `P`.

**REST:** `POST /api/v1/client/batchOrderCreate` — [Manual REST](/api/documentation#/paths/v1-client-batchOrderCreate/post) — responde cuando se ha procesado cada fila.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/batchOrderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-10-01" \
  -d '{
    "per_order_transaction": 1,
    "orders": [
      {
        "type": "D",
        "need_pick_up": 0,
        "ref": "WHS-20932",
        "name": "Sophie Roy",
        "company_name": "Boulangerie du Marché",
        "telephone": "5145550163",
        "address_1": "7070 Avenue Henri-Julien",
        "city": "Montréal",
        "province": "QC",
        "postcode": "H2S3S3",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "WHS-20932-1", "weight": 10, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      },
      {
        "type": "P",
        "need_pick_up": 1,
        "ref": "CRT-20932",
        "name": "Luc Gagnon",
        "company_name": "Épicerie Wellington",
        "telephone": "5145550187",
        "address_1": "4100 Rue Wellington",
        "city": "Verdun",
        "province": "QC",
        "postcode": "H4G1V5",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "CRT-20932-1", "weight": 4, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      }
    ]
  }'
```

```json
[
  { "result": true, "id": 12347, "ref": "WHS-20932", "tracking_number": ["SR123456789036"], "packages": [{ "id": 67892, "ref": "WHS-20932-1", "tracking_number": "SR123456789036", "external_tracking_number": "" }] },
  { "result": true, "id": 12348, "ref": "CRT-20932", "tracking_number": ["SR123456789043"], "packages": [{ "id": 67893, "ref": "CRT-20932-1", "tracking_number": "SR123456789043", "external_tracking_number": "" }] }
]
```

- Cada fila tiene su propio `result`; asóciela a su línea de pedido por `ref`. Una fila rechazada lleva `message` y `skipped_ref`, y puede llevar `code` (por ejemplo `INSUFFICIENT_BALANCE` o `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` confirma cada fila por separado, de modo que una fila fallida no puede revertir las demás.
- Los lotes de más de 100 pedidos reciben una cabecera de respuesta `X-Batch-Size-Warning`; envíelos al endpoint asíncrono.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [Manual REST](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — admite el mismo cuerpo y devuelve de inmediato un identificador de trabajo:

```json
{ "message": "Order batch created in async,please check later.", "asyncId": 28 }
```

Consulte `GET /api/v1/client/async/{id}` — [Manual REST](/api/documentation#/paths/v1-client-async-id/get) — con el `asyncId`, o reciba `order.create_async` en `order_create_async_postback_url`. El resultado del trabajo es la misma lista por filas que la del endpoint síncrono.

```bash
curl https://YOUR_HOST/api/v1/client/async/28 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**GraphQL:** `clientBatchOrderCreate` ([Manual GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreate)), `clientBatchOrderCreateAsync` ([Manual GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)) y `clientAsync` ([Manual GraphQL](/api/graphql/documentation#/client/clientAsync)).

**Verificación:** un lote de dos filas devuelve dos resultados, cada uno con su `ref`. El trabajo asíncrono devuelve las mismas filas una vez ejecutado.

## 14. Gestión de errores

| Situación | Estado HTTP | Código | Qué hace la integración |
|---|---|---|---|
| Falta un campo obligatorio o tiene un formato incorrecto (creación) | 400 | `VALIDATION_FAILED` | Corrija el campo indicado en `message` y envíe la petición de nuevo. |
| El saldo de la cuenta no cubre el pedido | 400 | `INSUFFICIENT_BALANCE` | Lea `insufficient_balance` (requerido, disponible, faltante); recargue y reintente. No se creó ningún pedido. |
| La dirección está fuera de la zona de servicio y la empresa elimina esos pedidos | 400 | `OUT_OF_DELIVERY_AREA` | Envíe una dirección dentro de la zona de servicio. No se creó ningún pedido. |
| Ya existe una `ref` de paquete o un número de seguimiento externo (con la deduplicación activada) | 200 (`result` `false`), o 409 con `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Lea `exist_package_ref` y vincule el pedido existente en lugar de crear uno nuevo. |
| Se reutiliza un `Idempotency-Key` con un cuerpo distinto | 409 | `IDEMPOTENCY_CONFLICT` | Utilice una clave nueva para una petición distinta. |
| Una petición con el mismo `Idempotency-Key` todavía está en curso | 409 | `IDEMPOTENCY_IN_PROGRESS` | Espere y reintente con la misma clave. |
| Cancelación sin identificador de pedido | 400 | `MISSING_IDENTIFIER` | Envíe uno de `order_id`, `tracking_number`, `external_tracking_number`. |
| Cancelación de un pedido que no existe | 400 | `ORDER_NOT_FOUND` | Compruebe el `id` o el número de seguimiento guardado. |
| El número coincide con más de un pedido activo | 409 | `MULTIPLE_ORDERS_MATCHED` | Cancele por `order_id`, con uno de los `matched_order_ids`. |
| El pedido pertenece a otra cuenta | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Cancele con la cuenta que creó el pedido. |
| El estado del pedido ya no permite cancelarlo | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Deje el pedido como está; gestione la devolución por separado. |
| El pedido está en manos de un transportista externo que no puede cancelarlo | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` o `THIRD_PARTY_CANCEL_FAILED` | El pedido no cambia; póngase en contacto con la empresa. |
| Falta el token o ha caducado, o la cuenta no puede realizar pedidos | 401 | — | Inicie sesión de nuevo; compruebe los permisos de la cuenta. |

## Lista de pruebas

Utilice referencias de prueba como `WHS-20931` y `CRT-20931`:

- [ ] (Opcional) La cotización devuelve un precio para un código postal dentro de la zona con `type` `D`.
- [ ] (Opcional) La cotización devuelve un precio para un código postal dentro de la zona con `type` `P`.
- [ ] Crear una entrega devuelve `id` + `tracking_number`; el mismo `Idempotency-Key` no crea un segundo pedido.
- [ ] Crear una recogida devuelve `id` + `tracking_number`; el detalle del pedido muestra `type` `P` y `need_pickup` `1`.
- [ ] El detalle del pedido y la lista muestran ambos pedidos en esta cuenta.
- [ ] El PDF de la etiqueta local se abre y muestra al destinatario o la dirección de recogida.
- [ ] El seguimiento público devuelve la línea de tiempo sin token; el evento más reciente es `100`.
- [ ] Llega `order.created` y su firma v2 se verifica.
- [ ] Cancelar devuelve `result: true`, y una segunda cancelación devuelve `already_cancelled: true`.
- [ ] Un lote de una entrega y una recogida devuelve dos resultados, cada uno con su `ref`.
