# Lagerung und Versand

Mit der API für Lagerung und Versand lagert das Kundenkonto eines Lagerunternehmens Waren ein, bezahlt den Lagerzeitraum und versendet eingelagerte Pakete später an die eigenen Käufer. Sie ist für Händler und Plattformen bestimmt, die ihren Bestand in einem Drittlager (3PL) halten und Lagerbuchung, Bestandsansicht und ausgehende Sendungen aus ihren eigenen Systemen automatisieren müssen. Alle Aufrufe erfolgen als Kundenkonto, niemals als Lagerunternehmen.

## 1. Was Sie damit bauen können

Die Beispiele in diesem Leitfaden folgen einem Szenario. **Northwind Outdoor**, ein saisonaler Online-Händler für Winterausrüstung, lagert seinen Winterbestand vom 1. November 2026 bis zum 31. März 2027 im **Toronto Hub** (Lager `7`) seines 3PL. Bestellt ein Käufer einen Karton isolierter Jacken, versendet Northwind diesen Karton aus dem Bestand an den Käufer in Ottawa.

- **Saisonale Lagerbuchung.** Das Backoffice des Händlers fragt für jeden eingehenden Karton einen Lagerzeitraum an und bucht ihn, bevor die Ware den Lieferanten verlässt, und bezahlt die Lagergebühr aus seinem Kontoguthaben.
- **Aktuelle Bestandsansicht.** Der Shop oder das ERP des Händlers listet die Pakete, die das Lager tatsächlich erhalten hat und die noch versandbereit sind, sodass nur echter Bestand zur Auftragsabwicklung angeboten wird.
- **Auftragsabwicklung aus dem Bestand.** Bestellt ein Käufer, ermittelt das System des Händlers den Preis der ausgehenden Sendung, legt eine Versandanforderung für die eingelagerten Pakete an, bezahlt sie und erfasst die Tracking-Nummer für den Käufer.
- **Statusverfolgung und Korrektur.** Das System des Händlers liest den Status jedes Lagerauftrags und jedes Versandauftrags, verfolgt die Sendung über das öffentliche Tracking und storniert einen nicht mehr benötigten Versandauftrag, solange dies noch zulässig ist.

## 2. Was dieser Leitfaden abdeckt

Verwenden Sie diesen Leitfaden, wenn die Waren bereits im Lager des Unternehmens eingelagert sind oder dort eingelagert werden und die Sendung von diesem Bestand ausgeht. Der Ablauf ist: anmelden → Lagerkonfiguration lesen → Lagerpreis anfragen → Lagerauftrag anlegen → bezahlen → Pakete im Bestand listen → Services listen und Versandpreis ermitteln → Versandauftrag anlegen → bezahlen → abrufen und verfolgen → Webhooks → stornieren.

Andere Leitfäden passen zu anderen Fällen:

- **Uniorder: eine API für jede Sendung** — der empfohlene einheitliche Einstiegspunkt (`/api/v1/uniorder/...`) für neue Integrationen, die lokale Zustellung oder Carrier-Labels buchen. Uniorder umfasst Lagerung und Versand **nicht**; Lageraufträge und Versandaufträge werden ausschließlich über die Kunden-Endpunkte in diesem Leitfaden angelegt.
- **Versandservice** — ein Kunde versendet Waren, die nicht eingelagert sind, über die Versandservices des Unternehmens.
- **Versandetiketten** — ein Unternehmen kauft Carrier-Labels direkt für seine eigenen Pakete.
- **Abholung und Zustellung (eigene Flotte)** — ein Unternehmen bucht Abholungen und Zustellungen mit seiner eigenen Flotte.

## 3. Bevor Sie beginnen

- **Kontotyp.** Ein **Kundenkonto** des Lagerunternehmens (das Unternehmen, das das Lager betreibt, ist der Dienstleister). Ein Token eines Unternehmenskontos (Kundenkontos) funktioniert nicht an den Endpunkten `/api/v1/customer/...`.
- **Berechtigungen.** Das Kundenkonto benötigt API-Zugriff. Die Lager-Endpunkte erfordern zusätzlich die Lagerfunktion; die Versand-Endpunkte erfordern, dass das Unternehmen den Versand (oder die Konsolidierung) für diesen Kunden aktiviert hat, andernfalls antworten sie mit `403`.
- **Guthaben.** Zahlungen für Lagerung und Versand werden vom Kontoguthaben des Kunden abgebucht. Bitten Sie das Unternehmen für einen Test, dem Guthaben des Testkunden einen Betrag gutzuschreiben.
- **Testdaten.** Eine Lager-`id`, mindestens eine Verpackungs-`id`, falls benutzerdefinierte Pakete nicht zulässig sind, und mindestens ein aktiver Versandservice, der ab diesem Lager verfügbar ist. Der Versand funktioniert erst, nachdem das Lager die eingelagerten Pakete **erhalten** hat; bitten Sie in einem Test das Lagerpersonal, den Testlagerauftrag zu vereinnahmen.
- **Umgang mit Tokens.** Melden Sie sich von Ihrem Server aus an, bewahren Sie das Token auf dem Server auf und legen Sie es niemals in Browser- oder Mobilcode ab.
- **Platzhalter.** Ersetzen Sie `YOUR_HOST` durch Ihren Plattform-Host und `ACCESS_TOKEN` durch das Token aus Schritt 4.

## 4. Als Kunde anmelden

Jeder weitere Aufruf wird mit einem Bearer-Token des Kunden autorisiert. Ihre Integration meldet sich einmal an, speichert das Token serverseitig und erneuert es vor `expires_at`.

**REST:** `POST /api/v1/user/customer/login` — [REST-Handbuch](/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` — Senden Sie es bei jeder Anfrage im unten gezeigten Header.
- `expires_at` / `expires_timestamp` — Melden Sie sich vor diesem Zeitpunkt erneut an.

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL verwendet denselben Header auf `POST /api/graphql`.

**Überprüfung:** Die Anmeldung liefert `access_token`. Nachfolgende Anfragen ohne dieses Token liefern `401`.

## 5. Lagerkonfiguration lesen

Das Konfigurationspaket listet die Lager, die der Kunde nutzen darf, den Verpackungskatalog, die Einheiten und die Zuschläge. Ihre Integration liest es einmal pro Sitzung, um das Lager zu wählen und gültige Paketzeilen zu erstellen.

**REST:** `GET /api/v1/customer/storage-orders/config` — [REST-Handbuch](/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` — die `warehouse_id` für jeden weiteren Aufruf.
- `allow_custom_package` — bei `false` muss jede Lagerposition eine `packaging_id` aus `packagings[]` enthalten; bei `true` können Positionen allein durch Maße beschrieben werden.
- `dimension_units` / `weight_units` — die in Paketzeilen verwendeten ganzzahligen Codes (`2` = cm, `2` = kg).
- `form_bindings` — Formulare, die das Unternehmen für einen Lagerauftrag verlangt; senden Sie die Antworten in Schritt 7 als `form_data`.

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

```graphql
query {
  customerStorageOrderConfig
}
```

**Überprüfung:** Sie haben eine Lager-`id` erfasst und, sofern der Katalog nicht leer ist, eine Verpackungs-`id`.

## 6. Preis für den Lagerzeitraum anfragen

Die Preisanfrage berechnet den Preis des Lagerzeitraums für die geplanten Pakete, bevor etwas gebucht wird. Ihre Integration zeigt diesen Preis an oder prüft ihn und legt dann den Auftrag mit denselben Eingaben an.

**REST:** `POST /api/v1/customer/storage-orders/calculate-price` — [REST-Handbuch](/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`, wenn ein Preis berechnet wurde.
- `price.total_price` / `price.currency` — der Lagerpreis einschließlich Steuern für den Zeitraum.
- `promotion` — nur vorhanden, wenn eine Aktion gilt.

**Überprüfung:** `success` oder `result` ist true, und Sie haben einen Preis. Fehlen `warehouse_id` oder Datumsangaben, lautet die Antwort `400`.

## 7. Lagerauftrag anlegen

Der Lagerauftrag kündigt dem Lager die eingehenden Pakete an und legt den Lagerzeitraum fest. Ihre Integration speichert die zurückgegebene ID; sie wird zum Bezahlen, Abrufen und Stornieren des Auftrags benötigt.

**REST:** `POST /api/v1/customer/storage-orders` — [REST-Handbuch](/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` — die Lagerauftrags-ID. Speichern Sie sie mit Ihrer Bestellung.
- `data.status` — `pending payment`, bis der Auftrag bezahlt ist.
- `Idempotency-Key` — Leiten Sie ihn aus Ihrer eigenen stabilen ID ab. Ein wiederholter Schlüssel mit demselben Body liefert die erste Antwort erneut (`replayed: true`); derselbe Schlüssel mit einem anderen Body wird mit `409 IDEMPOTENCY_CONFLICT` abgelehnt.
- Pflichtfelder: `warehouse_id`, `start_date`, `end_date` (nach `start_date`) und `items[]` mit `qty`, `length`, `width`, `height`, `dimension_unit`. Fügen Sie `items[].packaging_id` hinzu, wenn `allow_custom_package` `false` ist.

**Überprüfung:** Die Antwort enthält `data.id`. Speichern Sie diese Lagerauftrags-ID.

## 8. Lagerung bezahlen

Die Zahlung bestätigt den Lagerauftrag. Ihre Integration kann zuerst den fälligen Betrag lesen und bezahlt dann aus dem Guthaben des Kunden.

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

```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-Handbuch](/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` (Standard, bezahlt den Restbetrag), `minimum_payment` (bezahlt den vom Unternehmen verlangten Mindestbetrag) oder `custom` zusammen mit `custom_amount`.
- `is_fully_paid` — `true`, wenn nichts mehr zu bezahlen ist.
- Bei unzureichendem Guthaben lautet die Antwort `400` mit `customer_balance`; laden Sie das Guthaben auf und versuchen Sie es erneut.

Rufen Sie den Auftrag ab, um seinen Status zu bestätigen und später zu sehen, welche Pakete das Lager erhalten hat.

**REST:** `GET /api/v1/customer/storage-orders/{id}` — [REST-Handbuch](/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` nach der Zahlung; später `partial received` / `storage in progress`, während die Ware eintrifft.
- `packages[].received` — `true`, sobald das Lager dieses Paket erhalten hat.
- `can_cancel` — ob der Lagerauftrag noch storniert werden kann.

**GraphQL:** `customerStorageOrderShow` ([GraphQL-Handbuch](/api/graphql/documentation#/customer/customerStorageOrderShow)); die Liste aller Lageraufträge ist `customerStorageOrders` ([GraphQL-Handbuch](/api/graphql/documentation#/customer/customerStorageOrders)).

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

**Überprüfung:** Der Lagerauftrag ist bezahlt / bestätigt. Ein `400` mit `customer_balance` bedeutet: Guthaben aufladen und erneut versuchen.

Der folgende Versand funktioniert erst, nachdem die Pakete im Lager **vereinnahmt** wurden. Warten Sie in einem Test, bis das Personal (oder eine Testvereinnahmung) sie als erhalten markiert hat, und fahren Sie dann fort.

## 9. Artikel im Bestand listen

Diese Liste ist der Bestand, den Ihre Integration versenden darf. Sie enthält nur Pakete, die das Lager erhalten hat und die nicht bereits für einen anderen Versandauftrag gesperrt sind.

**REST:** `GET /api/v1/customer/shipout-orders/available-items` — [REST-Handbuch](/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` — die `storage_package_ids`, die in Schritt 10 versendet werden.
- `warehouses[].available_count` — die Anzahl der verfügbaren Pakete pro Lager.

**GraphQL:** `customerShipoutAvailableItems` ([GraphQL-Handbuch](/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 } }
    }
  }
}
```

**Überprüfung:** Sie haben eine oder mehrere `storage_package_ids` erfasst (Beispiel `5001`). Eine leere Liste bedeutet, dass noch nichts vereinnahmt wurde — legen Sie keinen Versandauftrag an. `403` bedeutet, dass der Versand für diesen Kunden deaktiviert ist.

## 10. Versandpreis ermitteln und Versandauftrag anlegen

Ein Versandauftrag wird über einen Versandservice des Unternehmens bepreist. Ihre Integration listet die ab dem Lager verfügbaren Services, ermittelt den Preis für das Ziel des Käufers und legt dann den Versandauftrag für die ausgewählten Pakete an.

**REST:** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [REST-Handbuch](/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 }
    ]
  }
}
```

Erfassen Sie einen `service_code`.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/estimate` — [REST-Handbuch](/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` — der ermittelte Preis für dieses Ziel.
- `has_items_needing_quote` — `true`, wenn der Service manuell bepreist wird; das Lager legt den Preis nach dem Anlegen des Versandauftrags fest, und die Zahlung wartet darauf.
- `refused` / `refusal_message` — Der Service nimmt diese Sendung nicht an, weil er sie nicht bepreisen kann.

**REST:** `POST /api/v1/customer/shipout-orders/services/{serviceCode}/orders` — [REST-Handbuch](/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` — die Versandauftrags-ID. Speichern Sie sie mit dem Auftrag des Käufers.
- `data.status` — `0` = ausstehend (Zahlung ausstehend), `1` = bestätigt, `2` = unterwegs, `3` = versendet, `4` = storniert, `5` = fehlgeschlagen.
- `storage_package_ids` — Diese Pakete sind jetzt für diesen Versandauftrag gesperrt und erscheinen nicht mehr in Schritt 9.
- Pflichtfelder: `warehouse_id`, `storage_package_ids`, `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`. Alle Pakete müssen aus demselben Lager stammen.

**GraphQL:** `customerCreateShipoutOrder` ([GraphQL-Handbuch](/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 }
  }
}
```

**Überprüfung:** Die Antwort enthält eine Versandauftrags-`id`. Die ausgewählten Lagerpakete sind für diese Anforderung gesperrt.

## 11. Versandauftrag bezahlen

Das Lager bearbeitet einen Versandauftrag, sobald er bezahlt ist. Ihre Integration bezahlt den Restbetrag aus dem Konto des Kunden; lassen Sie `amount` weg, um den vollen Betrag zu bezahlen.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/pay` — [REST-Handbuch](/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` (Anfrage, optional) — ein Teilbetrag; standardmäßig der gesamte Restbetrag.
- `order_status` — `1` (bestätigt) nach vollständiger Zahlung.
- `remaining_balance` — `0`, wenn vollständig bezahlt.

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

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

**Überprüfung:** Die Zahlung verbucht einen Betrag (oder liefert `402` / `422` mit einem eindeutigen Grund). `402` bedeutet, dass das Guthaben nicht ausreicht; `422` bedeutet, dass der Auftrag noch nicht bezahlbar ist (zum Beispiel, weil er noch auf ein manuelles Angebot wartet) oder der Betrag ungültig ist.

## 12. Versandauftrag abrufen und verfolgen

Ihre Integration ruft den Versandauftrag ab, um seinen Status zu verfolgen, und verfolgt die Sendung anhand ihrer Tracking-Nummer, sobald das Lager sie versendet hat.

**REST:** `GET /api/v1/customer/shipout-orders/{id}` — [REST-Handbuch](/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` — der aktuelle Status des Versandauftrags.
- `can_be_paid` / `can_be_cancelled` — ob Schritt 11 oder Schritt 14 derzeit zulässig ist.

Wenn eine Tracking-Nummer vorhanden ist:

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [REST-Handbuch](/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[]` — Tracking-Ereignisse in chronologischer Reihenfolge.
- `deliveried` — `true`, sobald die Sendung zugestellt ist.

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

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

**Überprüfung:** Der Abruf des Versandauftrags liefert den erwarteten `status`. Das öffentliche Tracking findet die Sendung, sobald eine Nummer vorhanden ist.

## 13. Webhooks abonnieren

Webhooks übertragen Tracking- und Statusänderungen an Ihren Server, statt dass Sie Abfragen durchführen. Ein Kundenkonto legt seine eigenen Webhook-URLs und sein Signatur-Secret fest; die Einstellungen werden im Kundenkonto gespeichert, nicht beim Unternehmen.

**REST:** `PUT /api/v1/webhook-settings` — [REST-Handbuch](/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"
  }
}
```

- Nur die übermittelten Schlüssel werden geändert; ein unbekannter Schlüssel oder eine ungültige URL führt zu `400`.
- `recipient_type` — `customer` bestätigt, dass die Einstellungen zum Kundenkonto gehören.
- `webhook_sign_secret` — 16 bis 255 Zeichen; bewahren Sie es auf Ihrem Server auf, um Signaturen zu prüfen.

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

Prüfen Sie **v2**: `HMAC_SHA256(timestamp + "." + raw_body, secret)` gegen `X-Webhook-Signature-V2`. Deduplizieren Sie anhand von `X-Webhook-Event-Id`. Antworten Sie **innerhalb von 3 Sekunden mit 2xx**.

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

**Überprüfung:** Die Aktualisierung liefert `changed_keys` mit den übermittelten Schlüsseln, und ein an Ihrer URL empfangenes Testereignis besteht die oben beschriebene Signaturprüfung.

## 14. Versandauftrag oder Lagerauftrag stornieren

Eine Stornierung gibt Reserviertes frei. Die Stornierung eines Versandauftrags gibt seine Pakete in den Bestand zurück; die Stornierung eines Lagerauftrags beendet eine Buchung, deren Ware noch nicht eingegangen ist.

**REST:** `POST /api/v1/customer/shipout-orders/{id}/cancel` — [REST-Handbuch](/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` (optional) — wird mit der Stornierung gespeichert.
- Ein Versandauftrag kann nur storniert werden, solange er ausstehend (`0`) oder bestätigt (`1`) ist.

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

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

Dies hebt die Sperre der Lagerpakete auf. Die Lagerung selbst wird mit `POST /api/v1/customer/storage-orders/{id}/cancel` storniert, solange dies noch zulässig ist (Status `pending payment`, `confirmed`, `waiting for pickup` oder `awaiting dropoff`; [REST-Handbuch](/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" } }
```

- Der für den Lagerauftrag bereits bezahlte Betrag wird dem Guthaben des Kunden gutgeschrieben.
- Ein Lagerauftrag in einem anderen Status liefert `403`.

**Überprüfung:** `422` bedeutet, dass dieser Status nicht storniert werden kann. Nach einer erfolgreichen Stornierung des Versandauftrags listet Schritt 9 die Pakete wieder.

## 15. Fehlerbehandlung

| Situation | HTTP-Status | Code | Vorgehen der Integration |
|---|---|---|---|
| Fehlendes oder abgelaufenes Token oder falscher Kontotyp | 401 | — | Erneut als Kunde anmelden (Schritt 4). |
| Lagerpreisanfrage ohne `warehouse_id` oder Datumsangaben | 400 | — | `warehouse_id`, `start_date` und `end_date` senden. |
| Validierung des Lagerauftrags fehlgeschlagen (fehlende Positionsmaße, `end_date` nicht nach `start_date`, fehlende `packaging_id`) | 422 | — | `errors` lesen, die Felder korrigieren und erneut senden. |
| Lagerzahlung bei unzureichendem Guthaben, oder Auftrag bereits vollständig bezahlt | 400 | — | Das Guthaben aufladen (die Antwort enthält `customer_balance`) oder abbrechen, wenn bereits bezahlt. |
| Lagerzahlung mit `custom_amount` außerhalb des zulässigen Bereichs | 422 | — | Einen Betrag zwischen dem Mindestbetrag und dem Restbetrag bezahlen. |
| Lagerauftrag kann in seinem aktuellen Status nicht storniert werden | 403 | — | Das Lager bitten, den Auftrag zu bearbeiten; nicht erneut versuchen. |
| Versand für diesen Kunden deaktiviert | 403 | — | Das Unternehmen bitten, den Versand für das Kundenkonto zu aktivieren. |
| Servicecode unbekannt, oder Versandauftrag / Lagerauftrag nicht gefunden | 404 | — | Die Service-Liste erneut lesen oder die gespeicherte ID prüfen. |
| Paket nicht verfügbar, Pakete aus verschiedenen Lagern, oder Service ab dem Lager nicht angeboten | 422 | — | Schritt 9 erneut lesen und verfügbare Pakete aus einem Lager auswählen. |
| Der Versandservice kann die Sendung nicht bepreisen und lehnt sie ab | 422 | `unpriced_refused` | Einen anderen Service oder ein anderes Ziel wählen; es wurde nichts angelegt. |
| Versandzahlung bei unzureichendem Guthaben | 402 | — | Das Guthaben aufladen und Schritt 11 erneut versuchen. |
| Versandauftrag noch nicht bezahlbar (wartet auf ein manuelles Angebot) oder ungültiger Betrag | 422 | — | Auf den Preis warten, den Versandauftrag erneut abrufen und dann bezahlen. |
| Versandauftrag kann in seinem aktuellen Status nicht storniert werden | 422 | — | Die Sendung ist bereits in Bearbeitung; nicht erneut versuchen. |
| Derselbe `Idempotency-Key` mit einem anderen Body gesendet | 409 | `IDEMPOTENCY_CONFLICT` | Für eine andere Anfrage einen neuen Schlüssel verwenden. |
| Ursprüngliche Anfrage mit demselben `Idempotency-Key` wird noch verarbeitet | 409 | `IDEMPOTENCY_IN_PROGRESS` | `Retry-After` Sekunden warten und dieselbe Anfrage erneut senden. |

## Testliste

- [ ] Die Lagerkonfiguration liefert eine Lager-`id`.
- [ ] Die Lagerpreisanfrage liefert einen Preis, und das Anlegen des Lagerauftrags liefert `data.id`.
- [ ] Die Lagerzahlung ist erfolgreich, **oder** Sie haben bestätigt, dass das Guthaben aufgeladen werden muss.
- [ ] Die verfügbaren Artikel listen vereinnahmte Pakete (`storage_package_ids`).
- [ ] Die Versandpreisermittlung liefert einen Preis oder `has_items_needing_quote`, und das Anlegen des Versandauftrags liefert eine `id` und sperrt diese Pakete.
- [ ] Die Versandzahlung ist erfolgreich (oder `402` / `422` ist verstanden).
- [ ] Das öffentliche Tracking findet die Sendung, sobald eine Tracking-Nummer vorhanden ist.
- [ ] Die Stornierung des Versandauftrags gibt die Pakete frei, **oder** dieser Status kann nicht storniert werden.
- [ ] Die Wiederholung einer Anlage mit demselben `Idempotency-Key` und Body liefert `replayed: true` und keinen zweiten Auftrag.
- [ ] Die Webhook-Einstellungen liefern `recipient_type: customer`, und ein empfangenes Ereignis besteht die v2-Signaturprüfung.
