# Afhaling en bezorging (eigen vloot)

Dit playbook beschrijft de API voor lokale bezorging van een bedrijfsaccount: orders die de eigen chauffeurs van het bedrijf bij een ontvanger bezorgen (`type` `D`) of bij een afzender ophalen (`type` `P`). Eén set endpoints offreert, maakt aan, labelt, volgt en annuleert beide soorten stops, en webhooks melden elke wijziging aan uw systeem. Het is geschreven voor ontwikkelaars van ordermanagementsystemen, ERP's en webwinkels die werk aan de eigen vloot van het bedrijf toewijzen.

## 1. Wat u kunt bouwen

De onderstaande voorbeelden volgen één bedrijf: **Farine & Fils**, een leverancier van bakkerijproducten met een depot aan 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), dat groothandelsorders over het hele eiland Montreal bezorgt en de lege broodkratten ophaalt die de klanten retourneren. Een typische bezorging is één stapel kratten van 12 kg en 60 × 40 × 30 cm voor Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), onder de groothandelsorder `WHS-20931`. Een typische afhaling is één stapel lege kratten van 4 kg bij Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), onder het kenmerk `CRT-20931`.

- **Groothandelsorders die vanuit het ERP naar de planning gaan.** Elke bevestigde groothandelsorder wordt een bezorgorder met het ochtendbezorgvenster van het café, en het ERP slaat het teruggegeven trackingnummer op bij de orderregel.
- **Afhalingen van retourkratten.** Wanneer een klant lege kratten meldt, maakt het ERP een afhaalorder aan voor het adres van de klant, en een chauffeur haalt de kratten op tijdens de volgende route.
- **Labels printen in het depot.** Het ERP downloadt de label-PDF van elke order en print deze aan het laadperron, zodat elke stapel kratten de trackingbarcode draagt.
- **Een klantenportaal met actuele status.** Elk café ziet de status van zijn bezorgingen en afhalingen, met het afleverbewijs, gevoed door webhooks in plaats van polling.

## 2. Wat dit playbook behandelt

Gebruik dit playbook wanneer de eigen chauffeurs van het bedrijf de order vervoeren: bezorgingen vanuit het depot en afhalingen op het adres van een klant, één voor één of in batches aangemaakt via de endpoints `/api/v1/client/...` en `/api/v1/orders/...`.

Voor nieuwe integraties is Uniorder (`/api/v1/uniorder/...`) het aanbevolen enkele toegangspunt: het biedt dezelfde bezorgingen met eigen vloot via één API, samen met vervoerderslabels, vanuit één offerte. Zie **Uniorder: één API voor elke zending** voor het overzicht en **Offerte en order in één stroom** voor de verzoeken per stap. De endpoints in dit playbook blijven beschikbaar en ongewijzigd voor integraties die erop zijn gebouwd.

Gebruik **Vervoerderslabels** wanneer een pakket wordt verzonden door een externe vervoerder met een label dat via het platform is gekocht. Gebruik **Verzenddiensten** voor orders die een klantaccount boekt op de diensten van een bedrijf, en **Opslag en uitslag** voor goederen die in een magazijn worden bewaard en op verzoek worden uitgeleverd; Uniorder is op die twee niet van toepassing.

## 3. Voordat u begint

- **Account.** Gebruik een bedrijfsaccount (klantaccount), of een medewerkersaccount van het bedrijf, met API-toestemming. Voor het aanmaken van orders is daarnaast de toestemming voor het plaatsen van orders vereist; zonder deze geeft `POST /api/v1/client/orderCreate` `401` terug.
- **Servicegebied.** Het bezorg- of afhaaladres moet binnen een actieve regio van het bedrijf liggen. Gebruik voor tests adressen binnen het gebied, zoals die in dit playbook.
- **Testgegevens.** Gebruik testkenmerken zoals `WHS-20931` en `CRT-20931`, en annuleer de testorders aan het einde (stap 12).
- **Tokens.** Vraag het toegangstoken aan vanaf uw server en bewaar het daar. Stuur het nooit naar een browser of een mobiele app.
- **Plaatshouders.** Vervang `YOUR_HOST` door de API-host van uw omgeving en `ACCESS_TOKEN` door het token uit stap 4.
- **Eenheden.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Beide hebben standaard de waarde `1`.

## 4. Authenticeren

Elke aanroep in dit playbook, behalve publieke tracking, wordt gedaan namens het bedrijfsaccount. Meld u eenmaal aan vanaf uw server, bewaar het teruggegeven token en stuur het mee bij elk verzoek.

**REST:** `POST /api/v1/user/login` — [REST-handboek](/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`: plaats dit in de header van elk volgend verzoek:

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL gebruikt dezelfde header op `POST /api/graphql`.

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

**Verificatie:** aanmelden geeft `access_token` terug. Volgende verzoeken zonder dit token geven `401` terug.

## 5. Een bezorging of afhaling offreren (optioneel)

Een offerte toont de prijs van een stop voordat de order bestaat, bijvoorbeeld om de bezorgkosten op een groothandelsfactuur te tonen. Er wordt niets aangemaakt, en voor het aanmaken van een order is geen voorafgaande offerte vereist. Zet `type` op `D` (bezorging) of `P` (afhaling); `to_postcode` is de postcode van de stop.

**REST:** `POST /api/v1/orders/rate` — [REST-handboek](/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`: de prijs van de stop exclusief belasting. Een lege prijs betekent dat de postcode niet in een actieve regio ligt, of dat de tariefkaart er geen rij voor heeft.
- `price_details.tax_details`: de belastingen die de order zal dragen; toon deze op de factuurregel.
- `currency`: de valuta van elk bedrag in de response.

Om de afhaling van de kratten te offreren, stuurt u hetzelfde verzoek met `"type": "P"`, `"to_postcode": "H4G1V5"` en het gewicht en de afmetingen van de stapel kratten.

**GraphQL:** `ordersRate` ([GraphQL-handboek](/api/graphql/documentation#/orders/ordersRate)). Het resultaat is een JSON-scalar en heeft geen 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 }]
  )
}
```

**Verificatie:** `result` is `true` en `shipping_price` is een getal voor zowel `type` `D` als `type` `P`. Het aanmaken van een order is niet afhankelijk van deze stap.

## 6. Een bezorgorder aanmaken

Elke bevestigde groothandelsorder wordt één bezorgorder. Het ERP slaat de teruggegeven `id` en `tracking_number` op bij de orderregel; elke latere aanroep gebruikt een van beide.

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

Stuur een `Idempotency-Key`-header mee, uniek per groothandelsorder, zodat een nieuwe poging na een time-out geen tweede order kan aanmaken.

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

| Veld | Betekenis |
|---|---|
| `type` | `D` bezorging, of `P` afhaling |
| `need_pick_up` | `0` — de goederen liggen al in het depot. `1` — een chauffeur moet het pakket ophalen |
| `ref` | Extern kenmerk voor opzoeken en afstemming |
| `name` / adres | Bezorging: ontvanger. Afhaling: afhaalstop |
| `schedule_date`, `time_window_start`, `time_window_end` | Bezorgdatum (`Y-m-d`) en het venster waarin de stop moet worden bediend (`Y-m-d H:i:s`) |
| `packagesDetail` | Eén vermelding per pakket; `ref` identificeert het pakket in uw systeem |
| `auto_deduplication` | `1` weigert een tweede pakket met hetzelfde pakket-`ref` |

In de response:

- `id`: de order-id; bewaar deze voor het orderdetail en de annuleringsaanroep.
- `tracking_number`: één trackingnummer per pakket; print en volg hiermee.
- `warning`: aanwezig wanneer de order met een melding is aangemaakt, bijvoorbeeld een adres buiten het bezorggebied dat het bedrijf bewaart of vasthoudt. Een bewaarde order buiten het gebied kan `shipping_price: null` teruggeven.

**GraphQL:** `clientOrderCreate` ([GraphQL-handboek](/api/graphql/documentation#/client/clientOrderCreate)). Het resultaat is een JSON-scalar met dezelfde body als de REST-response.

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

**Verificatie:** stuur dezelfde body opnieuw met dezelfde `Idempotency-Key`. De response bevat dezelfde `id`, en er wordt geen tweede order aangemaakt.

## 7. Een afhaalorder aanmaken

Een afhaalorder stuurt een chauffeur om goederen op een adres op te halen; hier de lege kratten bij Épicerie Wellington. Het gebruikt hetzelfde endpoint als een bezorging: het adres is de afhaalstop, `type` is `P` en `need_pick_up` is `1`.

**REST:** `POST /api/v1/client/orderCreate` — [REST-handboek](/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` en `tracking_number`: bewaar deze bij de kratretour, zoals bij een bezorging.
- `pickup_instruction`: wordt aan de chauffeur getoond bij de afhaalstop; `delivery_instruction` is de tegenhanger bij een bezorging.

**Verificatie:** het orderdetail (stap 8) toont `type` `P` en `need_pickup` `1` voor deze order.

## 8. De order lezen

Het orderdetail bevestigt wat is opgeslagen en geeft de huidige status terug; met het lijstendpoint stemt het ERP de eigen gegevens af met het platform.

**REST:** `GET /api/v1/orders/{orderId}` — [REST-handboek](/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`: de orderstatus; `2` is Nieuw, `12` is Geannuleerd.
- `order.type` en `order.need_pickup`: bevestigen dat de stop als bezorging of als afhaling is opgeslagen.
- `tracking_numbers`: de trackingnummers van de pakketten van de order.

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

De lijst geeft alle orders van het account terug, de nieuwste eerst, elk met de pakketten en artikelregels. Stuur `page` en `per_page` samen om te pagineren (`per_page` maximaal 1000); zonder deze worden de nieuwste 1000 orders teruggegeven met een `truncated`-markering.

**GraphQL:** `orders` ([GraphQL-handboek](/api/graphql/documentation#/orders/orders)) voor één order en `ordersList` ([GraphQL-handboek](/api/graphql/documentation#/orders/ordersList)) voor de lijst. Beide geven een JSON-scalar terug.

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

**Verificatie:** de order hoort bij het geauthenticeerde account, `ref` komt overeen met de waarde die bij het aanmaken is verzonden, en `tracking_numbers` komt overeen met de response van het aanmaken.

## 9. Het lokale label printen

Het label draagt de trackingbarcode die de chauffeur in het depot en bij de stop scant. Print één label per pakket en bevestig het op de stapel kratten.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [REST-handboek](/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`: hoe `id` wordt gelezen: `TRACKING_NUMBER` (standaard), `ORDER_ID` of `REF`.
- `base64`: `0` (standaard) streamt de PDF. `1` maakt van de volledige responsbody een JSON-tekenreeks op het hoogste niveau met de base64-PDF, geen object met een veld `pdf_data`. Roep in plaats daarvan `POST /api/v2/shipping/getShippingLabel` — [REST-handboek](/api/documentation#/paths/v2-shipping-getShippingLabel/post) aan om het label in een gewoon JSON-object te ontvangen.
- `packages`: optioneel; het aantal te printen labels. Een waarde die afwijkt van het aantal pakketten van de order werkt de order bij.
- `hide_sender_address` / `hide_receiver_address`: `1` laat dat adres leeg op het label.

**GraphQL:** `shippingGetShippingLabel` ([GraphQL-handboek](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([GraphQL-handboek](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) geeft altijd JSON (`pdf_data`) terug.

**Verificatie:** de gedecodeerde PDF opent. Het bezorglabel toont het adres van Café Lumière; het afhaallabel toont het adres van Épicerie Wellington. Een verborgen adres is leeg op het label.

## 10. De order volgen

Publieke tracking geeft de tijdlijn van gebeurtenissen van een pakket terug. Er is geen toegangstoken nodig, zodat een klantenportaal deze direct kan tonen; het bewijs van bezorging of afhaling wordt meegeleverd.

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

Dezelfde URL accepteert uw `ref` wanneer die als extern nummer is opgeslagen.

Vertak op `tracking_event_status_id`, niet op `description`; die tekenreeks volgt `Accept-Language`.

| `tracking_event_status_id` | Kant | Betekenis |
|---|---|---|
| `100` | beide | Order ontvangen |
| `300` / `301` | bezorging | In de vestiging |
| `450` | bezorging | Onderweg voor bezorging |
| `500` | bezorging | Bezorgd |
| `501` | bezorging | Bezorging mislukt, nieuw plan nodig |
| `460` | afhaling | Onderweg voor ophalen |
| `510` | afhaling | Opgehaald |
| `512` | afhaling | Ophalen mislukt, later opnieuw proberen |
| `513` | afhaling | Probleem bij ophalen |

- `data`: nieuwste eerst; de eerste rij is de huidige status.
- `deliveried`: `true` na `500`.
- `proofs[]`: bij `500` of `510` kan dit `type` `1` (handtekening) of `2` (foto) bevatten, met `file_id` en `signed_url`. Een foto die na dat event is geüpload, staat niet in deze payload; abonneer u op `pod.files_updated` (stap 11).

**GraphQL:** `trackingPublic` ([GraphQL-handboek](/api/graphql/documentation#/tracking/trackingPublic)). Het resultaat is getypeerd en heeft een selection set nodig.

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

**Verificatie:** direct na het aanmaken is het nieuwste event `100` en is `deliveried` `false`. Een onbekend nummer geeft `result: false` met `404` terug; toon een status 'niet gevonden' en verzin geen trackingevents.

## 11. Webhooks ontvangen

Webhooks sturen elke wijziging naar uw server, zodat het ERP en het klantenportaal actueel blijven zonder polling. Registreer de callback-URL's die deze stroom nodig heeft:

| Instelling | Event | Gebruik |
|---|---|---|
| `order_create_webhook_url` | `order.created` | `id` en `tracking_number` opslaan |
| `order_status_change_webhook_url` | `order.status_change` | Status zichtbaar voor de klant |
| `tracking_event_webhook_url` | `tracking.event` | Tijdlijn van afhaling of bezorging |
| `pod_files_webhook_url` | `pod.files_updated` | Foto of handtekening na afhaling of bezorging |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Een annulering die u stuurde is geweigerd |
| `order_create_async_postback_url` | `order.create_async` | Resultaat van een asynchrone batch (stap 13) |

**REST:** `PUT /api/v1/webhook-settings` — [REST-handboek](/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`: de instellingen die deze aanroep heeft gewijzigd.
- `settings.webhook_sign_secret`: wordt gemaskeerd teruggegeven; bewaar de volledige waarde alleen op uw server.

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

Verifieer aan de ontvangende kant de **v2**-handtekening over de ruwe body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` vergeleken met `X-Webhook-Signature-V2`, waarbij de timestamp `X-Webhook-Timestamp` is. Dedupliceer op `X-Webhook-Event-Id`. Antwoord met **2xx binnen 3 seconden** en verwerk het event daarna.

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

**Verificatie:** maak één testorder aan en ontvang `order.created` met dezelfde `id` en `tracking_number`. De ontvanger weigert een ongeldige handtekening met `401`, en een tweede levering van dezelfde `X-Webhook-Event-Id` wordt niet twee keer verwerkt.

## 12. Een order annuleren

Annuleer een order wanneer de groothandelsorder wordt ingetrokken of de afhaling van de kratten niet meer nodig is. De aanroep is idempotent: het annuleren van een order die al geannuleerd is, slaagt opnieuw.

**REST:** `POST /api/v1/orders/cancel` — [REST-handboek](/api/documentation#/paths/v1-orders-cancel/post) — stuur precies één van `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` wanneer de order is geannuleerd.
- `already_cancelled`: `true` wanneer de order vóór deze aanroep al was geannuleerd; behandel dit als geslaagd.
- `code`: aanwezig wanneer de annulering wordt geweigerd; zie stap 14.

**GraphQL:** `ordersCancel` ([GraphQL-handboek](/api/graphql/documentation#/orders/ordersCancel)). Het resultaat is getypeerd en heeft een selection set nodig.

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

**Verificatie:** het orderdetail toont `orders_status_id` `12`, en dezelfde annulering geeft `already_cancelled: true` terug. Wanneer een annulering wordt geweigerd, wordt `order.cancel_failed` naar `order_cancel_failed_webhook_url` verzonden.

## 13. Orders in batches aanmaken (optioneel)

Het ERP kan de groothandelsorders en kratafhalingen van de dag in één verzoek versturen. Elke rij gebruikt dezelfde velden als stap 6 en 7 en kan `type` `D` of `P` zijn.

**REST:** `POST /api/v1/client/batchOrderCreate` — [REST-handboek](/api/documentation#/paths/v1-client-batchOrderCreate/post) — antwoordt wanneer elke rij is verwerkt.

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

- Elke rij heeft een eigen `result`; koppel deze via `ref` aan uw orderregel. Een geweigerde rij bevat `message` en `skipped_ref`, en kan `code` bevatten (bijvoorbeeld `INSUFFICIENT_BALANCE` of `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` legt elke rij afzonderlijk vast, zodat één mislukte rij de andere niet kan terugdraaien.
- Batches van meer dan 100 orders krijgen een responseheader `X-Batch-Size-Warning`; stuur deze naar het asynchrone endpoint.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [REST-handboek](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — accepteert dezelfde body en geeft onmiddellijk een taak-id terug:

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

Poll `GET /api/v1/client/async/{id}` — [REST-handboek](/api/documentation#/paths/v1-client-async-id/get) — met de `asyncId`, of ontvang `order.create_async` op `order_create_async_postback_url`. Het resultaat van de taak is dezelfde lijst per rij als bij het synchrone endpoint.

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

**GraphQL:** `clientBatchOrderCreate` ([GraphQL-handboek](/api/graphql/documentation#/client/clientBatchOrderCreate)), `clientBatchOrderCreateAsync` ([GraphQL-handboek](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)) en `clientAsync` ([GraphQL-handboek](/api/graphql/documentation#/client/clientAsync)).

**Verificatie:** een batch van twee rijen geeft twee resultaten terug, elk met de eigen `ref`. De asynchrone taak geeft dezelfde rijen terug zodra deze is uitgevoerd.

## 14. Fouten afhandelen

| Situatie | HTTP-status | Code | Wat de integratie doet |
|---|---|---|---|
| Een verplicht veld ontbreekt of is ongeldig (aanmaken) | 400 | `VALIDATION_FAILED` | Corrigeer het veld dat in `message` wordt genoemd en verstuur het verzoek opnieuw. |
| Het saldo van het account dekt de order niet | 400 | `INSUFFICIENT_BALANCE` | Lees `insufficient_balance` (vereist, beschikbaar, tekort); waardeer op en probeer het opnieuw. Er is geen order aangemaakt. |
| Het adres ligt buiten het servicegebied en het bedrijf verwijdert zulke orders | 400 | `OUT_OF_DELIVERY_AREA` | Dien een adres binnen het servicegebied in. Er is geen order aangemaakt. |
| Een pakket-`ref` of extern trackingnummer bestaat al (met deduplicatie ingeschakeld) | 200 (`result` `false`), of 409 met `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Lees `exist_package_ref` en koppel de bestaande order in plaats van een nieuwe aan te maken. |
| Een `Idempotency-Key` wordt hergebruikt met een andere body | 409 | `IDEMPOTENCY_CONFLICT` | Gebruik een nieuwe sleutel voor een ander verzoek. |
| Een verzoek met dezelfde `Idempotency-Key` wordt nog verwerkt | 409 | `IDEMPOTENCY_IN_PROGRESS` | Wacht en probeer het daarna opnieuw met dezelfde sleutel. |
| Annuleren zonder order-id | 400 | `MISSING_IDENTIFIER` | Stuur één van `order_id`, `tracking_number`, `external_tracking_number`. |
| Annuleren van een order die niet bestaat | 400 | `ORDER_NOT_FOUND` | Controleer de opgeslagen `id` of het trackingnummer. |
| Het nummer komt overeen met meer dan één actieve order | 409 | `MULTIPLE_ORDERS_MATCHED` | Annuleer via `order_id`, met een van de `matched_order_ids`. |
| De order hoort bij een ander account | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Annuleer met het account dat de order heeft aangemaakt. |
| De status van de order staat geen annulering meer toe | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Laat de order ongewijzigd; handel de retour afzonderlijk af. |
| De order is in handen van een externe vervoerder die deze niet kan annuleren | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` of `THIRD_PARTY_CANCEL_FAILED` | De order is ongewijzigd; neem contact op met het bedrijf. |
| Het token ontbreekt of is verlopen, of het account mag geen orders plaatsen | 401 | — | Meld u opnieuw aan; controleer de rechten van het account. |

## Testlijst

Gebruik testkenmerken zoals `WHS-20931` en `CRT-20931`:

- [ ] (Optioneel) De offerte geeft een prijs terug voor een postcode binnen het gebied met `type` `D`.
- [ ] (Optioneel) De offerte geeft een prijs terug voor een postcode binnen het gebied met `type` `P`.
- [ ] Het aanmaken van een bezorging geeft `id` + `tracking_number` terug; dezelfde `Idempotency-Key` maakt geen tweede order aan.
- [ ] Het aanmaken van een afhaling geeft `id` + `tracking_number` terug; het orderdetail toont `type` `P` en `need_pickup` `1`.
- [ ] Het orderdetail en de lijst tonen beide orders onder dit account.
- [ ] De PDF van het lokale label opent en toont de ontvanger of het afhaaladres.
- [ ] Publieke tracking geeft de tijdlijn terug zonder token; het nieuwste event is `100`.
- [ ] `order.created` komt binnen en de v2-handtekening wordt geverifieerd.
- [ ] Annuleren geeft `result: true` terug, en een tweede annulering geeft `already_cancelled: true` terug.
- [ ] Een batch met één bezorging en één afhaling geeft twee resultaten terug, elk met de eigen `ref`.
