# Serviços de envio

A API de serviços de envio permite a uma conta de cliente reservar os serviços de envio que o seu prestador logístico configurou e lhe atribuiu. O sistema do próprio cliente lista os serviços que pode usar, carrega as regras de um serviço, calcula o preço de um envio, cria o pedido de envio, paga-o a partir do saldo da conta e acompanha o envio até à entrega. Este guia destina-se a programadores que ligam o sistema de um importador, de um comerciante ou de um grossista ao prestador logístico que o serve.

## 1. O que pode construir

Todos os exemplos deste guia usam um único cenário. A **Harbourline Imports Inc.**, uma importadora de chá em Toronto, tem uma conta de cliente junto do seu prestador logístico. O prestador oferece o serviço `intl_express` (International Express) a partir do seu armazém Toronto Hub (id de armazém `7`). A Harbourline entrega duas caixas de amostras de chá no Toronto Hub para um distribuidor em Seattle, com a sua ordem de compra `HLI-PO-1058`.

- **Reserva a partir do sistema de ordens de compra.** Quando uma ordem de compra é libertada, o sistema da Harbourline calcula o preço do envio em `intl_express`, cria o pedido de envio com o número da ordem de compra como referência e paga-o a partir do saldo pré-pago da conta, sem que ninguém abra o portal do prestador.
- **Uma verificação de preço antes do compromisso.** O comprador da Harbourline vê o frete, as sobretaxas, o imposto e o total das duas caixas antes de o envio ser reservado, e um envio cujo preço o serviço não consegue calcular é travado antes de existir um pedido.
- **Estado do envio dentro do ERP.** O número de rastreio de cada caixa é guardado associado à ordem de compra; os webhooks levam o estado do pedido e a linha temporal de rastreio para o ERP, e um job noturno faz a reconciliação com a lista de pedidos.
- **Alterações controladas.** Uma reserva não paga é corrigida no próprio pedido, e uma reserva que deixou de ser necessária é cancelada, com o montante pago devolvido ao crédito da conta.

## 2. O que este guia abrange

Use esta família quando quem faz a chamada é um **cliente** da empresa de logística e reserva um dos serviços de envio próprios da empresa: a empresa define o plano de preços, os armazéns, as sobretaxas e as embalagens, e atribui os serviços ao cliente. O cliente só vê e reserva os serviços que lhe estão atribuídos.

Use outra família nos seguintes casos:

- Quem faz a chamada é a própria empresa de logística (uma conta de empresa/cliente) e reserva recolhas e entregas locais ou no próprio dia com a sua frota: leia **Recolha e entrega (frota própria)**.
- Quem faz a chamada compra etiquetas de transportadora (por exemplo UPS ou FedEx) às tarifas negociadas da conta: leia **Etiquetas de transportadora**.
- O cliente guarda mercadorias no armazém do prestador e expede-as a partir do stock: leia **Armazenamento e saída**.

**Uniorder: uma API para cada envio** (`/api/v1/uniorder/...`) é o ponto de entrada único recomendado para novas integrações de entrega local e de etiquetas de transportadora. O Uniorder não abrange os serviços de envio: os pedidos de serviços de envio só são criados e geridos através dos endpoints `/api/v1/customer/shipping-orders/...` aqui descritos.

## 3. Antes de começar

- **Tipo de conta.** Uma conta de **cliente** da empresa de logística, com a **permissão de API** ativada pela empresa. Uma conta de empresa/cliente ou de funcionário não pode iniciar sessão através do login de cliente abaixo.
- **Atribuição de serviços.** A empresa tem de atribuir ao cliente pelo menos um serviço de envio ativo. Um cliente sem serviço atribuído recebe uma lista de serviços vazia.
- **Dados de teste.** Acorde com a empresa um código de serviço de teste, um armazém de teste e um pequeno saldo pré-pago na conta de teste. Use uma referência como `HLI-PO-1058` ou `DEV-SHIP-001` para que os pedidos de teste sejam fáceis de encontrar e cancelar.
- **Tratamento do token.** Chame a API apenas a partir do seu servidor. Mantenha a palavra-passe e o token de acesso fora de navegadores e clientes móveis. O token expira uma semana após o login (`expires_at`); inicie sessão outra vez antes de expirar.
- **Marcadores.** Substitua `YOUR_HOST` pelo nome do anfitrião da empresa de logística e `ACCESS_TOKEN` pelo token devolvido no passo de login.
- **Erros em JSON.** Envie `Accept: application/json` em cada pedido HTTP para que os erros de validação devolvam JSON em vez de um redirecionamento.

## 4. Iniciar sessão como cliente

O login troca o email e a palavra-passe do cliente por um token bearer. Todas as chamadas posteriores deste guia enviam esse 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`: envie-o em cada pedido HTTP como `Authorization: Bearer ACCESS_TOKEN`. O GraphQL usa o mesmo cabeçalho em `POST /api/graphql`.
- `expires_at` / `expires_timestamp`: programe um novo login antes desta hora.

**Verificação:** a resposta tem `result: true` e um `access_token`. Um pedido HTTP sem o token devolve `401`; um login com uma conta que não é de cliente, ou que não tem permissão de API, também devolve `401`.

## 5. Listar os serviços atribuídos ao cliente

A lista de serviços indica à integração que códigos de serviço pode reservar e se cada serviço aceita uma entrega no armazém, uma recolha, ou ambas. Guarde o `service_code`; todas as chamadas de serviço posteriores o utilizam.

**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`: o parâmetro de caminho de todas as chamadas de serviço posteriores.
- `offer_pickup` / `allow_warehouse_delivery`: os valores permitidos de `origin_type` (`pickup` / `warehouse`).
- `support_multi_package`: se um pedido pode ter mais de uma linha de volumes.
- Um array `services` vazio significa que nenhum serviço está atribuído a este cliente.

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

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

**Verificação:** a lista contém pelo menos um serviço e guardou o respetivo `service_code` (neste guia: `intl_express`).

## 6. Carregar a configuração do serviço

A configuração devolve tudo aquilo de que o formulário de pedido de um serviço precisa: os armazéns que aceitam entregas, as sobretaxas selecionáveis, o catálogo de embalagens e consumíveis, as unidades e os países em que o serviço pode recolher e para os quais pode entregar. Valide os dados do seu pedido com base nela antes de calcular o preço ou de criar o que quer que seja.

**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`: o `warehouse_id` a enviar quando `origin_type` é `warehouse`. Um id que não esteja nesta lista é recusado na criação.
- `service.weight_mode`: os campos de volume que o plano de preços exige: `0` peso real (peso), `1` peso volumétrico (comprimento, largura e altura), `2` peso faturável (ambos). `null` significa que o preço do serviço é definido manualmente. Envie o peso e as três dimensões para satisfazer todos os modos.
- `delivery_allowed_countries` / `pickup_allowed_countries`: recuse um país de destino ou de recolha fora destas listas antes de chamar a estimativa.
- `surcharges[].id`, `packagings[].id`, `products[].id`: os ids a usar para sobretaxas opcionais, embalagens e compras de consumíveis.
- `weight_units` / `dimension_units`: as unidades dos volumes são enviadas como números. Envie `weight_unit: 2` (kg) e `dimension_unit: 2` (cm), como fazem todos os exemplos deste guia; ambos são também os valores predefinidos quando os campos são omitidos.

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

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

**Verificação:** `result` é `true` e, para uma entrega no armazém, `warehouses` contém o armazém que pretende usar. `403` significa que o serviço não está atribuído a este cliente; `404` significa que o código de serviço não existe ou está inativo.

## 7. Estimar o preço

A estimativa calcula o preço do envio com o plano de preços do serviço sem gravar nada. Mostre o total ao comprador e não crie o pedido quando a estimativa indicar uma recusa.

**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` (o cliente entrega a mercadoria num armazém; envie `warehouse_id`) ou `pickup` (o prestador recolhe; envie `pickup_postcode` e `pickup_country`). Use apenas um valor que o passo 5 permita.
- `packages`: uma linha por grupo de volumes idênticos; `quantity` multiplica a linha.
- `total` e `currency`: o montante a apresentar. `total` é `null` enquanto houver alguma taxa não calculada.
- `needs_manual_quote` / `has_items_needing_quote`: a empresa define o preço do pedido manualmente; o pedido pode ser criado e é pago depois de a empresa definir o preço.
- `refused` / `refusal_message`: o serviço recusa envios cujo preço não consegue calcular. Não crie o pedido; mostre antes `refusal_message`.
- Entradas opcionais: `surcharges`, `products` (um mapa de id de produto para quantidade, considerado apenas quando `allow_purchase_supplies` é true), `has_special_requirements`, `coupon_code`.

**Verificação:** `result` é `true`, `refused` é `false`, e `total` tem um valor ou `needs_manual_quote` é `true`.

## 8. Criar o pedido de envio

A chamada de criação reserva o envio no serviço. A integração guarda o `id` devolvido associado à sua própria ordem de compra; todas as chamadas posteriores usam 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)

Envie uma `Idempotency-Key` derivada do seu próprio id estável (aqui, o número da ordem de compra). Uma repetição com a mesma chave e o mesmo corpo devolve a primeira resposta com `"replayed": true` e o cabeçalho `Idempotency-Replayed: true`, e não cria um 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
  }
}
```

- A chave do corpo para os volumes é `package` na criação (é `packages` na estimativa). Cada linha com `quantity` N passa a N volumes, e cada volume recebe o seu próprio número de rastreio.
- Campos obrigatórios: `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight`; mais `warehouse_id` para `warehouse`, ou `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` para `pickup`.
- Campos opcionais: `reference` (guardado como `reference_number` do pedido), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (um array de linhas de texto, considerado apenas quando o serviço o permite), `products`, `surcharges`, `coupon_code`.
- `id`: guarde-o. `status` `0` é Pendente (a aguardar pagamento).
- `total_price`: o montante que o passo 9 cobra. É `0` enquanto o pedido aguarda uma cotação manual.
- `tracking_number` ao nível do pedido é `null`; os números de rastreio estão nos volumes e são lidos no passo 10.
- O endpoint responde HTTP `201` para um pedido novo.

**Verificação:** a resposta tem `result: true` e um `id`. Repetir o mesmo pedido HTTP com a mesma `Idempotency-Key` devolve o mesmo `id` com `"replayed": true`.

## 9. Pagar o pedido a partir do saldo da conta

Os pedidos de envio são pagos na totalidade a partir do saldo da conta do cliente. Um pedido pago passa de Pendente a Confirmado, e o prestador começa a tratá-lo.

Leia primeiro o montante:

**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 }
    ]
  }
}
```

Em seguida, 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`: quando o saldo não cobre `remaining_balance`, carregue a conta antes de pagar.
- `payment_type`: só é suportado `remaining_balance`; é sempre cobrado o montante restante completo.
- `data.status` `1` é Confirmado.

**Verificação:** a chamada de pagamento devolve `result: true` e `status` `1`, e uma segunda chamada `payment-info` devolve `400` porque o pedido está totalmente pago. Uma chamada de pagamento sem saldo suficiente devolve `422` e não cobra nada.

## 10. Consultar o pedido e rastrear os volumes

A chamada de detalhe devolve o estado atual e o número de rastreio de cada volume. Guarde os números de rastreio dos volumes associados à ordem de compra; o rastreio público aceita cada um deles.

**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` Pendente, `1` Confirmado, `2` Em trânsito, `3` Expedido, `4` Cancelado, `5` Falhado, `6` Parcialmente recolhido, `7` Recolhido, `8` Em processamento.
- `can_edit` / `can_cancel`: se o passo 12 é permitido neste momento.
- `packages[].tracking_number`: os números a guardar e a rastrear.
- `shipping_code`: o código que os ecrãs de entrega no armazém aceitam; imprima-o na documentação 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 reconciliar todos os pedidos de um serviço, por exemplo num job noturno, liste-os com um filtro. O filtro `id` corresponde ao id do pedido, a um número de rastreio ou à referência.

**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))

O rastreio público não exige token e devolve a linha temporal de eventos de um volume:

**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
    }
  }
}
```

**Verificação:** o detalhe devolve o pedido deste cliente com um número de rastreio por volume, e o rastreio público devolve `result: true` para o número de rastreio de um volume. O id de pedido de outro cliente devolve `404`.

## 11. Receber webhooks

Os webhooks entregam ao seu servidor a criação de pedidos, as alterações de estado e os eventos de rastreio, pelo que a integração não precisa de fazer consultas periódicas. A conta de cliente configura os seus próprios URLs de webhook e o seu segredo de assinatura.

Quando é criado um pedido de envio, o prestador cria também um pedido de recolha associado para a sua equipa de expedição. Os webhooks são enviados para esse pedido associado: o seu `ref` é `Shipping-Pickup-{shipping order id}` (por exemplo `Shipping-Pickup-9001`), e cada um dos seus volumes traz o número de rastreio do volume de envio em `external_tracking_number`. Associe os eventos recebidos através destes dois campos.

| Definição | Evento | O que a integração faz |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Associa o evento ao pedido de envio através de `ref` e `packages[].external_tracking_number` |
| `tracking_event_webhook_url` | `tracking.event` | Acrescenta o evento à linha temporal do volume |
| `order_status_change_webhook_url` | `order.status_change` | Atualiza o estado apresentado no seu 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"
  }
}
```

- Só as chaves enviadas são alteradas; uma cadeia vazia apaga um URL. `webhook_sign_secret` tem de ter entre 16 e 255 caracteres, e nenhum webhook é enviado enquanto o segredo estiver vazio.
- `recipient_type` é `customer` para uma conta 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 a assinatura **v2** sobre o corpo em bruto: `HMAC_SHA256(timestamp + "." + raw_body, secret)` contra `X-Webhook-Signature-V2`. Deduplique em `X-Webhook-Event-Id`. Responda **2xx em menos de 3 segundos** e processe o evento depois.

```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;
}
```

**Verificação:** depois da chamada de definições, uma criação de teste produz um evento `order.created` cujo `ref` é `Shipping-Pickup-{id}` para o id do novo pedido de envio, e a verificação da assinatura é bem-sucedida.

## 12. Alterar ou cancelar um pedido

Um pedido pode ser corrigido enquanto está Pendente (antes do pagamento) e cancelado enquanto está Pendente ou Confirmado. O cancelamento de um pedido pago devolve o montante pago ao crédito da conta.

Para alterar, envie outra vez o pedido completo com os mesmos campos do passo 8. O preço é recalculado.

**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 cancelar:

**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` é Cancelado. O pedido de recolha associado é removido.
- `refund_amount`: o montante devolvido ao crédito da conta; `0` para um pedido não pago.
- Leia `can_edit` e `can_cancel` do passo 10 antes de oferecer estas ações aos utilizadores.

**Verificação:** o cancelamento devolve `status` `4` e o detalhe mostra `status_name` `Cancelled`. Um segundo cancelamento, ou o cancelamento de um pedido Em trânsito ou numa fase posterior, devolve `403` com a mensagem `This order can no longer be cancelled.`; a alteração de um pedido pago devolve `403`.

## 13. Tratamento de erros

| Situação | Estado HTTP | Código | O que a integração faz |
|---|---|---|---|
| Token em falta, expirado ou inválido; login com uma conta que não é de cliente ou sem permissão de API | `401` | — | Inicie sessão outra vez; se o próprio login falhar, peça à empresa que verifique o tipo de conta e a permissão de API |
| O serviço não está atribuído a este cliente | `403` | — | Volte a ler a lista de serviços (passo 5) e reserve apenas serviços atribuídos |
| A API é chamada a partir de uma sessão de aplicação da plataforma cuja aplicação tem os pedidos de envio desativados | `403` | `APP_CAPABILITY_DISABLED` | Peça à empresa que ative os pedidos de envio para a aplicação |
| Código de serviço desconhecido ou inativo; id de pedido não encontrado para este cliente | `404` | — | Atualize a lista de serviços; verifique o id de pedido guardado |
| Campo obrigatório em falta ou inválido | `422` | — | Leia `errors` no corpo, corrija os campos e envie outra vez |
| Tipo de origem não oferecido pelo serviço, ou armazém fora da lista do serviço | `422` | — | Use um `origin_type` e um `warehouse_id` dos passos 5 e 6 |
| O serviço não consegue calcular o preço do envio e recusa envios sem preço | `422` | `unpriced_refused` | Nada foi criado; mostre `message` e não repita sem alterações |
| Consumíveis encomendados sem stock | `422` | — | Leia `stock_shortages`, reduza as quantidades e envie outra vez |
| A mesma `Idempotency-Key` com um corpo diferente | `409` | `IDEMPOTENCY_CONFLICT` | Use uma chave nova para um pedido novo; nunca reutilize uma chave para conteúdo diferente |
| Uma repetição enquanto o primeiro pedido HTTP com essa chave ainda está a ser processado | `409` | `IDEMPOTENCY_IN_PROGRESS` | Aguarde `Retry-After` segundos e tente de novo com a mesma chave e o mesmo corpo |
| Pagamento sem saldo suficiente | `422` | — | Carregue a conta e pague outra vez |
| Informação de pagamento ou pagamento de um pedido totalmente pago | `400` | — | Trate o pedido como pago; leia o detalhe |
| Cancelamento depois de o pedido ter saído de Pendente ou Confirmado | `403` | — | Indique que o pedido já não pode ser cancelado; contacte a empresa |
| Alteração depois do pagamento | `403` | — | Cancele e crie um pedido novo, ou contacte a empresa |
| Erro do servidor durante a estimativa, a criação, o pagamento ou o cancelamento | `500` | — | Repita uma vez; na criação, repita com a mesma `Idempotency-Key` |

## Lista de testes

Use uma referência de teste como `DEV-SHIP-001` ou `HLI-PO-1058`:

- [ ] O login de cliente devolve `access_token`; um pedido HTTP sem o token devolve `401`.
- [ ] A lista de serviços não está vazia e guardou um `service_code`.
- [ ] A configuração devolve os armazéns, as unidades e os países permitidos para esse serviço, e o seu formulário usa-os.
- [ ] A estimativa devolve um `total` (ou `needs_manual_quote: true`), e um envio recusado não é criado.
- [ ] A criação devolve um `id`; a mesma `Idempotency-Key` com o mesmo corpo devolve o mesmo `id` com `"replayed": true`.
- [ ] O pagamento é bem-sucedido e o estado passa a Confirmado, ou confirmou que um saldo insuficiente devolve `422` e não cobra nada.
- [ ] O detalhe mostra o pedido deste cliente com um número de rastreio por volume, e o rastreio público encontra cada volume.
- [ ] Os webhooks estão configurados com um segredo de assinatura; uma criação de teste produz `order.created` com `ref` `Shipping-Pickup-{id}` e a verificação da assinatura é bem-sucedida.
- [ ] O cancelamento do pedido de teste devolve `status` `4` e o `refund_amount` esperado; um segundo cancelamento devolve `403`.
