# Opslag en uitslag

Met de API voor opslag en uitslag meldt een klantaccount van een magazijnbedrijf goederen aan voor opslag, betaalt het de opslagperiode en verzendt het later opgeslagen pakketten naar de eigen kopers. De API is bedoeld voor handelaren en platforms die voorraad aanhouden in een magazijn van een derde partij (3PL) en de opslagboeking, het voorraadoverzicht en de uitgaande zendingen vanuit hun eigen systemen willen automatiseren. Alle aanroepen worden uitgevoerd als het klantaccount, nooit als het magazijnbedrijf.

## 1. Wat u kunt bouwen

De voorbeelden in dit playbook volgen één scenario. **Northwind Outdoor**, een seizoensgebonden online verkoper van winteruitrusting, slaat de wintervoorraad van 1 november 2026 tot en met 31 maart 2027 op in de **Toronto Hub** (magazijn `7`) van de 3PL. Wanneer een koper een doos geïsoleerde jassen bestelt, verzendt Northwind die doos vanuit de voorraad naar de koper in Ottawa.

- **Seizoensgebonden opslagboeking.** De backoffice van de verkoper offreert en boekt een opslagperiode voor elke inkomende doos voordat de goederen de leverancier verlaten, en betaalt de opslagkosten uit het saldo van het account.
- **Actueel voorraadoverzicht.** De winkel of het ERP van de verkoper toont de pakketten die het magazijn daadwerkelijk heeft ontvangen en die nog beschikbaar zijn voor verzending, zodat alleen werkelijke voorraad voor orderafhandeling wordt aangeboden.
- **Orderafhandeling vanuit voorraad.** Wanneer een koper een order plaatst, prijst het systeem van de verkoper de uitgaande zending, maakt het een uitslagverzoek voor de opgeslagen pakketten aan, betaalt het dit en legt het trackingnummer voor de koper vast.
- **Statusopvolging en correctie.** Het systeem van de verkoper leest de status van elke opslagorder en uitslag, volgt de zending via publieke tracking en annuleert een uitslag die niet meer nodig is, zolang dat nog is toegestaan.

## 2. Wat dit playbook behandelt

Gebruik dit playbook wanneer de goederen al in het magazijn van het bedrijf zijn opgeslagen, of daar zullen worden opgeslagen, en de zending vanuit die voorraad vertrekt. De stroom is: aanmelden → de opslagconfiguratie lezen → opslag offreren → de opslagorder aanmaken → betalen → pakketten in voorraad ophalen → diensten ophalen en de uitslag schatten → de uitslag aanmaken → betalen → lezen en volgen → webhooks → annuleren.

Andere playbooks passen bij andere gevallen:

- **Uniorder: één API voor elke zending** — het aanbevolen enkele toegangspunt (`/api/v1/uniorder/...`) voor nieuwe integraties die lokale bezorging of vervoerderslabels boeken. Uniorder omvat opslag en uitslag **niet**; opslagorders en uitslagen worden uitsluitend aangemaakt via de klantendpoints in dit playbook.
- **Verzenddiensten** — een klant verzendt goederen die niet in opslag liggen, met de verzenddiensten van het bedrijf.
- **Vervoerderslabels** — een bedrijf koopt rechtstreeks vervoerderslabels voor de eigen pakketten.
- **Afhaling en bezorging (eigen vloot)** — een bedrijf boekt afhalingen en bezorgingen met de eigen vloot.

## 3. Voordat u begint

- **Accounttype.** Een **klantaccount** van het magazijnbedrijf (het bedrijf dat het magazijn exploiteert, is de dienstverlener). Een token van een bedrijfsaccount (klantaccount) werkt niet op endpoints `/api/v1/customer/...`.
- **Rechten.** Het klantaccount heeft API-toegang nodig. Opslagendpoints vereisen daarnaast de opslagfunctie; uitslagendpoints vereisen dat het bedrijf uitslag (of consolidatie) voor deze klant heeft ingeschakeld, anders antwoorden ze met `403`.
- **Saldo.** Betalingen voor opslag en uitslag worden afgeschreven van het saldo van het klantaccount. Vraag het bedrijf voor een test het saldo van de testklant te crediteren.
- **Testgegevens.** Een magazijn-`id`, ten minste één verpakkings-`id` als eigen verpakkingen niet zijn toegestaan, en ten minste één actieve verzenddienst die vanuit dat magazijn beschikbaar is. Uitslag werkt pas nadat het magazijn de opgeslagen pakketten heeft **ontvangen**; vraag bij een test het magazijnpersoneel de testopslagorder te ontvangen.
- **Omgang met tokens.** Meld u aan vanaf uw server, bewaar het token op de server en plaats het nooit in browser- of mobiele code.
- **Plaatshouders.** Vervang `YOUR_HOST` door de host van uw platform en `ACCESS_TOKEN` door het token uit stap 4.

## 4. Aanmelden als klant

Elke latere aanroep wordt geautoriseerd met een bearer-token van de klant. Uw integratie meldt zich eenmaal aan, bewaart het token aan de serverkant en vernieuwt het vóór `expires_at`.

**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" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

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

- `access_token` — stuur dit bij elk verzoek mee als de onderstaande header.
- `expires_at` / `expires_timestamp` — meld u vóór dit tijdstip opnieuw aan.

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

## 5. De opslagconfiguratie lezen

De configuratiebundel bevat de magazijnen die de klant mag gebruiken, de verpakkingscatalogus, de eenheden en de toeslagen. Uw integratie leest deze eenmaal per sessie om het magazijn te kiezen en geldige pakketregels op te bouwen.

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

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

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

- `warehouses[].id` — de `warehouse_id` voor elke latere aanroep.
- `allow_custom_package` — bij `false` moet elk opslagartikel een `packaging_id` uit `packagings[]` bevatten; bij `true` mogen artikelen alleen met afmetingen worden beschreven.
- `dimension_units` / `weight_units` — de gehele-getalcodes die in pakketregels worden gebruikt (`2` = cm, `2` = kg).
- `form_bindings` — formulieren die het bedrijf voor een opslagorder vereist; stuur de antwoorden mee als `form_data` in stap 7.

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

```graphql
query {
  customerStorageOrderConfig
}
```

**Verificatie:** u hebt een magazijn-`id` vastgelegd en, als de catalogus niet leeg is, een verpakkings-`id`.

## 6. De opslagperiode offreren

De offerte prijst de opslagperiode voor de geplande pakketten voordat er iets wordt geboekt. Uw integratie toont of controleert deze prijs en maakt daarna de order aan met dezelfde invoer.

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

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

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

- `success` — `true` wanneer een prijs is berekend.
- `price.total_price` / `price.currency` — de opslagprijs inclusief belasting voor de periode.
- `promotion` — alleen aanwezig wanneer een promotie van toepassing is.

**Verificatie:** `success` of `result` is true en u hebt een prijs. Ontbrekende `warehouse_id` / datums geven `400`.

## 7. De opslagorder aanmaken

De opslagorder meldt de inkomende pakketten aan bij het magazijn en legt de opslagperiode vast. Uw integratie bewaart de teruggegeven id; deze is nodig om te betalen, de order te lezen en deze te annuleren.

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

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

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

- `data.id` — de id van de opslagorder. Bewaar deze bij uw inkooporder.
- `data.status` — `pending payment` totdat de order is betaald.
- `Idempotency-Key` — leid deze af van uw eigen stabiele id. Een herhaalde sleutel met dezelfde body geeft het eerste antwoord opnieuw (`replayed: true`); dezelfde sleutel met een andere body wordt geweigerd met `409 IDEMPOTENCY_CONFLICT`.
- Verplichte velden: `warehouse_id`, `start_date`, `end_date` (na `start_date`), en `items[]` met `qty`, `length`, `width`, `height`, `dimension_unit`. Voeg `items[].packaging_id` toe wanneer `allow_custom_package` gelijk is aan `false`.

**Verificatie:** de response bevat `data.id`. Bewaar die id van de opslagorder.

## 8. De opslag betalen

Betaling bevestigt de opslagorder. Uw integratie kan eerst het verschuldigde bedrag lezen en betaalt daarna uit het saldo van de klant.

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

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

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

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

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

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

- `payment_type` — `full_balance` (standaard, betaalt het resterende bedrag), `minimum_payment` (betaalt het minimum dat het bedrijf vereist), of `custom` samen met `custom_amount`.
- `is_fully_paid` — `true` wanneer er niets meer te betalen is.
- Bij onvoldoende saldo is het antwoord `400` met `customer_balance`; waardeer het saldo op en probeer het opnieuw.

Lees de order om de status te bevestigen en later te zien welke pakketten het magazijn heeft ontvangen.

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

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

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

- `status` — `confirmed` na betaling; later `partial received` / `storage in progress` naarmate goederen binnenkomen.
- `packages[].received` — `true` zodra het magazijn dat pakket heeft ontvangen.
- `can_cancel` — of de opslagorder nog kan worden geannuleerd.

**GraphQL:** `customerStorageOrderShow` ([GraphQL-handboek](/api/graphql/documentation#/customer/customerStorageOrderShow)); de lijst van alle opslagorders is `customerStorageOrders` ([GraphQL-handboek](/api/graphql/documentation#/customer/customerStorageOrders)).

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

**Verificatie:** de opslagorder is betaald / bevestigd. Een `400` met `customer_balance` betekent dat u het saldo moet opwaarderen en het daarna opnieuw moet proberen.

De onderstaande uitslag werkt pas nadat pakketten in het magazijn zijn **ontvangen**. Wacht bij een test totdat het personeel (of een testontvangst) ze als ontvangen heeft gemarkeerd en ga daarna verder.

## 9. Artikelen ophalen die nog in voorraad zijn

Deze lijst is de voorraad die uw integratie mag verzenden. Deze bevat alleen pakketten die het magazijn heeft ontvangen en die nog niet aan een andere uitslag zijn gekoppeld.

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

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

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

- `storage_orders[].packages[].id` — de `storage_package_ids` die in stap 10 worden uitgeslagen.
- `warehouses[].available_count` — het aantal beschikbare pakketten per magazijn.

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

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

**Verificatie:** u hebt een of meer `storage_package_ids` vastgelegd (voorbeeld `5001`). Een lege lijst betekent dat er nog niets is ontvangen — maak dan geen uitslag aan. `403` betekent dat uitslag voor deze klant is uitgeschakeld.

## 10. De uitslag schatten en aanmaken

Een uitslag wordt geprijsd door een verzenddienst van het bedrijf. Uw integratie haalt de diensten op die vanuit het magazijn beschikbaar zijn, schat de prijs voor de bestemming van de koper en maakt daarna de uitslag aan voor de geselecteerde pakketten.

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

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

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

Leg een `service_code` vast.

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

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

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

- `total` / `currency` — de geschatte prijs voor deze bestemming.
- `has_items_needing_quote` — `true` wanneer de dienst handmatig wordt geprijsd; het magazijn stelt de prijs vast nadat de uitslag is aangemaakt, en de betaling wacht daarop.
- `refused` / `refusal_message` — de dienst accepteert deze zending niet omdat hij deze niet kan prijzen.

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

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

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

- `data.id` — de id van de uitslag. Bewaar deze bij de order van de koper.
- `data.status` — `0` = in afwachting (wacht op betaling), `1` = bevestigd, `2` = onderweg, `3` = verzonden, `4` = geannuleerd, `5` = mislukt.
- `storage_package_ids` — deze pakketten zijn nu aan deze uitslag gekoppeld en verschijnen niet meer in stap 9.
- Verplichte velden: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Alle pakketten moeten uit hetzelfde magazijn komen.

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

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

**Verificatie:** de response bevat een uitslag-`id`. De geselecteerde opslagpakketten zijn aan dit verzoek gekoppeld.

## 11. De uitslag betalen

Het magazijn verwerkt een uitslag zodra deze is betaald. Uw integratie betaalt het resterende bedrag uit het account van de klant; laat `amount` weg om volledig te betalen.

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

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

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

- `amount` (verzoek, optioneel) — een gedeeltelijk bedrag; standaard het volledige resterende bedrag.
- `order_status` — `1` (bevestigd) na volledige betaling.
- `remaining_balance` — `0` wanneer volledig betaald.

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

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

**Verificatie:** de betaling registreert een bedrag (of `402` / `422` met een duidelijke reden). `402` betekent dat het saldo onvoldoende is; `422` betekent dat de order nog niet te betalen is (bijvoorbeeld omdat deze nog op een handmatige offerte wacht) of dat het bedrag ongeldig is.

## 12. De uitslag lezen en volgen

Uw integratie leest de uitslag om de status te volgen en volgt de zending, zodra het magazijn deze heeft verzonden, via het trackingnummer.

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

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

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

- `status` / `status_label` — de huidige status van de uitslag.
- `can_be_paid` / `can_be_cancelled` — of stap 11 of stap 14 op dit moment is toegestaan.

Wanneer er een trackingnummer bestaat:

**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,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` — trackingevents in chronologische volgorde.
- `deliveried` — `true` zodra de zending is bezorgd.

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

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

**Verificatie:** het lezen van de uitslag geeft de verwachte `status` terug. Publieke tracking vindt de zending zodra er een nummer bestaat.

## 13. Abonneren op webhooks

Webhooks sturen tracking- en statuswijzigingen naar uw server in plaats van polling. Een klantaccount stelt zelf de webhook-URL's en het ondertekeningsgeheim in; de instellingen worden op het klantaccount opgeslagen, niet op het bedrijf.

**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 '{
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

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

- Alleen de ingediende sleutels worden gewijzigd; een onbekende sleutel of ongeldige URL geeft `400`.
- `recipient_type` — `customer` bevestigt dat de instellingen bij het klantaccount horen.
- `webhook_sign_secret` — 16 tot 255 tekens; bewaar dit op uw server om handtekeningen te verifiëren.

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

Verifieer **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` tegen `X-Webhook-Signature-V2`. Dedupliceer op `X-Webhook-Event-Id`. Antwoord met **2xx binnen 3 seconden**.

```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:** de update geeft `changed_keys` met de ingediende sleutels terug, en een testevent dat op uw URL wordt ontvangen, doorstaat de bovenstaande handtekeningcontrole.

## 14. Een uitslag of opslagorder annuleren

Annulering geeft vrij wat was gereserveerd. Het annuleren van een uitslag brengt de pakketten terug in de voorraad; het annuleren van een opslagorder beëindigt een boeking waarvan de goederen nog niet zijn ontvangen.

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

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

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

- `reason` (optioneel) — wordt bij de annulering vastgelegd.
- Een uitslag kan alleen worden geannuleerd zolang deze in afwachting (`0`) of bevestigd (`1`) is.

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

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

Dit heft de koppeling van de opslagpakketten op. De opslag zelf wordt geannuleerd met `POST /api/v1/customer/storage-orders/{id}/cancel` zolang dat nog is toegestaan (status `pending payment`, `confirmed`, `waiting for pickup` of `awaiting dropoff`; [REST-handboek](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)).

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

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

- Het al betaalde bedrag voor de opslagorder wordt teruggeboekt naar het saldo van de klant.
- Een opslagorder met een andere status geeft `403`.

**Verificatie:** `422` betekent dat deze status niet kan worden geannuleerd. Na een geslaagde annulering van een uitslag toont stap 9 de pakketten opnieuw.

## 15. Fouten afhandelen

| Situatie | HTTP-status | Code | Wat de integratie doet |
|---|---|---|---|
| Token ontbreekt of is verlopen, of verkeerd accounttype | 401 | — | Meld u opnieuw aan als de klant (stap 4). |
| Opslagofferte zonder `warehouse_id` of datums | 400 | — | Stuur `warehouse_id`, `start_date` en `end_date` mee. |
| Validatie van de opslagorder mislukt (ontbrekende afmetingen van artikelen, `end_date` niet na `start_date`, ontbrekende `packaging_id`) | 422 | — | Lees `errors`, corrigeer de velden en verstuur opnieuw. |
| Opslagbetaling met onvoldoende saldo, of order al volledig betaald | 400 | — | Waardeer het saldo op (het antwoord bevat `customer_balance`), of stop als al is betaald. |
| Opslagbetaling met `custom_amount` buiten het toegestane bereik | 422 | — | Betaal een bedrag tussen het minimum en het resterende bedrag. |
| Opslagorder kan in de huidige status niet worden geannuleerd | 403 | — | Vraag het magazijn de order af te handelen; probeer het niet opnieuw. |
| Uitslag uitgeschakeld voor deze klant | 403 | — | Vraag het bedrijf uitslag voor het klantaccount in te schakelen. |
| Dienstcode onbekend, of uitslag / opslagorder niet gevonden | 404 | — | Lees de lijst met diensten opnieuw of controleer de opgeslagen id. |
| Pakket niet beschikbaar, pakketten uit verschillende magazijnen, of dienst niet aangeboden vanuit het magazijn | 422 | — | Lees stap 9 opnieuw en selecteer beschikbare pakketten uit één magazijn. |
| Verzenddienst kan de zending niet prijzen en weigert deze | 422 | `unpriced_refused` | Kies een andere dienst of bestemming; er is niets aangemaakt. |
| Uitslagbetaling met onvoldoende saldo | 402 | — | Waardeer het saldo op en voer stap 11 opnieuw uit. |
| Uitslag nog niet te betalen (wacht op een handmatige offerte) of ongeldig bedrag | 422 | — | Wacht op de prijs, lees de uitslag opnieuw en betaal daarna. |
| Uitslag kan in de huidige status niet worden geannuleerd | 422 | — | De zending is al onderweg; probeer het niet opnieuw. |
| Dezelfde `Idempotency-Key` verzonden met een andere body | 409 | `IDEMPOTENCY_CONFLICT` | Gebruik een nieuwe sleutel voor een ander verzoek. |
| Oorspronkelijk verzoek met dezelfde `Idempotency-Key` wordt nog verwerkt | 409 | `IDEMPOTENCY_IN_PROGRESS` | Wacht `Retry-After` seconden en verstuur hetzelfde verzoek opnieuw. |

## Testlijst

- [ ] De opslagconfiguratie geeft een magazijn-`id` terug.
- [ ] De opslagofferte geeft een prijs terug, en het aanmaken van de opslag geeft `data.id` terug.
- [ ] Het betalen van de opslag slaagt, **of** u hebt bevestigd dat het saldo moet worden opgewaardeerd.
- [ ] Beschikbare artikelen toont ontvangen pakketten (`storage_package_ids`).
- [ ] De uitslagschatting geeft een prijs of `has_items_needing_quote` terug, en het aanmaken van de uitslag geeft een `id` terug en koppelt die pakketten.
- [ ] Het betalen van de uitslag slaagt (of `402` / `422` is begrepen).
- [ ] Publieke tracking vindt de zending zodra er een trackingnummer bestaat.
- [ ] Het annuleren van de uitslag geeft de pakketten vrij, **of** deze status kan niet worden geannuleerd.
- [ ] Een aanmaakverzoek herhalen met dezelfde `Idempotency-Key` en body geeft `replayed: true` terug en geen tweede order.
- [ ] De webhookinstellingen geven `recipient_type: customer` terug, en een ontvangen event doorstaat de v2-handtekeningcontrole.
