# Armazenamento e saída

A API de armazenamento e saída permite a uma conta de cliente de uma empresa de armazenagem dar entrada de mercadorias em armazenamento, pagar o período de armazenamento e, mais tarde, expedir os volumes armazenados para os seus próprios compradores. Destina-se a comerciantes e plataformas que mantêm inventário num armazém de terceiros (3PL) e precisam de automatizar, a partir dos seus próprios sistemas, a reserva de armazenamento, a consulta de stock e os envios de saída. Todas as chamadas são executadas como a conta de cliente, nunca como a empresa de armazenagem.

## 1. O que pode construir

Os exemplos deste guia acompanham um único cenário. A **Northwind Outdoor**, uma vendedora online sazonal de equipamento de inverno, guarda o seu stock de inverno no **Toronto Hub** (armazém `7`) do seu 3PL de 1 de novembro de 2026 a 31 de março de 2027. Quando um comprador encomenda uma caixa de casacos isolados, a Northwind expede essa caixa a partir do stock para o comprador em Ottawa.

- **Reserva de armazenamento sazonal.** O back office do vendedor cota e reserva um período de armazenamento para cada caixa de entrada antes de a mercadoria sair do fornecedor, e paga a taxa de armazenamento a partir do saldo da sua conta.
- **Consulta de stock em tempo real.** A loja ou o ERP do vendedor lista os volumes que o armazém efetivamente recebeu e que continuam disponíveis para expedição, para que apenas o stock real seja oferecido para processamento de encomendas.
- **Processamento de encomendas a partir do stock.** Quando um comprador faz uma encomenda, o sistema do vendedor calcula o preço do envio de saída, cria um pedido de saída para os volumes armazenados, paga-o e regista o número de rastreio para o comprador.
- **Acompanhamento e correção do estado.** O sistema do vendedor consulta o estado de cada pedido de armazenamento e de cada saída, acompanha o envio através do rastreio público e cancela uma saída que deixou de ser necessária enquanto ainda é permitido.

## 2. O que este guia abrange

Use este guia quando a mercadoria já está, ou vai estar, guardada no armazém da empresa e o envio parte desse stock. O fluxo é: iniciar sessão → ler a configuração de armazenamento → cotar o armazenamento → criar o pedido de armazenamento → pagar → listar os volumes em stock → listar os serviços e estimar a saída → criar a saída → pagar → consultar e rastrear → webhooks → cancelar.

Outros guias aplicam-se a outros casos:

- **Uniorder: uma API para cada envio** — o ponto de entrada único recomendado (`/api/v1/uniorder/...`) para novas integrações que reservam entregas locais ou etiquetas de transportadora. O Uniorder **não** abrange o armazenamento e a saída; os pedidos de armazenamento e as saídas só são criados através dos endpoints de cliente deste guia.
- **Serviços de envio** — um cliente expede mercadorias que não estão em armazenamento, usando os serviços de envio da empresa.
- **Etiquetas de transportadora** — uma empresa compra diretamente etiquetas de transportadora para os seus próprios volumes.
- **Recolha e entrega (frota própria)** — uma empresa reserva recolhas e entregas com a sua própria frota.

## 3. Antes de começar

- **Tipo de conta.** Uma conta de **cliente** da empresa de armazenagem (a empresa que opera o armazém é o prestador do serviço). Um token de conta de empresa (cliente) não funciona nos endpoints `/api/v1/customer/...`.
- **Permissões.** A conta de cliente precisa de acesso à API. Os endpoints de armazenamento exigem também a capacidade de armazenamento; os endpoints de saída exigem que a empresa tenha ativado a saída (ou a consolidação) para este cliente; caso contrário, respondem `403`.
- **Saldo.** Os pagamentos de armazenamento e de saída são debitados no saldo da conta do cliente. Para um teste, peça à empresa que credite o saldo do cliente de teste.
- **Dados de teste.** Um `id` de armazém, pelo menos um `id` de embalagem se não forem permitidas embalagens personalizadas, e pelo menos um serviço de envio ativo disponível a partir desse armazém. A saída só funciona depois de o armazém ter **recebido** os volumes armazenados; num teste, peça à equipa do armazém que receba o pedido de armazenamento de teste.
- **Tratamento do token.** Inicie sessão a partir do seu servidor, mantenha o token no servidor e nunca o coloque em código de navegador ou de aplicação móvel.
- **Marcadores.** Substitua `YOUR_HOST` pelo anfitrião da sua plataforma e `ACCESS_TOKEN` pelo token do passo 4.

## 4. Iniciar sessão como cliente

Todas as chamadas posteriores são autorizadas com um token bearer de cliente. A sua integração inicia sessão uma vez, guarda o token do lado do servidor e renova-o antes de `expires_at`.

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

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

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

- `access_token` — envie-o em cada pedido HTTP no cabeçalho abaixo.
- `expires_at` / `expires_timestamp` — inicie sessão outra vez antes desta hora.

```
Authorization: Bearer ACCESS_TOKEN
```

O GraphQL usa o mesmo cabeçalho em `POST /api/graphql`.

**Verificação:** o login devolve `access_token`. Os pedidos posteriores sem este token devolvem `401`.

## 5. Ler a configuração de armazenamento

O conjunto de configuração lista os armazéns que o cliente pode usar, o catálogo de embalagens, as unidades e as sobretaxas. A sua integração lê-o uma vez por sessão para escolher o armazém e construir linhas de volumes válidas.

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

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

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

- `warehouses[].id` — o `warehouse_id` para todas as chamadas posteriores.
- `allow_custom_package` — quando `false`, cada artigo de armazenamento tem de ter um `packaging_id` de `packagings[]`; quando `true`, os artigos podem ser descritos apenas pelas dimensões.
- `dimension_units` / `weight_units` — os códigos inteiros usados nas linhas de volumes (`2` = cm, `2` = kg).
- `form_bindings` — formulários que a empresa exige num pedido de armazenamento; envie as respostas como `form_data` no passo 7.

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

```graphql
query {
  customerStorageOrderConfig
}
```

**Verificação:** registou um `id` de armazém e, se o catálogo não estiver vazio, um `id` de embalagem.

## 6. Cotar o período de armazenamento

A cotação calcula o preço do período de armazenamento para os volumes previstos antes de qualquer reserva. A sua integração apresenta ou verifica este preço e depois cria o pedido com os mesmos dados.

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

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

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

- `success` — `true` quando foi calculado um preço.
- `price.total_price` / `price.currency` — o preço de armazenamento, com impostos, para o período.
- `promotion` — presente apenas quando se aplica uma promoção.

**Verificação:** `success` ou `result` é true e tem um preço. A falta de `warehouse_id` / datas devolve `400`.

## 7. Criar o pedido de armazenamento

O pedido de armazenamento anuncia ao armazém os volumes de entrada e fixa o período de armazenamento. A sua integração guarda o id devolvido; é necessário para pagar, consultar e cancelar o pedido.

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

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

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

- `data.id` — o id do pedido de armazenamento. Guarde-o com a sua ordem de compra.
- `data.status` — `pending payment` até o pedido ser pago.
- `Idempotency-Key` — derive-a do seu próprio id estável. Uma chave repetida com o mesmo corpo devolve de novo a primeira resposta (`replayed: true`); a mesma chave com um corpo diferente é recusada com `409 IDEMPOTENCY_CONFLICT`.
- Campos obrigatórios: `warehouse_id`, `start_date`, `end_date` (posterior a `start_date`) e `items[]` com `qty`, `length`, `width`, `height`, `dimension_unit`. Acrescente `items[].packaging_id` quando `allow_custom_package` é `false`.

**Verificação:** a resposta tem `data.id`. Guarde esse id do pedido de armazenamento.

## 8. Pagar o armazenamento

O pagamento confirma o pedido de armazenamento. A sua integração pode ler primeiro o montante em dívida e depois pagar a partir do saldo do cliente.

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

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

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

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

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

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

- `payment_type` — `full_balance` (predefinição, paga o montante restante), `minimum_payment` (paga o mínimo que a empresa exige) ou `custom` em conjunto com `custom_amount`.
- `is_fully_paid` — `true` quando não resta nada a pagar.
- Um saldo insuficiente responde `400` com `customer_balance`; carregue o saldo e tente de novo.

Consulte o pedido para confirmar o seu estado e, mais tarde, que volumes o armazém recebeu.

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

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

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

- `status` — `confirmed` após o pagamento; depois `partial received` / `storage in progress` à medida que a mercadoria chega.
- `packages[].received` — `true` quando o armazém recebeu esse volume.
- `can_cancel` — se o pedido de armazenamento ainda pode ser cancelado.

**GraphQL:** `customerStorageOrderShow` ([Manual GraphQL](/api/graphql/documentation#/customer/customerStorageOrderShow)); a lista de todos os pedidos de armazenamento é `customerStorageOrders` ([Manual GraphQL](/api/graphql/documentation#/customer/customerStorageOrders)).

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

**Verificação:** o pedido de armazenamento está pago / confirmado. Um `400` com `customer_balance` significa que deve carregar o saldo e tentar de novo.

A saída abaixo só funciona depois de os volumes serem **recebidos** no armazém. Para um teste, espere até a equipa (ou uma receção de teste) os ter marcado como recebidos e continue.

## 9. Listar artigos ainda em stock

Esta lista é o stock que a sua integração pode expedir. Contém apenas volumes que o armazém recebeu e que ainda não estão bloqueados noutra saída.

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

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

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

- `storage_orders[].packages[].id` — os `storage_package_ids` a expedir no passo 10.
- `warehouses[].available_count` — o número de volumes disponíveis por armazém.

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

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

**Verificação:** registou um ou mais `storage_package_ids` (exemplo `5001`). Uma lista vazia significa que ainda nada foi recebido — não crie uma saída. `403` significa que a saída está desativada para este cliente.

## 10. Estimar e criar a saída

O preço de uma saída é calculado por um serviço de envio da empresa. A sua integração lista os serviços disponíveis a partir do armazém, estima o preço para o destino do comprador e depois cria a saída para os volumes selecionados.

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

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

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

Registe um `service_code`.

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

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

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

- `total` / `currency` — o preço estimado para este destino.
- `has_items_needing_quote` — `true` quando o preço do serviço é definido manualmente; o armazém define o preço depois de a saída ser criada, e o pagamento aguarda por ele.
- `refused` / `refusal_message` — o serviço não aceita este envio porque não consegue calcular o seu preço.

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

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

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

- `data.id` — o id da saída. Guarde-o com a encomenda do comprador.
- `data.status` — `0` = pendente (a aguardar pagamento), `1` = confirmado, `2` = em trânsito, `3` = expedido, `4` = cancelado, `5` = falhado.
- `storage_package_ids` — estes volumes ficam agora bloqueados nesta saída e deixam de aparecer no passo 9.
- Campos obrigatórios: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Todos os volumes têm de vir do mesmo armazém.

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

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

**Verificação:** a resposta tem um `id` de saída. Os volumes de armazenamento selecionados ficam bloqueados neste pedido.

## 11. Pagar a saída

O armazém processa uma saída depois de paga. A sua integração paga o montante restante a partir da conta do cliente; omita `amount` para pagar a totalidade.

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

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

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

- `amount` (pedido HTTP, opcional) — um montante parcial; por predefinição, o montante restante completo.
- `order_status` — `1` (confirmado) após o pagamento total.
- `remaining_balance` — `0` quando totalmente pago.

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

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

**Verificação:** o pagamento regista um montante (ou `402` / `422` com um motivo claro). `402` significa que o saldo é insuficiente; `422` significa que o pedido ainda não pode ser pago (por exemplo, ainda aguarda uma cotação manual) ou que o montante é inválido.

## 12. Consultar e rastrear a saída

A sua integração consulta a saída para acompanhar o seu estado e, depois de o armazém a ter expedido, acompanha o envio pelo seu número de rastreio.

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

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

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

- `status` / `status_label` — o estado atual da saída.
- `can_be_paid` / `can_be_cancelled` — se o passo 11 ou o passo 14 é permitido neste momento.

Quando existe um número de rastreio:

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

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

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

- `data[]` — eventos de rastreio por ordem cronológica.
- `deliveried` — `true` quando o envio foi entregue.

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

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

**Verificação:** a consulta da saída devolve o `status` esperado. O rastreio público encontra o envio assim que existir um número.

## 13. Subscrever webhooks

Os webhooks enviam as alterações de rastreio e de estado para o seu servidor, em vez de consultas periódicas. Uma conta de cliente define os seus próprios URLs de webhook e o seu segredo de assinatura; as definições são guardadas na conta de cliente, não na empresa.

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

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

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

- Só as chaves enviadas são alteradas; uma chave desconhecida ou um URL inválido responde `400`.
- `recipient_type` — `customer` confirma que as definições pertencem à conta de cliente.
- `webhook_sign_secret` — entre 16 e 255 caracteres; guarde-o no seu servidor para verificar as assinaturas.

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

Verifique **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` contra `X-Webhook-Signature-V2`. Deduplique em `X-Webhook-Event-Id`. Responda **2xx em 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;
}
```

**Verificação:** a atualização devolve `changed_keys` com as chaves enviadas, e um evento de teste recebido no seu URL passa a verificação de assinatura acima.

## 14. Cancelar uma saída ou um pedido de armazenamento

O cancelamento liberta o que foi reservado. Cancelar uma saída devolve os seus volumes ao stock; cancelar um pedido de armazenamento termina uma reserva cuja mercadoria ainda não foi recebida.

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

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

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

- `reason` (opcional) — registado com o cancelamento.
- Uma saída só pode ser cancelada enquanto está pendente (`0`) ou confirmada (`1`).

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

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

Isto liberta o bloqueio dos volumes de armazenamento. O próprio armazenamento cancela-se com `POST /api/v1/customer/storage-orders/{id}/cancel` enquanto ainda for permitido (estado `pending payment`, `confirmed`, `waiting for pickup` ou `awaiting dropoff`; [Manual REST](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

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

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

- O montante já pago pelo pedido de armazenamento é creditado de volta no saldo do cliente.
- Um pedido de armazenamento em qualquer outro estado responde `403`.

**Verificação:** `422` significa que este estado não pode ser cancelado. Após um cancelamento bem-sucedido da saída, o passo 9 volta a listar os volumes.

## 15. Tratamento de erros

| Situação | Estado HTTP | Código | O que a integração faz |
|---|---|---|---|
| Token em falta ou expirado, ou tipo de conta errado | 401 | — | Inicie sessão outra vez como cliente (passo 4). |
| Cotação de armazenamento sem `warehouse_id` ou sem datas | 400 | — | Envie `warehouse_id`, `start_date` e `end_date`. |
| Falha de validação do pedido de armazenamento (dimensões de artigo em falta, `end_date` não posterior a `start_date`, `packaging_id` em falta) | 422 | — | Leia `errors`, corrija os campos e envie outra vez. |
| Pagamento de armazenamento com saldo insuficiente, ou pedido já totalmente pago | 400 | — | Carregue o saldo (a resposta traz `customer_balance`), ou pare se já estiver pago. |
| Pagamento de armazenamento com `custom_amount` fora do intervalo permitido | 422 | — | Pague um montante entre o mínimo e o montante restante. |
| O pedido de armazenamento não pode ser cancelado no estado atual | 403 | — | Peça ao armazém que trate o pedido; não repita. |
| Saída desativada para este cliente | 403 | — | Peça à empresa que ative a saída para a conta de cliente. |
| Código de serviço desconhecido, ou saída / pedido de armazenamento não encontrado | 404 | — | Volte a ler a lista de serviços ou verifique o id guardado. |
| Volume não disponível, volumes de armazéns diferentes, ou serviço não oferecido a partir do armazém | 422 | — | Volte a ler o passo 9 e selecione volumes disponíveis de um único armazém. |
| O serviço de envio não consegue calcular o preço do envio e recusa-o | 422 | `unpriced_refused` | Escolha outro serviço ou destino; nada foi criado. |
| Pagamento da saída com saldo insuficiente | 402 | — | Carregue o saldo e repita o passo 11. |
| Saída ainda não pagável (a aguardar uma cotação manual) ou montante inválido | 422 | — | Aguarde o preço, consulte a saída outra vez e depois pague. |
| A saída não pode ser cancelada no estado atual | 422 | — | O envio já está em curso; não repita. |
| A mesma `Idempotency-Key` enviada com um corpo diferente | 409 | `IDEMPOTENCY_CONFLICT` | Use uma chave nova para um pedido HTTP diferente. |
| O pedido HTTP original com a mesma `Idempotency-Key` ainda está a ser processado | 409 | `IDEMPOTENCY_IN_PROGRESS` | Aguarde `Retry-After` segundos e envie outra vez o mesmo pedido HTTP. |

## Lista de testes

- [ ] A configuração de armazenamento devolve um `id` de armazém.
- [ ] A cotação de armazenamento devolve um preço, e a criação do armazenamento devolve `data.id`.
- [ ] O pagamento do armazenamento é bem-sucedido, **ou** confirmou que a carteira precisa de ser carregada.
- [ ] Os artigos disponíveis listam volumes recebidos (`storage_package_ids`).
- [ ] A estimativa da saída devolve um preço ou `has_items_needing_quote`, e a criação da saída devolve um `id` e bloqueia esses volumes.
- [ ] O pagamento da saída é bem-sucedido (ou `402` / `422` é compreendido).
- [ ] O rastreio público encontra o envio assim que existir um número de rastreio.
- [ ] O cancelamento da saída liberta os volumes, **ou** este estado não pode ser cancelado.
- [ ] Repetir uma criação com a mesma `Idempotency-Key` e o mesmo corpo devolve `replayed: true` e nenhum segundo pedido.
- [ ] As definições de webhook devolvem `recipient_type: customer`, e um evento recebido passa a verificação de assinatura v2.
