# Recolha e entrega (frota própria)

Este guia descreve a API de entrega local de uma conta de empresa: pedidos que os motoristas da própria empresa entregam a um destinatário (`type` `D`) ou recolhem junto de um remetente (`type` `P`). Um único conjunto de endpoints cota, cria, etiqueta, rastreia e cancela os dois tipos de paragem, e os webhooks comunicam cada alteração ao seu sistema. Destina-se a programadores de sistemas de gestão de pedidos, ERP e lojas online que atribuem trabalho à frota própria da empresa.

## 1. O que pode construir

Os exemplos seguintes acompanham uma única empresa: **Farine & Fils**, um fornecedor de padarias com um armazém em 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), que entrega pedidos por grosso em toda a ilha de Montreal e recolhe as caixas de pão vazias que os seus clientes devolvem. Uma entrega típica é uma pilha de caixas de 12 kg com 60 × 40 × 30 cm para o Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), com o pedido por grosso `WHS-20931`. Uma recolha típica é uma pilha de caixas vazias de 4 kg na Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), com a referência `CRT-20931`.

- **Pedidos por grosso enviados do ERP para a expedição.** Cada pedido por grosso confirmado passa a ser um pedido de entrega com a janela de entrega matinal do café, e o ERP guarda o número de rastreio devolvido na linha do pedido.
- **Recolhas de caixas devolvidas.** Quando um cliente comunica que tem caixas vazias, o ERP cria um pedido de recolha para o endereço do cliente, e um motorista recolhe as caixas na rota seguinte.
- **Impressão de etiquetas no armazém.** O ERP descarrega o PDF da etiqueta de cada pedido e imprime-o no cais de carga, para que cada pilha de caixas tenha o seu código de barras de rastreio.
- **Um portal de clientes com o estado em tempo real.** Cada café vê o estado das suas entregas e recolhas, com o comprovativo de entrega, alimentado por webhooks em vez de consultas periódicas.

## 2. O que este guia abrange

Use este guia quando são os motoristas da própria empresa que transportam o pedido: entregas a partir do armazém e recolhas no endereço de um cliente, criadas uma a uma ou em lotes através dos endpoints `/api/v1/client/...` e `/api/v1/orders/...`.

Para novas integrações, o Uniorder (`/api/v1/uniorder/...`) é o ponto de entrada único recomendado: oferece as mesmas entregas com frota própria através de uma só API, juntamente com as etiquetas de transportadora, a partir de uma única cotação. Consulte **Uniorder: uma API para cada envio** para a visão geral e **Cotação e pedido num só fluxo** para os respetivos pedidos HTTP passo a passo. Os endpoints deste guia continuam disponíveis e inalterados para as integrações que os utilizam.

Use **Etiquetas de transportadora** quando um volume é expedido por uma transportadora externa com uma etiqueta comprada através da plataforma. Use **Serviços de envio** para pedidos que uma conta de cliente reserva nos serviços de uma empresa, e **Armazenamento e saída** para mercadorias guardadas num armazém e expedidas a pedido; o Uniorder não se aplica a estes dois casos.

## 3. Antes de começar

- **Conta.** Use uma conta de empresa (cliente), ou uma conta de funcionário da empresa, com permissão de API. A criação de pedidos exige ainda a permissão de fazer pedidos; sem ela, `POST /api/v1/client/orderCreate` devolve `401`.
- **Área de serviço.** O endereço de entrega ou de recolha tem de estar dentro de uma região ativa da empresa. Para os testes, use endereços dentro da área, como os deste guia.
- **Dados de teste.** Use referências de teste como `WHS-20931` e `CRT-20931`, e cancele os pedidos de teste no fim (passo 12).
- **Tokens.** Peça o token de acesso a partir do seu servidor e mantenha-o aí. Nunca o envie para um navegador ou uma aplicação móvel.
- **Marcadores.** Substitua `YOUR_HOST` pelo anfitrião da API do seu ambiente e `ACCESS_TOKEN` pelo token do passo 4.
- **Unidades.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Ambos têm `1` como predefinição.

## 4. Autenticação

Todas as chamadas deste guia, exceto o rastreio público, são feitas em nome da conta de empresa. Inicie sessão uma vez a partir do seu servidor, guarde o token devolvido e envie-o em cada pedido HTTP.

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

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

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

- `access_token`: coloque-o no cabeçalho de cada pedido HTTP posterior:

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

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

## 5. Cotar uma entrega ou uma recolha (opcional)

Uma cotação mostra o preço de uma paragem antes de o pedido existir, por exemplo para apresentar o custo de entrega numa fatura por grosso. Não cria nada, e a criação de um pedido não exige uma cotação prévia. Defina `type` como `D` (entrega) ou `P` (recolha); `to_postcode` é o código postal da paragem.

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

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

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

- `shipping_price`: o preço da paragem antes de impostos. Um preço vazio significa que o código postal não está numa região ativa ou que a tabela de preços não tem uma linha para ele.
- `price_details.tax_details`: os impostos que o pedido terá; apresente-os na linha da fatura.
- `currency`: a moeda de todos os montantes da resposta.

Para cotar a recolha das caixas, envie o mesmo pedido HTTP com `"type": "P"`, `"to_postcode": "H4G1V5"` e o peso e as dimensões da pilha de caixas.

**GraphQL:** `ordersRate` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersRate)). O resultado é um escalar JSON e não aceita selection set.

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

**Verificação:** `result` é `true` e `shipping_price` é um número tanto para `type` `D` como para `type` `P`. A criação de um pedido não depende deste passo.

## 6. Criar um pedido de entrega

Cada pedido por grosso confirmado passa a ser um pedido de entrega. O ERP guarda o `id` e o `tracking_number` devolvidos na sua linha de pedido; todas as chamadas posteriores usam um deles.

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

Envie um cabeçalho `Idempotency-Key`, único por pedido por grosso, para que uma repetição após um tempo limite esgotado não possa criar um segundo pedido.

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

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

| Campo | Significado |
|---|---|
| `type` | `D` entrega, ou `P` recolha |
| `need_pick_up` | `0` — a mercadoria já está no armazém. `1` — um motorista deve recolher o volume |
| `ref` | Referência externa usada para pesquisa e reconciliação |
| `name` / endereço | Entrega: destinatário. Recolha: ponto de recolha |
| `schedule_date`, `time_window_start`, `time_window_end` | Data de entrega (`Y-m-d`) e a janela em que a paragem tem de ser servida (`Y-m-d H:i:s`) |
| `packagesDetail` | Uma entrada por volume; `ref` identifica o volume no seu sistema |
| `auto_deduplication` | `1` recusa um segundo volume com a mesma `ref` de volume |

Na resposta:

- `id`: o id do pedido; guarde-o para o detalhe do pedido e para a chamada de cancelamento.
- `tracking_number`: um número de rastreio por volume; imprima e rastreie com estes números.
- `warning`: presente quando o pedido foi criado com um aviso, por exemplo um endereço fora da área de entrega que a empresa conserva ou retém. Um pedido fora de zona que é conservado pode devolver `shipping_price: null`.

**GraphQL:** `clientOrderCreate` ([Manual GraphQL](/api/graphql/documentation#/client/clientOrderCreate)). O resultado é um escalar JSON com o mesmo corpo da resposta REST.

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

**Verificação:** envie outra vez o mesmo corpo com a mesma `Idempotency-Key`. A resposta traz o mesmo `id` e não é criado um segundo pedido.

## 7. Criar um pedido de recolha

Um pedido de recolha envia um motorista para recolher mercadoria num endereço; aqui, as caixas vazias na Épicerie Wellington. Usa o mesmo endpoint que uma entrega: o endereço é o ponto de recolha, `type` é `P` e `need_pick_up` é `1`.

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

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

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

- `id` e `tracking_number`: guarde-os associados à devolução das caixas, tal como numa entrega.
- `pickup_instruction`: apresentada ao motorista no ponto de recolha; `delivery_instruction` é o equivalente numa entrega.

**Verificação:** o detalhe do pedido (passo 8) mostra `type` `P` e `need_pickup` `1` para este pedido.

## 8. Consultar o pedido

O detalhe do pedido confirma o que foi guardado e devolve o estado atual; o endpoint de lista permite ao ERP reconciliar os seus próprios registos com a plataforma.

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

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

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

- `order.orders_status_id`: o estado do pedido; `2` é Novo, `12` é Cancelado.
- `order.type` e `order.need_pickup`: confirmam que a paragem foi guardada como entrega ou como recolha.
- `tracking_numbers`: os números de rastreio dos volumes do pedido.

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

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

A lista devolve todos os pedidos da conta, os mais recentes primeiro, cada um com os seus volumes e linhas de artigos. Envie `page` e `per_page` em conjunto para paginar (`per_page` no máximo 1000); sem eles, são devolvidos os 1000 pedidos mais recentes com um indicador `truncated`.

**GraphQL:** `orders` ([Manual GraphQL](/api/graphql/documentation#/orders/orders)) para um pedido e `ordersList` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersList)) para a lista. Ambos devolvem um escalar JSON.

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

**Verificação:** o pedido pertence à conta autenticada, `ref` coincide com o valor enviado na criação e `tracking_numbers` coincide com a resposta da criação.

## 9. Imprimir a etiqueta local

A etiqueta tem o código de barras de rastreio que o motorista digitaliza no armazém e na paragem. Imprima uma etiqueta por volume e cole-a na pilha de caixas.

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

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

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

- `type`: como `id` é interpretado: `TRACKING_NUMBER` (predefinição), `ORDER_ID` ou `REF`.
- `base64`: `0` (predefinição) envia o PDF. `1` faz com que todo o corpo da resposta seja uma cadeia JSON de nível superior que contém o PDF em base64, e não um objeto com um campo `pdf_data`. Chame antes `POST /api/v2/shipping/getShippingLabel` — [Manual REST](/api/documentation#/paths/v2-shipping-getShippingLabel/post) para receber a etiqueta dentro de um objeto JSON normal.
- `packages`: opcional; o número de etiquetas a imprimir. Um valor diferente do número de volumes do pedido atualiza o pedido.
- `hide_sender_address` / `hide_receiver_address`: `1` deixa esse endereço em branco na etiqueta.

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

**Verificação:** o PDF descodificado abre. A etiqueta de entrega mostra o endereço do Café Lumière; a etiqueta de recolha mostra o endereço da Épicerie Wellington. Um endereço oculto aparece em branco na etiqueta.

## 10. Rastrear o pedido

O rastreio público devolve a linha temporal de eventos de um volume. Não exige token de acesso, pelo que um portal de clientes pode apresentá-la diretamente; o comprovativo de entrega ou de recolha vem com ela.

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

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

```json
{
  "result": true,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

O mesmo URL aceita o seu `ref` quando foi guardado como número externo.

Ramifique por `tracking_event_status_id`, não por `description`; essa cadeia segue `Accept-Language`.

| `tracking_event_status_id` | Lado | Significado |
|---|---|---|
| `100` | ambos | Pedido recebido |
| `300` / `301` | entrega | Nas instalações |
| `450` | entrega | Em entrega |
| `500` | entrega | Entregue |
| `501` | entrega | Entrega falhada, é preciso um plano novo |
| `460` | recolha | Em recolha |
| `510` | recolha | Recolhido |
| `512` | recolha | Recolha falhada, tente mais tarde |
| `513` | recolha | Problema de recolha |

- `data`: do mais recente para o mais antigo; a primeira linha é o estado atual.
- `deliveried`: `true` depois de `500`.
- `proofs[]`: em `500` ou `510`, pode trazer `type` `1` (assinatura) ou `2` (foto), com `file_id` e `signed_url`. Uma foto carregada depois desse evento não está neste payload; subscreva `pod.files_updated` (passo 11).

**GraphQL:** `trackingPublic` ([Manual GraphQL](/api/graphql/documentation#/tracking/trackingPublic)). O resultado é tipado e precisa de selection set.

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

**Verificação:** logo após a criação, o evento mais recente é `100` e `deliveried` é `false`. Um número desconhecido devolve `result: false` com `404`; mostre um estado de não encontrado e não invente eventos de rastreio.

## 11. Receber webhooks

Os webhooks enviam cada alteração para o seu servidor, para que o ERP e o portal de clientes se mantenham atualizados sem consultas periódicas. Registe os URLs de callback de que este fluxo precisa:

| Definição | Evento | Utilização |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Persistir `id` e `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Estado visível ao cliente |
| `tracking_event_webhook_url` | `tracking.event` | Linha temporal de recolha ou entrega |
| `pod_files_webhook_url` | `pod.files_updated` | Foto ou assinatura após a recolha ou a entrega |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Um cancelamento que enviou foi recusado |
| `order_create_async_postback_url` | `order.create_async` | Resultado de um lote assíncrono (passo 13) |

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

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

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

- `changed_keys`: as definições que esta chamada alterou.
- `settings.webhook_sign_secret`: devolvido mascarado; mantenha o valor completo apenas no seu servidor.

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

No lado recetor, verifique a assinatura **v2** sobre o corpo em bruto: `HMAC_SHA256(timestamp + "." + raw_body, secret)` comparado com `X-Webhook-Signature-V2`, em que o timestamp é `X-Webhook-Timestamp`. 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:** crie um pedido de teste e receba `order.created` com o mesmo `id` e `tracking_number`. O recetor recusa uma assinatura inválida com `401`, e uma segunda entrega do mesmo `X-Webhook-Event-Id` não é processada duas vezes.

## 12. Cancelar um pedido

Cancele um pedido quando o pedido por grosso é retirado ou a recolha das caixas deixa de ser necessária. A chamada é idempotente: cancelar um pedido já cancelado volta a ter êxito.

**REST:** `POST /api/v1/orders/cancel` — [Manual REST](/api/documentation#/paths/v1-orders-cancel/post) — envie exatamente um de `order_id`, `tracking_number`, `external_tracking_number`.

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

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

- `result`: `true` quando o pedido é cancelado.
- `already_cancelled`: `true` quando o pedido já tinha sido cancelado antes desta chamada; trate-o como êxito.
- `code`: presente quando o cancelamento é recusado; consulte o passo 14.

**GraphQL:** `ordersCancel` ([Manual GraphQL](/api/graphql/documentation#/orders/ordersCancel)). O resultado é tipado e precisa de selection set.

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

**Verificação:** o detalhe do pedido mostra `orders_status_id` `12`, e o mesmo cancelamento devolve `already_cancelled: true`. Quando um cancelamento é recusado, `order.cancel_failed` é enviado para `order_cancel_failed_webhook_url`.

## 13. Criar pedidos em lote (opcional)

O ERP pode enviar os pedidos por grosso e as recolhas de caixas do dia num único pedido HTTP. Cada linha aceita os mesmos campos dos passos 6 e 7 e pode ser `type` `D` ou `P`.

**REST:** `POST /api/v1/client/batchOrderCreate` — [Manual REST](/api/documentation#/paths/v1-client-batchOrderCreate/post) — responde quando todas as linhas tiverem sido processadas.

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

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

- Cada linha tem o seu próprio `result`; associe-o à sua linha de pedido através de `ref`. Uma linha recusada traz `message` e `skipped_ref`, e pode trazer `code` (por exemplo `INSUFFICIENT_BALANCE` ou `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` confirma cada linha separadamente, para que uma linha falhada não possa reverter as outras.
- Os lotes com mais de 100 pedidos recebem um cabeçalho de resposta `X-Batch-Size-Warning`; envie-os para o endpoint assíncrono.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [Manual REST](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — aceita o mesmo corpo e devolve de imediato um identificador de job:

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

Consulte `GET /api/v1/client/async/{id}` — [Manual REST](/api/documentation#/paths/v1-client-async-id/get) — com o `asyncId`, ou receba `order.create_async` em `order_create_async_postback_url`. O resultado do job é a mesma lista por linha que a do endpoint síncrono.

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

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

**Verificação:** um lote de duas linhas devolve dois resultados, cada um com o seu `ref`. O job assíncrono devolve as mesmas linhas depois de executado.

## 14. Tratamento de erros

| Situação | Estado HTTP | Código | O que a integração faz |
|---|---|---|---|
| Um campo obrigatório está em falta ou mal formado (criação) | 400 | `VALIDATION_FAILED` | Corrija o campo indicado em `message` e envie o pedido HTTP outra vez. |
| O saldo da conta não cobre o pedido | 400 | `INSUFFICIENT_BALANCE` | Leia `insufficient_balance` (required, available, shortfall); carregue saldo e tente de novo. Não foi criado nenhum pedido. |
| O endereço está fora da área de serviço e a empresa apaga esses pedidos | 400 | `OUT_OF_DELIVERY_AREA` | Envie um endereço dentro da área de serviço. Não foi criado nenhum pedido. |
| Uma `ref` de volume ou um número de rastreio externo já existe (com a deduplicação ativa) | 200 (`result` `false`), ou 409 com `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Leia `exist_package_ref` e associe o pedido existente em vez de criar um novo. |
| Uma `Idempotency-Key` é reutilizada com um corpo diferente | 409 | `IDEMPOTENCY_CONFLICT` | Use uma chave nova para um pedido HTTP diferente. |
| Um pedido HTTP com a mesma `Idempotency-Key` ainda está em execução | 409 | `IDEMPOTENCY_IN_PROGRESS` | Aguarde e tente de novo com a mesma chave. |
| Cancelamento sem identificador de pedido | 400 | `MISSING_IDENTIFIER` | Envie um de `order_id`, `tracking_number`, `external_tracking_number`. |
| Cancelamento de um pedido que não existe | 400 | `ORDER_NOT_FOUND` | Verifique o `id` ou o número de rastreio guardado. |
| O número corresponde a mais de um pedido ativo | 409 | `MULTIPLE_ORDERS_MATCHED` | Cancele por `order_id`, usando um dos `matched_order_ids`. |
| O pedido pertence a outra conta | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Cancele com a conta que criou o pedido. |
| O estado do pedido já não permite o cancelamento | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Deixe o pedido como está; trate a devolução separadamente. |
| O pedido está com uma transportadora terceira que não o pode cancelar | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` ou `THIRD_PARTY_CANCEL_FAILED` | O pedido fica inalterado; contacte a empresa. |
| O token está em falta ou expirou, ou a conta não pode fazer pedidos | 401 | — | Inicie sessão outra vez; verifique as permissões da conta. |

## Lista de testes

Use referências de teste como `WHS-20931` e `CRT-20931`:

- [ ] (Opcional) A cotação devolve um preço para um código postal na zona com `type` `D`.
- [ ] (Opcional) A cotação devolve um preço para um código postal na zona com `type` `P`.
- [ ] Criar uma entrega devolve `id` + `tracking_number`; a mesma `Idempotency-Key` não cria um segundo pedido.
- [ ] Criar uma recolha devolve `id` + `tracking_number`; o detalhe do pedido mostra `type` `P` e `need_pickup` `1`.
- [ ] O detalhe do pedido e a lista mostram os dois pedidos nesta conta.
- [ ] O PDF da etiqueta local abre e mostra o destinatário ou o endereço de recolha.
- [ ] O rastreio público devolve a linha temporal sem token; o evento mais recente é `100`.
- [ ] Chega `order.created` e a sua assinatura v2 é verificada.
- [ ] Cancelar devolve `result: true`, e um segundo cancelamento devolve `already_cancelled: true`.
- [ ] Um lote com uma entrega e uma recolha devolve dois resultados, cada um com o seu `ref`.
