# Verzenddiensten

Met de API voor verzenddiensten boekt een klantaccount de verzenddiensten die de logistieke dienstverlener heeft geconfigureerd en aan het account heeft toegewezen. Het eigen systeem van de klant haalt de diensten op die het mag gebruiken, laadt de regels van één dienst, prijst een zending, maakt de verzendorder aan, betaalt deze uit het saldo van het account en volgt de zending tot de bezorging. Dit playbook is bedoeld voor ontwikkelaars die het systeem van een importeur, handelaar of groothandel koppelen aan de logistieke dienstverlener die deze bedient.

## 1. Wat u kunt bouwen

Alle voorbeelden in dit playbook gebruiken één scenario. **Harbourline Imports Inc.**, een theeimporteur in Toronto, heeft een klantaccount bij de logistieke dienstverlener. De dienstverlener biedt de dienst `intl_express` (International Express) aan vanuit het magazijn Toronto Hub (magazijn-id `7`). Harbourline levert twee dozen monsterthee af bij de Toronto Hub voor een distributeur in Seattle, onder de inkooporder `HLI-PO-1058`.

- **Boeken vanuit het inkoopordersysteem.** Wanneer een inkooporder wordt vrijgegeven, prijst het systeem van Harbourline de zending op `intl_express`, maakt het de verzendorder aan met het inkoopordernummer als kenmerk en betaalt het deze uit het vooraf betaalde saldo van het account, zonder dat iemand het portaal van de dienstverlener opent.
- **Een prijscontrole vóór de toezegging.** De inkoper bij Harbourline ziet de vracht, toeslagen, belasting en het totaal voor de twee dozen voordat de zending wordt geboekt, en een zending die de dienst niet kan prijzen, wordt tegengehouden voordat er een order bestaat.
- **Zendingsstatus in het ERP.** Het trackingnummer van elke doos wordt bij de inkooporder opgeslagen; webhooks brengen de orderstatus en de trackingtijdlijn naar het ERP, en een nachtelijke taak stemt af met de orderlijst.
- **Gecontroleerde wijzigingen.** Een onbetaalde boeking wordt ter plaatse gecorrigeerd, en een boeking die niet meer nodig is, wordt geannuleerd, waarbij het betaalde bedrag naar het tegoed van het account terugkeert.

## 2. Wat dit playbook behandelt

Gebruik deze groep wanneer de aanroeper een **klant** van het logistieke bedrijf is en een van de eigen verzenddiensten van het bedrijf boekt: het bedrijf stelt het prijsplan, de magazijnen, de toeslagen en de verpakkingen vast en wijst de diensten aan de klant toe. De klant ziet en boekt alleen de diensten die aan hem zijn toegewezen.

Gebruik in de volgende gevallen een andere groep:

- De aanroeper is het logistieke bedrijf zelf (een bedrijfs-/klantaccount) en boekt afhalingen en bezorgingen op dezelfde dag of lokaal met de eigen vloot: lees **Afhaling en bezorging (eigen vloot)**.
- De aanroeper koopt vervoerderslabels (bijvoorbeeld UPS of FedEx) tegen de onderhandelde tarieven van het account: lees **Vervoerderslabels**.
- De klant slaat goederen op in het magazijn van de dienstverlener en verzendt ze vanuit de voorraad: lees **Opslag en uitslag**.

**Uniorder: één API voor elke zending** (`/api/v1/uniorder/...`) is het aanbevolen enkele toegangspunt voor nieuwe integraties van lokale bezorging en vervoerderslabels. Uniorder omvat geen verzenddiensten: orders voor verzenddiensten worden uitsluitend aangemaakt en beheerd via de hier beschreven endpoints `/api/v1/customer/shipping-orders/...`.

## 3. Voordat u begint

- **Accounttype.** Een **klantaccount** van het logistieke bedrijf, waarop het bedrijf **API-toestemming** heeft ingeschakeld. Een bedrijfs-/klantaccount of medewerkersaccount kan zich niet aanmelden via de onderstaande klantaanmelding.
- **Toewijzing van diensten.** Het bedrijf moet ten minste één actieve verzenddienst aan de klant toewijzen. Een klant zonder toegewezen dienst ontvangt een lege lijst met diensten.
- **Testgegevens.** Spreek met het bedrijf een testdienstcode, een testmagazijn en een klein vooraf betaald saldo op het testaccount af. Gebruik een kenmerk zoals `HLI-PO-1058` of `DEV-SHIP-001`, zodat testorders gemakkelijk te vinden en te annuleren zijn.
- **Omgang met tokens.** Roep de API alleen vanaf uw server aan. Houd het wachtwoord en het toegangstoken buiten browsers en mobiele clients. Het token verloopt één week na het aanmelden (`expires_at`); meld u opnieuw aan voordat het verloopt.
- **Plaatshouders.** Vervang `YOUR_HOST` door de hostnaam van het logistieke bedrijf en `ACCESS_TOKEN` door het token dat de aanmeldstap teruggeeft.
- **JSON-fouten.** Stuur bij elk verzoek `Accept: application/json` mee, zodat validatiefouten JSON teruggeven in plaats van een omleiding.

## 4. Aanmelden als klant

Bij het aanmelden worden het e-mailadres en wachtwoord van de klant ingewisseld voor een bearer-token. Elke latere aanroep in dit playbook stuurt dat token mee.

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

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

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2026-10-05 09:15:00",
  "expires_timestamp": 1791206100,
  "name": "Harbourline Imports Inc."
}
```

- `access_token`: stuur dit bij elk verzoek mee als `Authorization: Bearer ACCESS_TOKEN`. GraphQL gebruikt dezelfde header op `POST /api/graphql`.
- `expires_at` / `expires_timestamp`: plan vóór dit tijdstip een nieuwe aanmelding.

**Verificatie:** de response bevat `result: true` en een `access_token`. Een verzoek zonder het token geeft `401` terug; aanmelden met een account dat geen klantaccount is of geen API-toestemming heeft, geeft eveneens `401` terug.

## 5. De aan de klant toegewezen diensten ophalen

De lijst met diensten vertelt de integratie welke dienstcodes mogen worden geboekt en of elke dienst afgifte in een magazijn, een afhaling of beide accepteert. Bewaar de `service_code`; elke latere aanroep voor de dienst gebruikt deze.

**REST:** `GET /api/v1/customer/shipping-orders/services` — [REST-handboek](/api/documentation#/paths/v1-customer-shipping-orders-services/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "services": [
      {
        "id": 12,
        "service_code": "intl_express",
        "name": { "en": "International Express" },
        "offer_pickup": true,
        "allow_warehouse_delivery": true,
        "support_multi_package": true,
        "allow_special_requirements": false,
        "allow_purchase_supplies": true,
        "send_confirmation_email": true,
        "warehouses": [{ "id": 7, "name": "Toronto Hub" }]
      }
    ]
  }
}
```

- `service_code`: de padparameter van elke latere aanroep voor de dienst.
- `offer_pickup` / `allow_warehouse_delivery`: de toegestane waarden van `origin_type` (`pickup` / `warehouse`).
- `support_multi_package`: of één order meer dan één pakketregel mag bevatten.
- Een lege array `services` betekent dat aan deze klant geen dienst is toegewezen.

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

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

**Verificatie:** de lijst bevat ten minste één dienst en u hebt de `service_code` ervan opgeslagen (in dit playbook: `intl_express`).

## 6. De configuratie van de dienst laden

De configuratie geeft alles terug wat het orderformulier van één dienst nodig heeft: de magazijnen die afgiften accepteren, de selecteerbare toeslagen, de catalogus met verpakkingen en benodigdheden, de eenheden, en de landen waaruit de dienst kan ophalen en waarnaar hij kan bezorgen. Valideer uw ordergegevens hiertegen voordat u iets prijst of aanmaakt.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [REST-handboek](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/config \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "service": {
      "service_code": "intl_express",
      "offer_pickup": true,
      "allow_warehouse_delivery": true,
      "allow_special_requirements": false,
      "allow_purchase_supplies": true,
      "weight_mode": 2,
      "volumetric_factor": 5000
    },
    "warehouses": [
      {
        "id": 7,
        "name": "Toronto Hub",
        "address_1": "250 Dundas St W",
        "city": "Toronto",
        "province": "ON",
        "country": "CA",
        "postcode": "M5T 2Z5",
        "telephone": "4165550100"
      }
    ],
    "surcharges": [],
    "has_surcharges": false,
    "packagings": [],
    "products": [],
    "weight_units": { "2": { "name": "weight_kg", "accuracy": 3 } },
    "dimension_units": { "2": { "name": "dimension_cm", "accuracy": 1 } },
    "delivery_allowed_countries": ["CA", "US"],
    "pickup_allowed_countries": ["CA"]
  }
}
```

- `warehouses[].id`: de `warehouse_id` die u meestuurt wanneer `origin_type` gelijk is aan `warehouse`. Een id die niet in deze lijst staat, wordt bij het aanmaken geweigerd.
- `service.weight_mode`: welke pakketvelden het prijsplan vereist: `0` werkelijk gewicht (gewicht), `1` volumegewicht (lengte, breedte en hoogte), `2` factureerbaar gewicht (beide). `null` betekent dat de dienst handmatig wordt geprijsd. Stuur het gewicht en alle drie de afmetingen mee om aan elke modus te voldoen.
- `delivery_allowed_countries` / `pickup_allowed_countries`: weiger een bestemmings- of afhaalland buiten deze lijsten voordat u de schatting aanroept.
- `surcharges[].id`, `packagings[].id`, `products[].id`: de id's voor optionele toeslagen, verpakkingen en aankopen van benodigdheden.
- `weight_units` / `dimension_units`: pakketeenheden worden als getallen verzonden. Stuur `weight_unit: 2` (kg) en `dimension_unit: 2` (cm), zoals alle voorbeelden in dit playbook doen; beide zijn ook de standaardwaarden wanneer de velden worden weggelaten.

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

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

**Verificatie:** `result` is `true` en, bij afgifte in een magazijn, bevat `warehouses` het magazijn dat u wilt gebruiken. `403` betekent dat de dienst niet aan deze klant is toegewezen; `404` betekent dat de dienstcode niet bestaat of inactief is.

## 7. De prijs schatten

De schatting prijst de zending met het prijsplan van de dienst zonder iets op te slaan. Toon het totaal aan de inkoper, en maak de order niet aan wanneer de schatting een weigering meldt.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/estimate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_postcode": "98104",
    "delivery_country": "US",
    "packages": [{
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "needs_manual_quote": false,
    "shipping_fee": 118.40,
    "shipping_fee_total": 131.20,
    "fuel_surcharge": 12.80,
    "pickup_fee": null,
    "surcharges_total": 0,
    "sub_total": 131.20,
    "tax": { "total_tax": 17.06 },
    "total": 148.26,
    "currency": "CAD",
    "all_fees_calculated": true,
    "has_items_needing_quote": false,
    "unpriced_items": [],
    "refused": false,
    "refusal_message": null
  }
}
```

- `origin_type`: `warehouse` (de klant levert de goederen af in een magazijn; stuur `warehouse_id` mee) of `pickup` (de dienstverlener haalt op; stuur `pickup_postcode` en `pickup_country` mee). Gebruik alleen een waarde die stap 5 toestaat.
- `packages`: één regel per groep identieke pakketten; `quantity` vermenigvuldigt de regel.
- `total` en `currency`: het te tonen bedrag. `total` is `null` zolang een kostenpost niet is berekend.
- `needs_manual_quote` / `has_items_needing_quote`: het bedrijf prijst de order handmatig; de order kan worden aangemaakt en wordt betaald nadat het bedrijf de prijs heeft vastgesteld.
- `refused` / `refusal_message`: de dienst weigert zendingen die hij niet kan prijzen. Maak de order niet aan; toon in plaats daarvan `refusal_message`.
- Optionele invoer: `surcharges`, `products` (een koppeling van product-id naar aantal, alleen verwerkt wanneer `allow_purchase_supplies` true is), `has_special_requirements`, `coupon_code`.

**Verificatie:** `result` is `true`, `refused` is `false`, en `total` heeft een waarde of `needs_manual_quote` is `true`.

## 8. De verzendorder aanmaken

De aanmaakaanroep boekt de zending op de dienst. De integratie slaat de teruggegeven `id` op bij de eigen inkooporder; elke latere aanroep gebruikt deze id.

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

Stuur een `Idempotency-Key` mee die is afgeleid van uw eigen stabiele id (hier het inkoopordernummer). Een nieuwe poging met dezelfde sleutel en dezelfde body geeft het eerste antwoord terug met `"replayed": true` en de header `Idempotency-Replayed: true`, en maakt geen tweede order aan.

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: hli-po-1058" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "note": "Two cartons of sample tea, dock door B",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "price_breakdown": { "total": 148.26, "currency": "CAD" },
    "promotion_id": null,
    "tracking_number": null
  }
}
```

- De sleutel in de body voor pakketten is `package` bij het aanmaken (bij de schatting is dit `packages`). Elke regel met `quantity` N wordt N pakketten, en elk pakket krijgt een eigen trackingnummer.
- Verplichte velden: `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight`; plus `warehouse_id` voor `warehouse`, of `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` voor `pickup`.
- Optionele velden: `reference` (opgeslagen als `reference_number` van de order), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (een array van tekstregels, alleen verwerkt wanneer de dienst deze toestaat), `products`, `surcharges`, `coupon_code`.
- `id`: bewaar deze. `status` `0` is In afwachting (wacht op betaling).
- `total_price`: het bedrag dat stap 9 in rekening brengt. Het is `0` zolang de order op een handmatige offerte wacht.
- `tracking_number` op orderniveau is `null`; de trackingnummers staan bij de pakketten en worden in stap 10 gelezen.
- Het endpoint antwoordt met HTTP `201` bij een nieuwe order.

**Verificatie:** de response bevat `result: true` en een `id`. Hetzelfde verzoek herhalen met dezelfde `Idempotency-Key` geeft dezelfde `id` terug met `"replayed": true`.

## 9. De order betalen uit het saldo van het account

Verzendorders worden volledig betaald uit het saldo van het klantaccount. Een betaalde order gaat van In afwachting naar Bevestigd, en de dienstverlener begint met de afhandeling.

Lees eerst het bedrag:

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "order_id": 9001,
    "currency": "CAD",
    "total_price": "148.26",
    "paid_amount": "0.00",
    "remaining_balance": "148.26",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "shortfall": 0,
    "payment_options": [
      { "type": "remaining_balance", "amount": 148.26 }
    ]
  }
}
```

Betaal daarna:

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

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

```json
{
  "result": true,
  "message": "Payment of $148.26 processed successfully.",
  "data": {
    "order_id": 9001,
    "status": 1,
    "amount_paid": "148.26"
  }
}
```

- `has_sufficient_balance` / `shortfall`: wanneer het saldo `remaining_balance` niet dekt, waardeert u het account op voordat u betaalt.
- `payment_type`: alleen `remaining_balance` wordt ondersteund; het volledige resterende bedrag wordt altijd in rekening gebracht.
- `data.status` `1` is Bevestigd.

**Verificatie:** de betaalaanroep geeft `result: true` en `status` `1` terug, en een tweede `payment-info`-aanroep geeft `400` terug omdat de order volledig is betaald. Een betaalaanroep zonder voldoende saldo geeft `422` terug en brengt niets in rekening.

## 10. De order lezen en de pakketten volgen

De detailaanroep geeft de huidige status en het trackingnummer van elk pakket terug. Sla de trackingnummers van de pakketten op bij de inkooporder; publieke tracking accepteert elk ervan.

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 1,
    "status_name": "Confirmed",
    "can_edit": false,
    "can_cancel": true,
    "shipping_code": "K7RW2Q",
    "tracking_number": null,
    "reference_number": "HLI-PO-1058",
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_address": { "name": "Cascade Tea Distributors", "city": "Seattle", "country": "US" },
    "package_count": 2,
    "packages": [
      { "id": 55101, "description": "Sample tea carton", "tracking_number": "SR123456789012", "weight": 12, "weight_unit": 2 },
      { "id": 55102, "description": "Sample tea carton", "tracking_number": "SR123456789013", "weight": 12, "weight_unit": 2 }
    ],
    "total_price": 148.26
  }
}
```

- `status`: `0` In afwachting, `1` Bevestigd, `2` Onderweg, `3` Verzonden, `4` Geannuleerd, `5` Mislukt, `6` Gedeeltelijk opgehaald, `7` Opgehaald, `8` In behandeling.
- `can_edit` / `can_cancel`: of stap 12 op dit moment is toegestaan.
- `packages[].tracking_number`: de nummers om op te slaan en te volgen.
- `shipping_code`: de code die de afgifteschermen in het magazijn accepteren; print deze op de afgiftedocumenten.

**GraphQL:** `customerShippingOrderShow` ([GraphQL-handboek](/api/graphql/documentation#/customer/customerShippingOrderShow))

```graphql
query CustomerShippingOrderShow {
  customerShippingOrderShow(id: 9001) {
    result
    message
    data {
      id
      status
      status_name
      can_cancel
      reference_number
      packages {
        id
        tracking_number
        weight
      }
      total_price
    }
  }
}
```

Om alle orders van één dienst af te stemmen, bijvoorbeeld in een nachtelijke taak, haalt u ze op met een filter. Het filter `id` komt overeen met de order-id, een trackingnummer of het kenmerk.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/orders` — [REST-handboek](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--orders/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders?id=HLI-PO-1058&created_at_from=2026-09-01&per_page=20" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

**GraphQL:** `customerShippingOrders` ([GraphQL-handboek](/api/graphql/documentation#/customer/customerShippingOrders))

Publieke tracking vereist geen token en geeft de tijdlijn van gebeurtenissen van één pakket terug:

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

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012 \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 1,
      "otep_status": "received",
      "description": "Received at warehouse",
      "location_city": "Toronto",
      "updated_at": "2026-09-29 10:42:00"
    }
  ]
}
```

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

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

**Verificatie:** het detail geeft de order van deze klant terug met één trackingnummer per pakket, en publieke tracking geeft `result: true` terug voor een trackingnummer van een pakket. De order-id van een andere klant geeft `404` terug.

## 11. Webhooks ontvangen

Webhooks leveren het aanmaken van orders, statuswijzigingen en trackingevents aan uw server, zodat de integratie niet hoeft te pollen. Het klantaccount configureert zelf de webhook-URL's en het ondertekeningsgeheim.

Wanneer een verzendorder wordt aangemaakt, maakt de dienstverlener ook een gekoppelde afhaalorder aan voor het planningsteam. De webhooks worden voor die gekoppelde order verzonden: de `ref` ervan is `Shipping-Pickup-{shipping order id}` (bijvoorbeeld `Shipping-Pickup-9001`), en elk pakket ervan bevat het trackingnummer van het verzendpakket in `external_tracking_number`. Koppel binnenkomende events aan de hand van deze twee velden.

| Instelling | Event | Wat de integratie doet |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Het event via `ref` en `packages[].external_tracking_number` aan de verzendorder koppelen |
| `tracking_event_webhook_url` | `tracking.event` | Het event aan de tijdlijn van het pakket toevoegen |
| `order_status_change_webhook_url` | `order.status_change` | De status in uw systeem bijwerken |

**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" \
  -H "Accept: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.harbourline-imports.example/hooks/order-created",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking",
    "order_status_change_webhook_url": "https://erp.harbourline-imports.example/hooks/status",
    "webhook_sign_secret": "hli-webhook-secret-7f2c9a1e5b"
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "tracking_event_webhook_url",
    "order_status_change_webhook_url",
    "webhook_sign_secret"
  ],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "*************************1e5b",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking"
  }
}
```

- Alleen de ingediende sleutels worden gewijzigd; een lege tekenreeks wist een URL. `webhook_sign_secret` moet 16 tot 255 tekens lang zijn, en er wordt geen webhook verzonden zolang het geheim leeg is.
- `recipient_type` is `customer` voor een klantaccount.

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

```graphql
mutation WebhookSettingsUpdate {
  webhookSettingsUpdate(
    order_status_change_webhook_url: "https://erp.harbourline-imports.example/hooks/status"
    tracking_event_webhook_url: "https://erp.harbourline-imports.example/hooks/tracking"
  )
}
```

Verifieer de **v2**-handtekening over de ruwe body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` tegen `X-Webhook-Signature-V2`. 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:** na de instellingsaanroep levert één testaanmaak een `order.created`-event op waarvan de `ref` gelijk is aan `Shipping-Pickup-{id}` voor de id van de nieuwe verzendorder, en de handtekeningcontrole slaagt.

## 12. Een order wijzigen of annuleren

Een order kan worden gecorrigeerd zolang deze In afwachting is (vóór betaling) en worden geannuleerd zolang deze In afwachting of Bevestigd is. Bij annulering van een betaalde order keert het betaalde bedrag terug naar het tegoed van het account.

Om te wijzigen, stuurt u de volledige order opnieuw met dezelfde velden als in stap 8. De prijs wordt opnieuw berekend.

**REST:** `PUT /api/v1/customer/shipping-orders/{id}` — [REST-handboek](/api/documentation#/paths/v1-customer-shipping-orders-id/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_address_2": "Suite 210",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "promotion_id": null,
    "coupon_code": null,
    "promotion_discount": null
  }
}
```

Om te annuleren:

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

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

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 4,
    "refund_amount": 148.26
  },
  "message": "Order cancelled. $148.26 refunded to your credit."
}
```

- `status` `4` is Geannuleerd. De gekoppelde afhaalorder wordt verwijderd.
- `refund_amount`: het bedrag dat naar het tegoed van het account terugkeert; `0` voor een onbetaalde order.
- Lees `can_edit` en `can_cancel` uit stap 10 voordat u deze acties aan gebruikers aanbiedt.

**Verificatie:** de annulering geeft `status` `4` terug en het detail toont `status_name` `Cancelled`. Een tweede annulering, of een annulering van een order die Onderweg of verder is, geeft `403` terug met het bericht `This order can no longer be cancelled.`; een wijziging van een betaalde order geeft `403` terug.

## 13. Fouten afhandelen

| Situatie | HTTP-status | Code | Wat de integratie doet |
|---|---|---|---|
| Token ontbreekt, is verlopen of ongeldig; aanmelden met een account dat geen klantaccount is of zonder API-toestemming | `401` | — | Meld u opnieuw aan; als het aanmelden zelf mislukt, vraag het bedrijf het accounttype en de API-toestemming te controleren |
| De dienst is niet aan deze klant toegewezen | `403` | — | Lees de lijst met diensten opnieuw (stap 5) en boek alleen toegewezen diensten |
| De API wordt aangeroepen vanuit een sessie van een platformapp waarvan de app verzendorders heeft uitgeschakeld | `403` | `APP_CAPABILITY_DISABLED` | Vraag het bedrijf verzendorders voor de app in te schakelen |
| Onbekende of inactieve dienstcode; order-id niet gevonden voor deze klant | `404` | — | Vernieuw de lijst met diensten; controleer de opgeslagen order-id |
| Verplicht veld ontbreekt of is ongeldig | `422` | — | Lees `errors` in de body, corrigeer de velden en verstuur opnieuw |
| Oorsprongstype niet aangeboden door de dienst, of magazijn niet in de lijst van de dienst | `422` | — | Gebruik een `origin_type` en `warehouse_id` uit stap 5 en 6 |
| De dienst kan de zending niet prijzen en weigert ongeprijsde zendingen | `422` | `unpriced_refused` | Er is niets aangemaakt; toon `message` en probeer het niet ongewijzigd opnieuw |
| Bestelde benodigdheden zijn niet op voorraad | `422` | — | Lees `stock_shortages`, verlaag de aantallen en verstuur opnieuw |
| Dezelfde `Idempotency-Key` met een andere body | `409` | `IDEMPOTENCY_CONFLICT` | Gebruik een nieuwe sleutel voor een nieuwe order; hergebruik een sleutel nooit voor andere inhoud |
| Een nieuwe poging terwijl het eerste verzoek met die sleutel nog wordt verwerkt | `409` | `IDEMPOTENCY_IN_PROGRESS` | Wacht `Retry-After` seconden en probeer het daarna opnieuw met dezelfde sleutel en body |
| Betalen zonder voldoende saldo | `422` | — | Waardeer het account op en betaal opnieuw |
| Betaalinformatie of betalen voor een order die volledig is betaald | `400` | — | Behandel de order als betaald; lees het detail |
| Annuleren nadat de order In afwachting of Bevestigd heeft verlaten | `403` | — | Toon dat de order niet meer kan worden geannuleerd; neem contact op met het bedrijf |
| Wijzigen na betaling | `403` | — | Annuleer en maak een nieuwe order aan, of neem contact op met het bedrijf |
| Serverfout bij schatten, aanmaken, betalen of annuleren | `500` | — | Probeer het eenmaal opnieuw; probeer het bij aanmaken opnieuw met dezelfde `Idempotency-Key` |

## Testlijst

Gebruik een testkenmerk zoals `DEV-SHIP-001` of `HLI-PO-1058`:

- [ ] Klantaanmelding geeft `access_token` terug; een verzoek zonder het token geeft `401` terug.
- [ ] De lijst met diensten is niet leeg en u hebt één `service_code` opgeslagen.
- [ ] De configuratie geeft de magazijnen, eenheden en toegestane landen voor die dienst terug, en uw formulier gebruikt deze.
- [ ] De schatting geeft een `total` terug (of `needs_manual_quote: true`), en een geweigerde zending wordt niet aangemaakt.
- [ ] Aanmaken geeft een `id` terug; dezelfde `Idempotency-Key` met dezelfde body geeft dezelfde `id` terug met `"replayed": true`.
- [ ] Betalen slaagt en de status wordt Bevestigd, of u hebt bevestigd dat een onvoldoende saldo `422` teruggeeft en niets in rekening brengt.
- [ ] Het detail toont de order van deze klant met één trackingnummer per pakket, en publieke tracking vindt elk pakket.
- [ ] Webhooks zijn geconfigureerd met een ondertekeningsgeheim; een testaanmaak levert `order.created` op met `ref` `Shipping-Pickup-{id}` en de handtekeningcontrole slaagt.
- [ ] Annuleren van de testorder geeft `status` `4` en het verwachte `refund_amount` terug; een tweede annulering geeft `403` terug.
