# Etiquetas de transportista

El servicio de etiquetas compra etiquetas de envío a los transportistas conectados a una cuenta (por ejemplo UPS y Canada Post) y conserva cada etiqueta como un pedido. Una integración lista los métodos de envío de la cuenta, cotiza un paquete, crea el pedido de etiqueta, compra la etiqueta en el servicio de transportista elegido, imprime el PDF, sigue el paquete y cancela las etiquetas que no se utilizan. Está destinado a tiendas en línea, sistemas de almacén y sistemas de gestión de pedidos que envían paquetes a través de transportistas y no con sus propios conductores.

## 1. Qué puede construir

Los ejemplos de esta guía siguen a una sola empresa: **Northbound Outfitters**, una tienda en línea de equipamiento para actividades al aire libre que envía desde su almacén en 1200 Eglinton Ave E, Toronto. Su cuenta tiene un método de Canada Post y un método de UPS. Un pedido típico es una caja con una tienda de campaña de 4,2 kg, de 60 × 30 × 25 cm, con destino a Calgary; los pedidos a Estados Unidos se envían por UPS.

- **Elección del transportista en el pago.** La tienda cotiza el carrito del cliente con Canada Post, muestra los servicios con precio y días de tránsito, y envía con el servicio que el cliente pagó.
- **Impresión de etiquetas con un clic en el almacén.** El puesto de embalaje crea el pedido de etiqueta cuando se embala una caja, compra la etiqueta en el servicio elegido e imprime el PDF del transportista en una impresora térmica.
- **Envíos internacionales con datos aduaneros.** Los pedidos a Estados Unidos llevan líneas de artículos (descripción, cantidad, valor, código HS) para que la etiqueta de UPS se emita con sus datos comerciales.
- **Actualizaciones automáticas de estado para el cliente.** La tienda guarda el número de seguimiento del transportista, muestra el historial de seguimiento público en la página del pedido y actualiza el pedido cuando un webhook `tracking.event` informa de que el paquete fue entregado.

## 2. Qué cubre esta guía

Esta guía cubre el servicio de etiquetas v1 (`/api/v1/labelservice/...`): un método de envío (una cuenta de transportista) por llamada. Utilícelo cuando la integración ya sabe con qué método de envío envía, o cuando mantiene una integración existente del servicio de etiquetas.

Para integraciones nuevas, Uniorder (`/api/v1/uniorder/...`) es el punto de entrada único recomendado. Las guías de Uniorder, «Uniorder: una API para cada envío» y «Cotización y pedido en un solo flujo», cotizan a la vez todos los servicios de transportista de la cuenta (junto con la entrega propia de la empresa, cuando corresponde) y compran la etiqueta en el servicio elegido devolviendo su `rate_id`. Las mismas llamadas imprimen, siguen y cancelan después cada pedido.

Otras guías cubren las demás familias de envíos:

- Entrega por los conductores propios de la empresa: «Recogida y entrega (flota propia)».
- Una cuenta de cliente que envía mediante los servicios que ofrece su empresa: «Servicios de envío».
- Mercancía guardada en un almacén y enviada a petición: «Almacenaje y salida».

## 3. Antes de empezar

- **Cuenta.** Utilice una cuenta de empresa (cliente), un empleado de esa cuenta o una cuenta de cliente de una empresa. Una cuenta de empresa ve sus propios métodos de envío. Una cuenta de cliente solo ve los métodos que su empresa le asignó, y cada etiqueta que compra se carga a su saldo; cuando la empresa activó Auto Pause Label Service para ese cliente, se rechaza una etiqueta mientras el saldo más el crédito no la cubra.
- **Permiso de API.** La cuenta debe tener habilitado el acceso a la API. Sin él, cada llamada al servicio de etiquetas devuelve `401` con `Unauthorized`.
- **Métodos de envío.** Al menos un método de envío debe estar activo en la cuenta (para clientes: asignado al cliente). Los identificadores de método cambian según la cuenta y no deben fijarse en el código; léalos en el paso 5.
- **Datos de prueba.** Utilice un método de envío de prueba o un entorno de pruebas del transportista cuando exista uno configurado (las tarifas llevan entonces `test_mode: true`), y un destino que usted controle. Cancele cada etiqueta de prueba que se haya comprado en un método real.
- **Gestión del token.** Inicie sesión desde su servidor y conserve allí el token. No coloque el token ni la contraseña en un navegador ni en una aplicación móvil.
- **Marcadores.** Sustituya `YOUR_HOST` por el host de su plataforma y `ACCESS_TOKEN` por el token del paso 4. Sustituya los valores de `shipping_method` por los id de su cuenta.

## 4. Autenticación

Cada llamada al servicio de etiquetas necesita un token bearer. La integración inicia sesión una vez, guarda `access_token` y `expires_at` en el servidor y vuelve a iniciar sesión antes de que el token caduque.

**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":"shipping@northbound-outfitters.ca","password":"your_password"}'
```

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

- `access_token`: envíelo en cada llamada posterior como la cabecera siguiente.
- `expires_at`: vuelva a iniciar sesión antes de esta hora.

```
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 solicitudes posteriores sin este token devuelven `401`.

## 5. Listar métodos de envío

La lista de métodos indica a la integración con qué cuentas de transportista puede enviar y qué opciones acepta cada una. Guarde el `id` de cada método que utilice; es el `shipping_method` de cada llamada posterior.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingMethodList \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"detail": true}'
```

```json
[
  {
    "id": 59,
    "name": "Canada Post",
    "unique_identifier": "CPC-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    },
    "package_type": {
      "parcel": {
        "name": "Parcel",
        "options": { "weight_options": true, "dimension_options": true }
      }
    },
    "from_contry_limit": ["CA"],
    "isUploadMethod": false
  },
  {
    "id": 61,
    "name": "UPS",
    "unique_identifier": "UPS-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    }
  }
]
```

Cada fila tiene:

| Campo | Uso |
|---|---|
| `id` | `shipping_method` en cada llamada posterior |
| `name` | Nombre para mostrar |
| `unique_identifier` | Código estable |
| `options.signature_option` | Firma disponible |
| `options.insurance_option` | Seguro disponible |
| `options.multi_package` | Más de una pieza |
| `package_type` | Códigos `package_type` aceptados y si cada uno necesita peso y dimensiones |
| `from_contry_limit` | Países en los que puede estar la dirección del remitente |
| `services` | Transportistas y servicios detrás del método; los códigos pueden restringir una cotización con `carriers` / `services` |

Envíe `"id": 59` para leer un solo método, o `"detail": false` para recibir solo `id`, `name` y `unique_identifier`.

**GraphQL:** `labelserviceGetShippingMethodList` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingMethodList)) (escalar JSON).

**Verificación:** la lista no está vacía. Eligió un `id` y sabe si ese método admite firma, seguro y varios paquetes. Una lista vacía significa que no hay ningún método habilitado en la cuenta.

## 6. Cotizar

Una cotización pide precios al transportista sin crear nada: el pedido temporal usado para la solicitud se elimina y no se cobra nada. Northbound Outfitters la solicita en el pago para mostrar los servicios de Canada Post del carrito. El cuerpo tiene la misma forma que en el paso 7. `shipping_method` es obligatorio.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "transit_days": 3,
      "test_mode": false
    },
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "transit_days": 2,
      "test_mode": false
    }
  ],
  "best_rate": {
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86,
    "transit_days": 3
  }
}
```

- `rates[]`: una entrada por servicio de transportista, con `price`, `currency`, `transit_days` y `price_detail` (cargo base, recargo por combustible, impuestos). Muéstrelos al cliente.
- `best_rate` / `shipping_price`: la primera tarifa devuelta por el método.
- Una cotización no lleva `rate_id` ni `id` de pedido. Las etiquetas se compran a partir de las tarifas del pedido creado en el paso 7.

`weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in.

Para más de una pieza, envíe `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`. `shipping_from` (id de la libreta de direcciones) o `shipping_from_code` pueden sustituir el bloque `sender_*`. `carriers` y `services` restringen la cotización a los códigos indicados.

**GraphQL:** `labelserviceRate` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceRate)).

**Verificación:** `result` es true y tiene un precio (y días de tránsito, si el transportista los envía). Si no hay tarifa, corrija destino / paquete / método **antes** de crear.

## 7. Crear el pedido de etiqueta

Esta llamada crea el pedido de etiqueta y pide al transportista las tarifas de ese envío. Devuelve el `id` del pedido y un `rate_id` por servicio. En este punto la etiqueta aún no se ha comprado y no se cobra nada; el paso 8 la compra. Northbound Outfitters la realiza cuando la caja está embalada y guarda `id` junto a su pedido `NB-10482`.

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

El mismo cuerpo que en el paso 6. Envíe `Idempotency-Key`: un reintento con la misma clave y el mismo cuerpo devuelve la primera respuesta en lugar de crear un segundo pedido.

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-label" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "id": 128455,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
      "carrier_name": "canadapost",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "currency": "CAD",
      "transit_days": 3
    },
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c11",
      "carrier_name": "canadapost",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "currency": "CAD",
      "transit_days": 2
    }
  ],
  "best_rate": {
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86
  }
}
```

| Campo | Uso |
|---|---|
| `id` | Id de pedido Superroute — compra, descarga y cancelación |
| `rates[].rate_id` | El servicio que se compra en el paso 8; válido solo para este pedido |
| `rates[].price` | Precio de ese servicio |
| `shipping_price` | Precio de `best_rate` |

Un envío a Estados Unidos pasa por el método de UPS con las líneas de artículos que exige la aduana. Este ejemplo utiliza la forma `packages`, que lleva los artículos por caja:

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10497-label" \
  -d '{
    "shipping_method": 61,
    "name": "Daniel Price",
    "telephone": "2065550117",
    "email": "daniel.price@example.com",
    "address_1": "500 Mercer St",
    "city": "Seattle",
    "province": "WA",
    "postcode": "98109",
    "country": "US",
    "package_type": "parcel",
    "paid_by": 1,
    "ref": "NB-10497",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA",
    "packages": [
      {
        "ref": "NB-10497-1",
        "weight": 2.6,
        "length": 45,
        "width": 30,
        "height": 20,
        "weight_unit": 2,
        "dimension_unit": 2,
        "items": [
          {
            "name": "Down sleeping bag",
            "description": "Down-filled sleeping bag, -7 C rating",
            "quantity": 1,
            "unit_price": 289.00,
            "currency": "CAD",
            "weight": 1.6,
            "hscode": "9404400000",
            "sku": "NB-SB-7C",
            "unit": "PCS"
          },
          {
            "name": "Camp stove",
            "description": "Canister camp stove",
            "quantity": 1,
            "unit_price": 79.00,
            "currency": "CAD",
            "weight": 1.0,
            "hscode": "7321111000",
            "sku": "NB-ST-01",
            "unit": "PCS"
          }
        ]
      }
    ]
  }'
```

**GraphQL:** `labelserviceSubmitOrder` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)). El cuerpo REST va en `input`:

```graphql
mutation {
  labelserviceSubmitOrder(input: {
    shipping_method: 59
    name: "Emily Tremblay"
    telephone: "4035550182"
    address_1: "1415 17 Ave SW"
    city: "Calgary"
    province: "AB"
    postcode: "T2T0C8"
    country: "CA"
    weight: 4.2
    length: 60
    width: 30
    height: 25
    dimension_unit: 2
    weight_unit: 2
    package_type: "parcel"
    ref: "NB-10482"
    sender_name: "Northbound Outfitters"
    sender_telephone: "4165550140"
    sender_address_1: "1200 Eglinton Ave E"
    sender_city: "Toronto"
    sender_province: "ON"
    sender_postcode: "M3C1H9"
    sender_country: "CA"
  })
}
```

**Verificación:** la respuesta tiene un `id` y al menos un `rates[].rate_id`. Guarde ambos. El mismo `Idempotency-Key` con el mismo cuerpo devuelve el mismo `id` y no crea un segundo pedido.

## 8. Comprar la etiqueta y leer el detalle del envío

Esta llamada compra la etiqueta en el servicio elegido, la cobra y devuelve los números de seguimiento del transportista. Cuando la etiqueta ya está comprada, solo lee el detalle, de modo que una llamada repetida nunca compra dos veces. Northbound Outfitters envía el `rate_id` del servicio que el cliente pagó.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingDetail \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  }'
```

```json
{
  "id": 128455,
  "shippingPrice": "24.86",
  "mainTrackingNumber": "7023210039414604",
  "trackingNumber": "7023210039414604",
  "needSubmitShippingInformation": false,
  "rate": {
    "carrier_name": "canadapost",
    "price": 24.86,
    "price_detail": [
      { "name": "Base charge", "amount": 18.40 },
      { "name": "Fuel surcharge", "amount": 3.60 },
      { "name": "GST", "amount": 1.10 }
    ],
    "tax_items": ["HST", "GST", "PST", "QST"]
  },
  "labelStatus": "ready",
  "shippingLabel": "JVBERi0xLjQKMS... (base64 encoded)"
}
```

| Campo | Uso |
|---|---|
| `mainTrackingNumber` | Número de seguimiento del transportista del primer paquete; facilíteselo al cliente |
| `trackingNumber` | Números de seguimiento del transportista de todos los paquetes, separados por comas |
| `shippingPrice` | Importe cobrado |
| `labelStatus` | `ready`: `shippingLabel` contiene el PDF. `pending`: comprada y cobrada, el transportista aún no ha generado el archivo; vuelva a llamar más tarde. `failed`: la obtención en segundo plano se abandonó; volver a llamar la reinicia |
| `needSubmitShippingInformation` | `true` cuando este método necesita que se envíe la información del envío (paso 13) |

`type` puede ser `ORDER_ID` (predeterminado), `TRACKING_NUMBER` (el número de paquete de Superroute) o `THIRD_PARTY_TRACKING_NUMBER` (el número del transportista). Envíe `rate_id` para que la etiqueta se compre en el servicio que eligió; sin él, el método compra con su tarifa predeterminada.

**GraphQL:** `labelserviceGetShippingDetail` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingDetail))

```graphql
mutation {
  labelserviceGetShippingDetail(
    id: "128455"
    type: "ORDER_ID"
    rate_id: "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  )
}
```

**Verificación:** `mainTrackingNumber` no está vacío y `labelStatus` es `ready` (o `pending`, que pasa a `ready` en una llamada posterior). Una segunda llamada devuelve el mismo número de seguimiento y el mismo `shippingPrice`.

## 9. Descargar el PDF

El almacén imprime la etiqueta del transportista a partir de esta llamada. Si la etiqueta aún no se ha comprado, la primera llamada la compra con la tarifa predeterminada, como en el paso 8; llame primero al paso 8 para fijar el servicio.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "base64": 1
  }'
```

```json
"JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+..."
```

- Con `base64: 1` el cuerpo es el PDF como una sola cadena base64; decodifíquela y envíela a la impresora.
- Con `base64: 0` la respuesta es el propio archivo PDF (`application/pdf`).

`type` puede ser `ORDER_ID` (predeterminado), `TRACKING_NUMBER` o `THIRD_PARTY_TRACKING_NUMBER` (el número del transportista). Esta es la **etiqueta oficial del transportista**. El número de piezas lo fija la reserva.

**GraphQL:** `labelserviceGetShippingLabel` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)). GraphQL siempre devuelve la cadena base64.

**Verificación:** el PDF se abre y muestra el código de barras / número de seguimiento del transportista del paso 8. Imprima una copia de prueba y deséchela; no entregue una etiqueta de prueba a un transportista.

## 10. Seguir

La tienda muestra el avance del paquete en la página del pedido del cliente. El endpoint de seguimiento público no necesita token y acepta el número del transportista del paso 8.

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

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

```json
{
  "result": true,
  "is_third_party_tracking": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 430,
      "otep_status": "in_transit",
      "description": "Item in transit",
      "location_city": "Mississauga",
      "updated_at_localized": "2026-09-29 18:42"
    }
  ]
}
```

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

```graphql
query {
  trackingPublic(trackingNumber: "7023210039414604") {
    result
    deliveried
    is_third_party_tracking
    data {
      tracking_event_status_id
      otep_status
      description
      updated_at_localized
    }
    third_party_info { tracking_number status carrier_tracking_link }
    proofs { file_id type full_url signed_url }
  }
}
```

- `is_third_party_tracking` es true cuando los eventos provienen del transportista.
- `data`: el evento más reciente va primero. Bifurque por `tracking_event_status_id` / `otep_status`, no por `description`. Los primeros eventos pueden seguir siendo «información enviada» hasta que el transportista escanee el paquete.
- `deliveried` es true y `500` significa entregado; `proofs[]` puede incluir entonces firma (`type` `1`) o foto (`type` `2`).

**Verificación:** la consulta devuelve el envío que acaba de crear. Un número desconocido o cancelado devuelve `404` con `result: false`.

## 11. Configurar notificaciones de eventos

Los webhooks sustituyen al sondeo: el servidor de la tienda recibe cada escaneo del transportista y actualiza el pedido sin llamar al paso 10 de forma periódica.

| Ajuste | Evento | Cuándo |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | Escaneos del transportista, en reparto, entregado |
| `order_status_change_webhook_url` | `order.status_change` | Estado en su sistema |
| `order_create_webhook_url` | `order.created` | Se creó un pedido de etiqueta (paso 7); se envía para pedidos de etiqueta solo cuando `order_created_webhook_all_types` es `1` |

**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://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "tracking_event_webhook_url",
    "order_create_webhook_url",
    "order_created_webhook_all_types",
    "webhook_sign_secret",
    "webhook_verify_ssl"
  ],
  "recipient_type": "business",
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_verify_ssl": 1
  }
}
```

- Solo se modifican las claves que envía; una clave desconocida se rechaza con `400`.
- `changed_keys` enumera lo que se guardó. El secreto siempre se devuelve enmascarado.

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

Cada `tracking.event` lleva `order_id`, `tracking_event_status_id`, `tracking_event_key`, `tracking_number` y `external_tracking_number`; asócielo a su pedido mediante `order_id` (el `id` del paso 7).

Verifique **v2** sobre el cuerpo sin procesar: `HMAC_SHA256(timestamp + "." + raw_body, secret)` contra `X-Webhook-Signature-V2`. Elimine duplicados 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;
}
```

`order_cancel_failed_webhook_url` (`order.cancel_failed`) no se envía para las cancelaciones de etiquetas del paso 12; una cancelación de etiqueta rechazada se comunica en la respuesta de esa llamada.

**Verificación:** un `submitOrder` de prueba produce `order.created` con el `id` del pedido, y el primer escaneo del transportista produce `tracking.event`. El receptor debe rechazar una firma no válida con `401`.

## 12. Cancelar

Una etiqueta que no se va a enviar se cancela para que el transportista no la facture; el cargo se reembolsa a la cuenta. La cancelación solo es posible mientras el transportista aún la permita (normalmente antes de la recogida).

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/cancelShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-cancel" \
  -d '{"id": 128455}'
```

```json
{
  "result": true,
  "message": "Shipping Label cancelled successfully"
}
```

- Envíe exactamente uno de `id` (el id del pedido) o `tracking_number` (el número de seguimiento de Superroute o del transportista). Enviar ambos devuelve `400`.
- `result: true`: el transportista aceptó la cancelación y el cargo de la etiqueta se reembolsó.

**GraphQL:** `labelserviceCancelShippingLabel` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceCancelShippingLabel)).

Un transportista que ya tiene el paquete la rechaza: la respuesta es `400` con `result: false` y el mensaje del transportista. Un pedido cuya etiqueta nunca se compró no puede cancelarse con esta llamada.

**Verificación:** la respuesta es `result: true`, y el seguimiento público de ese número devuelve `404`. Un reintento con el mismo `Idempotency-Key` devuelve la respuesta guardada; una nueva solicitud de cancelación para el mismo pedido devuelve `400` `This order already cancelled`.

## 13. Enviar la información del envío y cerrar el día (solo si este método lo exige)

Algunos transportistas necesitan que se transmitan los envíos del día (un manifiesto) antes de la recogida. El paso 8 lo indica por pedido en `needSubmitShippingInformation`. Reúna esos id de pedido durante el día y envíelos después de la última etiqueta.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitShippingInformation \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ids": [128455, 128461, 128470]}'
```

```json
{
  "result": true,
  "message": "Processed 3 orders. Success: 3, Failed: 0",
  "data": {
    "total_processed": 3,
    "success_count": 3,
    "failure_count": 0,
    "details": [
      { "order_id": 128455, "result": true, "message": "Successful" },
      { "order_id": 128461, "result": true, "message": "Successful" },
      { "order_id": 128470, "result": true, "message": "Successful" }
    ]
  }
}
```

- `details[]`: una línea por pedido; vuelva a enviar los pedidos con `result: false` después de corregir el motivo indicado en `message`.
- `404` `No eligible orders found for shipping information submission`: ninguno de los id tiene una etiqueta comprada que aún necesite envío de información.

**GraphQL:** `labelserviceSubmitShippingInformation` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceSubmitShippingInformation)).

A continuación, cierre el día. La llamada no lleva cuerpo y abarca todos los pedidos de etiqueta del llamante.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/endofday \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "success": 0,
  "failed": 0,
  "success_ids": [],
  "failed_ids": []
}
```

- `400` con `There are orders need to submit shipping information`: algunas etiquetas compradas aún necesitan envío de información; envíelas con `submitShippingInformation` y vuelva a llamar.

**GraphQL:** `labelserviceEndofday` ([Manual GraphQL](/api/graphql/documentation#/labelservice/labelserviceEndofday)).

Omita este paso cuando ningún pedido del día haya indicado `needSubmitShippingInformation: true`.

**Verificación:** `submitShippingInformation` indica `failure_count: 0` y `endofday` responde `200`. Ejecútelo primero en un método de prueba.

## 14. Gestión de errores

Los errores del servicio de etiquetas llevan un `message`; un `code` solo está presente donde la tabla lo indica.

| Situación | Estado HTTP | Código | Qué hace la integración |
|---|---|---|---|
| Token ausente o caducado, o acceso a la API no habilitado | `401` | — (`Unauthorized`) | Vuelva a iniciar sesión; si persiste, pida a la empresa que habilite el acceso a la API |
| `shipping_method` ausente o no disponible para el llamante | `400` | — | Vuelva a cargar la lista de métodos (paso 5) y use un `id` de ella |
| `package_type` no ofrecido por el método | `400` | — | Use una clave de `package_type` del paso 5 |
| Dirección o paquete no válidos, o el transportista no devuelve ninguna tarifa | `400` | — (mensaje del transportista) | Muestre el mensaje, corrija los datos y vuelva a cotizar |
| `auto_deduplication` es `1` y el `ref` ya existe | `400` | — (`exist_order_ids`) | Use el pedido existente de `exist_order_ids` en lugar de crear uno nuevo |
| El mismo `Idempotency-Key` con un cuerpo distinto | `409` | `IDEMPOTENCY_CONFLICT` | Use una clave nueva para una solicitud distinta |
| El mismo `Idempotency-Key` mientras la primera solicitud sigue en curso | `409` | `IDEMPOTENCY_IN_PROGRESS` | Espere los segundos de `Retry-After` y reintente con la misma clave y el mismo cuerpo |
| El saldo más el crédito del cliente no cubre la etiqueta | `400` | `INSUFFICIENT_BALANCE` | Recargue con el detalle `insufficient_balance` (`shortfall`, `add_funds_url`) y vuelva a llamar al paso 8 |
| Etiqueta comprada, archivo del transportista aún no disponible | `400` en la primera compra, `200` después | `shipment_label_not_ready` | Espere mientras `labelStatus` sea `pending`; vuelva a llamar al paso 8 cuando sea `failed` |
| Id o número de pedido que no pertenece al llamante | `401` | — (`Not Auth`) | Compruebe el id y `type`; use la cuenta que creó el pedido |
| Cancelación rechazada por el transportista, o el pedido ya está cancelado | `400` | — | Trate la etiqueta como enviada (o ya cancelada); no reintente |
| Número de seguimiento desconocido o cancelado | `404` | — | Deje de mostrar el historial de ese número |
| `endofday` con envíos cuya información aún no se ha enviado | `400` | — | Ejecute `submitShippingInformation` para esos pedidos y vuelva a llamar |

## Lista de pruebas

Use un destino que controle y un método que se pueda cancelar:

- [ ] La lista de métodos no está vacía; capturó un `id`.
- [ ] La tarifa devuelve un precio para ese método y destino.
- [ ] Submit devuelve un `id` de pedido y `rates[].rate_id`; el mismo `Idempotency-Key` no crea un segundo pedido.
- [ ] `getShippingDetail` con el `rate_id` elegido devuelve `mainTrackingNumber`; una segunda llamada no vuelve a cobrar.
- [ ] El PDF de la etiqueta se abre y muestra el número de seguimiento del transportista.
- [ ] El seguimiento público encuentra el envío por ese número.
- [ ] Llega `tracking.event` (y `order.created`, cuando está habilitado); la firma v2 verifica.
- [ ] Un envío internacional de prueba con `items` es aceptado por el transportista.
- [ ] Cancelar funciona, **o** confirmó que este método no se puede cancelar después de reservar.
- [ ] Si el método necesita cierre de día, una ejecución de prueba termina sin error.
