# Ritiro e consegna (flotta propria)

Questa guida descrive l’API di consegna locale di un account aziendale: ordini che gli autisti dell’azienda consegnano a un destinatario (`type` `D`) o ritirano presso un mittente (`type` `P`). Un unico insieme di endpoint quota, crea, etichetta, traccia e annulla entrambi i tipi di fermata, e i webhook notificano ogni modifica al vostro sistema. È destinata agli sviluppatori di sistemi di gestione ordini, ERP e negozi online che affidano il lavoro alla flotta propria dell’azienda.

## 1. Cosa potete realizzare

Gli esempi seguenti riguardano un’unica azienda: **Farine & Fils**, un fornitore di panetterie con un deposito in 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), che consegna ordini all’ingrosso in tutta l’isola di Montréal e ritira le cassette del pane vuote restituite dai clienti. Una consegna tipica è una pila di cassette da 12 kg, 60 × 40 × 30 cm, per Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), con l’ordine all’ingrosso `WHS-20931`. Un ritiro tipico è una pila di cassette vuote da 4 kg presso Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), con il riferimento `CRT-20931`.

- **Ordini all’ingrosso inviati alla pianificazione dall’ERP.** Ogni ordine all’ingrosso confermato diventa un ordine di consegna con la fascia di consegna mattutina del bar, e l’ERP registra il numero di tracciamento restituito sulla riga d’ordine.
- **Ritiri per la restituzione delle cassette.** Quando un cliente segnala cassette vuote, l’ERP crea un ordine di ritiro all’indirizzo del cliente e un autista ritira le cassette nel giro successivo.
- **Stampa delle etichette in deposito.** L’ERP scarica il PDF dell’etichetta di ogni ordine e lo stampa alla banchina di carico, in modo che ogni pila di cassette porti il proprio codice a barre di tracciamento.
- **Un portale clienti con lo stato aggiornato.** Ogni bar vede lo stato delle proprie consegne e dei propri ritiri, con la prova di consegna, alimentato dai webhook anziché da interrogazioni periodiche.

## 2. Ambito di questa guida

Usate questa guida quando l’ordine è trasportato dagli autisti dell’azienda: consegne dal deposito e ritiri all’indirizzo di un cliente, creati singolarmente o in lotti tramite gli endpoint `/api/v1/client/...` e `/api/v1/orders/...`.

Per le nuove integrazioni, Uniorder (`/api/v1/uniorder/...`) è il punto di accesso unico consigliato: offre le stesse consegne con flotta propria tramite un’unica API, insieme alle etichette corriere, a partire da un unico preventivo. Consultate **Uniorder: un’API per ogni spedizione** per la panoramica e **Preventivo e ordine in un unico flusso** per le richieste passo per passo. Gli endpoint di questa guida restano disponibili e invariati per le integrazioni che li utilizzano.

Usate **Etichette corriere** quando un collo viene spedito da un corriere esterno con un’etichetta acquistata tramite la piattaforma. Usate **Servizi di spedizione** per gli ordini che un account cliente prenota sui servizi di un’azienda, e **Stoccaggio e uscita** per la merce conservata in magazzino e spedita su richiesta; Uniorder non si applica a questi due casi.

## 3. Prima di iniziare

- **Account.** Usate un account aziendale (cliente), o un account dipendente dell’azienda, con autorizzazione API. La creazione di ordini richiede inoltre l’autorizzazione a effettuare ordini; senza di essa, `POST /api/v1/client/orderCreate` restituisce `401`.
- **Area di servizio.** L’indirizzo di consegna o di ritiro deve trovarsi in una zona attiva dell’azienda. Per i test usate indirizzi compresi nell’area, come quelli di questa guida.
- **Dati di prova.** Usate riferimenti di prova come `WHS-20931` e `CRT-20931`, e annullate gli ordini di prova alla fine (passo 12).
- **Token.** Richiedete il token di accesso dal vostro server e conservatelo lì. Non inviatelo mai a un browser o a un’app mobile.
- **Segnaposto.** Sostituite `YOUR_HOST` con l’host API del vostro ambiente e `ACCESS_TOKEN` con il token del passo 4.
- **Unità.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Entrambe hanno valore predefinito `1`.

## 4. Autenticazione

Ogni chiamata di questa guida, tranne il tracciamento pubblico, viene eseguita per conto dell’account aziendale. Effettuate il login una volta dal vostro server, conservate il token restituito e inviatelo in ogni richiesta.

**REST:** `POST /api/v1/user/login` — [Manuale 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`: inseritelo nell’intestazione di ogni richiesta successiva:

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL usa la stessa intestazione su `POST /api/graphql`.

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

**Verifica:** il login restituisce `access_token`. Le richieste successive senza questo token restituiscono `401`.

## 5. Quotare una consegna o un ritiro (facoltativo)

Un preventivo mostra il prezzo di una fermata prima che l’ordine esista, ad esempio per indicare il costo di consegna su una fattura all’ingrosso. Non crea nulla, e la creazione di un ordine non richiede un preventivo precedente. Impostate `type` su `D` (consegna) o `P` (ritiro); `to_postcode` è il CAP della fermata.

**REST:** `POST /api/v1/orders/rate` — [Manuale 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`: il prezzo della fermata al netto delle imposte. Un prezzo vuoto significa che il CAP non è in una zona attiva, oppure che il tariffario non contiene una riga per esso.
- `price_details.tax_details`: le imposte che l’ordine riporterà; indicatele sulla riga della fattura.
- `currency`: la valuta di tutti gli importi della risposta.

Per quotare il ritiro delle cassette, inviate la stessa richiesta con `"type": "P"`, `"to_postcode": "H4G1V5"` e il peso e le dimensioni della pila di cassette.

**GraphQL:** `ordersRate` ([Manuale GraphQL](/api/graphql/documentation#/orders/ordersRate)). Il risultato è uno scalare JSON e non accetta un 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:** `result` è `true` e `shipping_price` è un numero sia per `type` `D` sia per `type` `P`. La creazione di un ordine non dipende da questo passo.

## 6. Creare un ordine di consegna

Ogni ordine all’ingrosso confermato diventa un ordine di consegna. L’ERP registra `id` e `tracking_number` restituiti sulla propria riga d’ordine; ogni chiamata successiva utilizza uno dei due.

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

Inviate un’intestazione `Idempotency-Key`, univoca per ogni ordine all’ingrosso, in modo che un nuovo tentativo dopo un timeout non possa creare un secondo ordine.

```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 | Significato |
|---|---|
| `type` | `D` consegna, oppure `P` ritiro |
| `need_pick_up` | `0` — la merce è già in deposito. `1` — un autista deve ritirare il collo |
| `ref` | Riferimento esterno usato per la ricerca e la riconciliazione |
| `name` / indirizzo | Consegna: destinatario. Ritiro: fermata di ritiro |
| `schedule_date`, `time_window_start`, `time_window_end` | Data di consegna (`Y-m-d`) e fascia oraria in cui la fermata deve essere servita (`Y-m-d H:i:s`) |
| `packagesDetail` | Una voce per collo; `ref` identifica il collo nel vostro sistema |
| `auto_deduplication` | `1` rifiuta un secondo collo con la stessa `ref` di collo |

Nella risposta:

- `id`: l’identificativo dell’ordine; conservatelo per il dettaglio dell’ordine e per la chiamata di annullamento.
- `tracking_number`: un numero di tracciamento per ogni collo; usateli per stampare e tracciare.
- `warning`: presente quando l’ordine è stato creato con un avviso, ad esempio per un indirizzo fuori dall’area di consegna che l’azienda conserva o mette in attesa. Un ordine fuori zona conservato può restituire `shipping_price: null`.

**GraphQL:** `clientOrderCreate` ([Manuale GraphQL](/api/graphql/documentation#/client/clientOrderCreate)). Il risultato è uno scalare JSON con lo stesso corpo della risposta 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:** inviate di nuovo lo stesso corpo con lo stesso `Idempotency-Key`. La risposta riporta lo stesso `id` e non viene creato un secondo ordine.

## 7. Creare un ordine di ritiro

Un ordine di ritiro invia un autista a ritirare merce presso un indirizzo; in questo caso, le cassette vuote presso Épicerie Wellington. Usa lo stesso endpoint di una consegna: l’indirizzo è la fermata di ritiro, `type` è `P` e `need_pick_up` è `1`.

**REST:** `POST /api/v1/client/orderCreate` — [Manuale 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`: registrateli sulla restituzione delle cassette, come per una consegna.
- `pickup_instruction`: mostrata all’autista alla fermata di ritiro; `delivery_instruction` è l’equivalente per una consegna.

**Verifica:** il dettaglio dell’ordine (passo 8) mostra `type` `P` e `need_pickup` `1` per questo ordine.

## 8. Consultare l’ordine

Il dettaglio dell’ordine conferma ciò che è stato registrato e restituisce lo stato attuale; l’endpoint dell’elenco consente all’ERP di riconciliare i propri dati con la piattaforma.

**REST:** `GET /api/v1/orders/{orderId}` — [Manuale 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`: lo stato dell’ordine; `2` è Nuovo, `12` è Annullato.
- `order.type` e `order.need_pickup`: confermano che la fermata è stata registrata come consegna o come ritiro.
- `tracking_numbers`: i numeri di tracciamento dei colli dell’ordine.

**REST:** `GET /api/v1/orders/list` — [Manuale 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"
```

L’elenco restituisce tutti gli ordini dell’account, i più recenti per primi, ognuno con i propri colli e le relative righe articolo. Inviate `page` e `per_page` insieme per paginare (`per_page` al massimo 1000); senza di essi vengono restituiti i 1000 ordini più recenti con un indicatore `truncated`.

**GraphQL:** `orders` ([Manuale GraphQL](/api/graphql/documentation#/orders/orders)) per un singolo ordine e `ordersList` ([Manuale GraphQL](/api/graphql/documentation#/orders/ordersList)) per l’elenco. Entrambi restituiscono uno scalare JSON.

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

**Verifica:** l’ordine appartiene all’account autenticato, `ref` coincide con il valore inviato alla creazione e `tracking_numbers` coincide con la risposta di creazione.

## 9. Stampare l’etichetta locale

L’etichetta riporta il codice a barre di tracciamento che l’autista scansiona in deposito e alla fermata. Stampate un’etichetta per collo e applicatela alla pila di cassette.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [Manuale 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`: come viene interpretato `id`: `TRACKING_NUMBER` (predefinito), `ORDER_ID` o `REF`.
- `base64`: `0` (predefinito) invia il PDF in streaming. Con `1` l’intero corpo della risposta è una stringa JSON di primo livello che contiene il PDF in base64, non un oggetto con un campo `pdf_data`. Richiamate invece `POST /api/v2/shipping/getShippingLabel` — [Manuale REST](/api/documentation#/paths/v2-shipping-getShippingLabel/post) per ricevere l’etichetta all’interno di un normale oggetto JSON.
- `packages`: facoltativo; il numero di etichette da stampare. Un valore diverso dal numero di colli dell’ordine aggiorna l’ordine.
- `hide_sender_address` / `hide_receiver_address`: `1` lascia vuoto quell’indirizzo sull’etichetta.

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

**Verifica:** il PDF decodificato si apre. L’etichetta di consegna mostra l’indirizzo di Café Lumière; l’etichetta di ritiro mostra l’indirizzo di Épicerie Wellington. Un indirizzo nascosto è vuoto sull’etichetta.

## 10. Tracciare l’ordine

Il tracciamento pubblico restituisce la cronologia degli eventi di un collo. Non richiede un token di accesso, quindi un portale clienti può mostrarlo direttamente; la prova di consegna o di ritiro è inclusa.

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [Manuale 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": []
}
```

Lo stesso URL accetta il vostro `ref` quando è stato registrato come numero esterno.

Basate la logica su `tracking_event_status_id`, non su `description`; quella stringa segue `Accept-Language`.

| `tracking_event_status_id` | Lato | Significato |
|---|---|---|
| `100` | entrambi | Ordine ricevuto |
| `300` / `301` | consegna | In struttura |
| `450` | consegna | In consegna |
| `500` | consegna | Consegnato |
| `501` | consegna | Consegna non riuscita, serve una nuova pianificazione |
| `460` | ritiro | In ritiro |
| `510` | ritiro | Ritirato |
| `512` | ritiro | Ritiro non riuscito, riprovare più tardi |
| `513` | ritiro | Problema di ritiro |

- `data`: i più recenti per primi; la prima riga è lo stato attuale.
- `deliveried`: `true` dopo `500`.
- `proofs[]`: su `500` o `510`, può contenere `type` `1` (firma) o `2` (foto), con `file_id` e `signed_url`. Una foto caricata dopo quell’evento non è presente in questo payload; iscrivetevi a `pod.files_updated` (passo 11).

**GraphQL:** `trackingPublic` ([Manuale GraphQL](/api/graphql/documentation#/tracking/trackingPublic)). Il risultato è tipizzato e richiede un selection set.

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

**Verifica:** subito dopo la creazione, l’evento più recente è `100` e `deliveried` è `false`. Un numero sconosciuto restituisce `result: false` con `404`; mostrate uno stato di elemento non trovato e non generate eventi di tracciamento fittizi.

## 11. Ricevere i webhook

I webhook inviano ogni modifica al vostro server, così l’ERP e il portale clienti restano aggiornati senza interrogazioni periodiche. Registrate gli URL di callback necessari per questo flusso:

| Impostazione | Evento | Utilizzo |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Registrare `id` e `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Stato mostrato al cliente |
| `tracking_event_webhook_url` | `tracking.event` | Cronologia del ritiro o della consegna |
| `pod_files_webhook_url` | `pod.files_updated` | Foto o firma dopo il ritiro o la consegna |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Un annullamento inviato è stato rifiutato |
| `order_create_async_postback_url` | `order.create_async` | Risultato di un lotto asincrono (passo 13) |

**REST:** `PUT /api/v1/webhook-settings` — [Manuale 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`: le impostazioni modificate da questa chiamata.
- `settings.webhook_sign_secret`: restituito mascherato; conservate il valore completo solo sul vostro server.

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

Sul lato ricevente, verificate la firma **v2** sul corpo non elaborato: `HMAC_SHA256(timestamp + "." + raw_body, secret)` confrontata con `X-Webhook-Signature-V2`, dove il timestamp è `X-Webhook-Timestamp`. Eliminate i duplicati in base a `X-Webhook-Event-Id`. Rispondete con **2xx entro 3 secondi** ed elaborate l’evento in seguito.

```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:** create un ordine di prova e ricevete `order.created` con gli stessi `id` e `tracking_number`. Il ricevitore rifiuta una firma non valida con `401`, e una seconda consegna dello stesso `X-Webhook-Event-Id` non viene elaborata due volte.

## 12. Annullare un ordine

Annullate un ordine quando l’ordine all’ingrosso viene ritirato o il ritiro delle cassette non è più necessario. La chiamata è idempotente: l’annullamento di un ordine già annullato riesce di nuovo.

**REST:** `POST /api/v1/orders/cancel` — [Manuale REST](/api/documentation#/paths/v1-orders-cancel/post) — inviate esattamente uno tra `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 l’ordine è annullato.
- `already_cancelled`: `true` quando l’ordine era stato annullato prima di questa chiamata; trattatelo come esito positivo.
- `code`: presente quando l’annullamento viene rifiutato; vedere il passo 14.

**GraphQL:** `ordersCancel` ([Manuale GraphQL](/api/graphql/documentation#/orders/ordersCancel)). Il risultato è tipizzato e richiede un selection set.

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

**Verifica:** il dettaglio dell’ordine mostra `orders_status_id` `12`, e lo stesso annullamento restituisce `already_cancelled: true`. Quando un annullamento viene rifiutato, `order.cancel_failed` viene inviato a `order_cancel_failed_webhook_url`.

## 13. Creare ordini in lotti (facoltativo)

L’ERP può inviare gli ordini all’ingrosso e i ritiri di cassette della giornata in un’unica richiesta. Ogni riga accetta gli stessi campi dei passi 6 e 7 e può essere di `type` `D` o `P`.

**REST:** `POST /api/v1/client/batchOrderCreate` — [Manuale REST](/api/documentation#/paths/v1-client-batchOrderCreate/post) — risponde quando tutte le righe sono state elaborate.

```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": "" }] }
]
```

- Ogni riga ha il proprio `result`; associatelo alla vostra riga d’ordine tramite `ref`. Una riga rifiutata contiene `message` e `skipped_ref`, e può contenere `code` (ad esempio `INSUFFICIENT_BALANCE` o `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` conferma ogni riga separatamente, così una riga non riuscita non può annullare le altre.
- I lotti di oltre 100 ordini ricevono un’intestazione di risposta `X-Batch-Size-Warning`; inviateli all’endpoint asincrono.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [Manuale REST](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — accetta lo stesso corpo e restituisce subito un identificativo del job:

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

Interrogate `GET /api/v1/client/async/{id}` — [Manuale REST](/api/documentation#/paths/v1-client-async-id/get) — con l’`asyncId`, oppure ricevete `order.create_async` su `order_create_async_postback_url`. Il risultato del job è lo stesso elenco per riga dell’endpoint sincrono.

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

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

**Verifica:** un lotto di due righe restituisce due risultati, ciascuno con la propria `ref`. Il job asincrono restituisce le stesse righe una volta eseguito.

## 14. Gestione degli errori

| Situazione | Stato HTTP | Codice | Cosa fa l’integrazione |
|---|---|---|---|
| Un campo obbligatorio manca o non è valido (creazione) | 400 | `VALIDATION_FAILED` | Correggere il campo indicato in `message` e inviare di nuovo la richiesta. |
| Il saldo dell’account non copre l’ordine | 400 | `INSUFFICIENT_BALANCE` | Leggere `insufficient_balance` (richiesto, disponibile, mancante); ricaricare, poi ritentare. Nessun ordine è stato creato. |
| L’indirizzo è fuori dall’area di servizio e l’azienda elimina tali ordini | 400 | `OUT_OF_DELIVERY_AREA` | Inviare un indirizzo compreso nell’area di servizio. Nessun ordine è stato creato. |
| Una `ref` di collo o un numero di tracciamento esterno esiste già (con deduplicazione attiva) | 200 (`result` `false`), oppure 409 con `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Leggere `exist_package_ref` e collegare l’ordine esistente invece di crearne uno nuovo. |
| Un `Idempotency-Key` viene riutilizzato con un corpo diverso | 409 | `IDEMPOTENCY_CONFLICT` | Usare una nuova chiave per una richiesta diversa. |
| Una richiesta con lo stesso `Idempotency-Key` è ancora in corso | 409 | `IDEMPOTENCY_IN_PROGRESS` | Attendere, poi ritentare con la stessa chiave. |
| Annullamento senza identificativo dell’ordine | 400 | `MISSING_IDENTIFIER` | Inviare uno tra `order_id`, `tracking_number`, `external_tracking_number`. |
| Annullamento di un ordine inesistente | 400 | `ORDER_NOT_FOUND` | Controllare l’`id` o il numero di tracciamento registrato. |
| Il numero corrisponde a più di un ordine attivo | 409 | `MULTIPLE_ORDERS_MATCHED` | Annullare tramite `order_id`, usando uno dei `matched_order_ids`. |
| L’ordine appartiene a un altro account | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Annullare con l’account che ha creato l’ordine. |
| Lo stato dell’ordine non consente più l’annullamento | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Lasciare l’ordine invariato; gestire il reso separatamente. |
| L’ordine è in carico a un corriere terzo che non può annullarlo | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` o `THIRD_PARTY_CANCEL_FAILED` | L’ordine è invariato; contattare l’azienda. |
| Il token manca o è scaduto, oppure l’account non può effettuare ordini | 401 | — | Effettuare di nuovo il login; verificare le autorizzazioni dell’account. |

## Elenco di verifica

Usate riferimenti di prova come `WHS-20931` e `CRT-20931`:

- [ ] (Facoltativo) Il preventivo restituisce un prezzo per un CAP in zona con `type` `D`.
- [ ] (Facoltativo) Il preventivo restituisce un prezzo per un CAP in zona con `type` `P`.
- [ ] La creazione di una consegna restituisce `id` + `tracking_number`; lo stesso `Idempotency-Key` non crea un secondo ordine.
- [ ] La creazione di un ritiro restituisce `id` + `tracking_number`; il dettaglio dell’ordine mostra `type` `P` e `need_pickup` `1`.
- [ ] Il dettaglio dell’ordine e l’elenco mostrano entrambi gli ordini per questo account.
- [ ] Il PDF dell’etichetta locale si apre e mostra il destinatario o l’indirizzo di ritiro.
- [ ] Il tracciamento pubblico restituisce la cronologia senza token; l’evento più recente è `100`.
- [ ] Arriva `order.created` e la sua firma v2 viene verificata.
- [ ] L’annullamento restituisce `result: true`, e un secondo annullamento restituisce `already_cancelled: true`.
- [ ] Un lotto con una consegna e un ritiro restituisce due risultati, ciascuno con la propria `ref`.
