# Versandservice

Mit der API für Versandservices bucht ein Kundenkonto die Versandservices, die sein Logistikdienstleister konfiguriert und ihm zugewiesen hat. Das eigene System des Kunden listet die Services, die es nutzen darf, lädt die Regeln eines Service, ermittelt den Preis einer Sendung, legt den Versandauftrag an, bezahlt ihn aus dem Kontoguthaben und verfolgt die Sendung bis zur Zustellung. Dieser Leitfaden richtet sich an Entwickler, die das System eines Importeurs, Händlers oder Großhändlers mit dem Logistikdienstleister verbinden, der ihn betreut.

## 1. Was Sie damit bauen können

Alle Beispiele in diesem Leitfaden verwenden ein Szenario. **Harbourline Imports Inc.**, ein Teeimporteur in Toronto, hat ein Kundenkonto bei seinem Logistikdienstleister. Der Dienstleister bietet den Service `intl_express` (International Express) ab seinem Lager Toronto Hub (Lager-ID `7`) an. Harbourline liefert zwei Kartons mit Teeproben im Toronto Hub für einen Vertriebspartner in Seattle ein, unter seiner Bestellung `HLI-PO-1058`.

- **Buchung aus dem Bestellsystem.** Wird eine Bestellung freigegeben, ermittelt das System von Harbourline den Preis der Sendung für `intl_express`, legt den Versandauftrag mit der Bestellnummer als Referenz an und bezahlt ihn aus dem vorausbezahlten Kontoguthaben, ohne dass jemand das Portal des Dienstleisters öffnet.
- **Eine Preisprüfung vor der Festlegung.** Der Einkäufer bei Harbourline sieht Fracht, Zuschläge, Steuern und Gesamtbetrag für die beiden Kartons, bevor die Sendung gebucht wird, und eine Sendung, die der Service nicht bepreisen kann, wird gestoppt, bevor ein Auftrag existiert.
- **Sendungsstatus im ERP.** Die Tracking-Nummer jedes Kartons wird zur Bestellung gespeichert; Webhooks übertragen Auftragsstatus und Tracking-Zeitleiste in das ERP, und ein nächtlicher Job gleicht mit der Auftragsliste ab.
- **Kontrollierte Änderungen.** Eine unbezahlte Buchung wird direkt korrigiert, und eine nicht mehr benötigte Buchung wird storniert, wobei der bezahlte Betrag dem Kontoguthaben gutgeschrieben wird.

## 2. Was dieser Leitfaden abdeckt

Verwenden Sie diese Gruppe, wenn der Aufrufer ein **Kunde** des Logistikunternehmens ist und einen der eigenen Versandservices des Unternehmens bucht: Das Unternehmen legt Tarifplan, Lager, Zuschläge und Verpackung fest und weist die Services dem Kunden zu. Der Kunde sieht und bucht nur die ihm zugewiesenen Services.

Verwenden Sie in diesen Fällen eine andere Gruppe:

- Der Aufrufer ist das Logistikunternehmen selbst (ein Unternehmens- bzw. Kundenkonto) und bucht Abholungen und Zustellungen am selben Tag oder im Nahbereich mit der eigenen Flotte: Lesen Sie **Abholung und Zustellung (eigene Flotte)**.
- Der Aufrufer kauft Carrier-Labels (zum Beispiel UPS oder FedEx) zu den ausgehandelten Tarifen des Kontos: Lesen Sie **Versandetiketten**.
- Der Kunde lagert Waren im Lager des Dienstleisters und versendet sie aus dem Bestand: Lesen Sie **Lagerung und Versand**.

**Uniorder: eine API für jede Sendung** (`/api/v1/uniorder/...`) ist der empfohlene einheitliche Einstiegspunkt für neue Integrationen der lokalen Zustellung und der Carrier-Labels. Uniorder umfasst keine Versandservices: Versandservice-Aufträge werden ausschließlich über die hier beschriebenen Endpunkte `/api/v1/customer/shipping-orders/...` angelegt und verwaltet.

## 3. Bevor Sie beginnen

- **Kontotyp.** Ein **Kundenkonto** des Logistikunternehmens, für das das Unternehmen die **API-Berechtigung** aktiviert hat. Ein Unternehmens-, Kunden- oder Mitarbeiterkonto des Unternehmens kann sich nicht über die unten beschriebene Kundenanmeldung anmelden.
- **Service-Zuweisung.** Das Unternehmen muss dem Kunden mindestens einen aktiven Versandservice zuweisen. Ein Kunde ohne zugewiesenen Service erhält eine leere Service-Liste.
- **Testdaten.** Vereinbaren Sie mit dem Unternehmen einen Test-Servicecode, ein Testlager und ein kleines vorausbezahltes Guthaben auf dem Testkonto. Verwenden Sie eine Referenz wie `HLI-PO-1058` oder `DEV-SHIP-001`, damit Testaufträge leicht zu finden und zu stornieren sind.
- **Umgang mit Tokens.** Rufen Sie die API nur von Ihrem Server aus auf. Halten Sie Passwort und Zugriffstoken von Browsern und mobilen Clients fern. Das Token läuft eine Woche nach der Anmeldung ab (`expires_at`); melden Sie sich vor Ablauf erneut an.
- **Platzhalter.** Ersetzen Sie `YOUR_HOST` durch den Hostnamen des Logistikunternehmens und `ACCESS_TOKEN` durch das beim Anmeldeschritt zurückgegebene Token.
- **JSON-Fehler.** Senden Sie bei jeder Anfrage `Accept: application/json`, damit Validierungsfehler als JSON statt als Weiterleitung zurückgegeben werden.

## 4. Als Kunde anmelden

Die Anmeldung tauscht E-Mail-Adresse und Passwort des Kunden gegen ein Bearer-Token. Jeder weitere Aufruf in diesem Leitfaden sendet dieses Token.

**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" \
  -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`: Senden Sie es bei jeder Anfrage als `Authorization: Bearer ACCESS_TOKEN`. GraphQL verwendet denselben Header auf `POST /api/graphql`.
- `expires_at` / `expires_timestamp`: Planen Sie vor diesem Zeitpunkt eine neue Anmeldung ein.

**Überprüfung:** Die Antwort enthält `result: true` und ein `access_token`. Eine Anfrage ohne das Token liefert `401`; eine Anmeldung mit einem Konto, das kein Kundenkonto ist oder keine API-Berechtigung hat, liefert ebenfalls `401`.

## 5. Dem Kunden zugewiesene Services listen

Die Service-Liste zeigt der Integration, welche Servicecodes sie buchen darf und ob jeder Service eine Einlieferung im Lager, eine Abholung oder beides akzeptiert. Speichern Sie den `service_code`; jeder weitere Service-Aufruf verwendet ihn.

**REST:** `GET /api/v1/customer/shipping-orders/services` — [REST-Handbuch](/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`: der Pfadparameter jedes weiteren Service-Aufrufs.
- `offer_pickup` / `allow_warehouse_delivery`: die zulässigen Werte von `origin_type` (`pickup` / `warehouse`).
- `support_multi_package`: ob ein Auftrag mehr als eine Paketzeile enthalten darf.
- Ein leeres `services`-Array bedeutet, dass diesem Kunden kein Service zugewiesen ist.

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

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

**Überprüfung:** Die Liste enthält mindestens einen Service, und Sie haben dessen `service_code` gespeichert (in diesem Leitfaden: `intl_express`).

## 6. Service-Konfiguration laden

Die Konfiguration liefert alles, was das Auftragsformular eines Service benötigt: die Lager, die Einlieferungen annehmen, die wählbaren Zuschläge, den Katalog für Verpackung und Verbrauchsmaterial, die Einheiten sowie die Länder, aus denen der Service abholen und in die er zustellen kann. Prüfen Sie Ihre Auftragsdaten dagegen, bevor Sie einen Preis ermitteln oder etwas anlegen.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [REST-Handbuch](/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`: die `warehouse_id`, die zu senden ist, wenn `origin_type` `warehouse` ist. Eine ID, die nicht in dieser Liste steht, wird beim Anlegen abgelehnt.
- `service.weight_mode`: welche Paketfelder der Tarifplan verlangt: `0` tatsächliches Gewicht (Gewicht), `1` Volumengewicht (Länge, Breite und Höhe), `2` abrechenbares Gewicht (beides). `null` bedeutet, dass der Service manuell bepreist wird. Senden Sie Gewicht und alle drei Maße, um jeden Modus zu erfüllen.
- `delivery_allowed_countries` / `pickup_allowed_countries`: Lehnen Sie ein Ziel- oder Abholland außerhalb dieser Listen ab, bevor Sie die Preisermittlung aufrufen.
- `surcharges[].id`, `packagings[].id`, `products[].id`: die IDs für optionale Zuschläge, Verpackung und den Kauf von Verbrauchsmaterial.
- `weight_units` / `dimension_units`: Paketeinheiten werden als Zahlen gesendet. Senden Sie `weight_unit: 2` (kg) und `dimension_unit: 2` (cm), wie es alle Beispiele in diesem Leitfaden tun; beide sind auch die Standardwerte, wenn die Felder fehlen.

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

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

**Überprüfung:** `result` ist `true`, und bei einer Einlieferung im Lager enthält `warehouses` das Lager, das Sie verwenden möchten. `403` bedeutet, dass der Service diesem Kunden nicht zugewiesen ist; `404` bedeutet, dass der Servicecode nicht existiert oder inaktiv ist.

## 7. Preis ermitteln

Die Preisermittlung bepreist die Sendung nach dem Tarifplan des Service, ohne etwas zu speichern. Zeigen Sie dem Einkäufer den Gesamtbetrag an, und legen Sie den Auftrag nicht an, wenn die Preisermittlung eine Ablehnung meldet.

**REST:** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/estimate-price` — [REST-Handbuch](/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` (der Kunde liefert die Ware in einem Lager ein; senden Sie `warehouse_id`) oder `pickup` (der Dienstleister holt ab; senden Sie `pickup_postcode` und `pickup_country`). Verwenden Sie nur einen Wert, den Schritt 5 zulässt.
- `packages`: eine Zeile pro Gruppe gleicher Pakete; `quantity` vervielfacht die Zeile.
- `total` und `currency`: der anzuzeigende Betrag. `total` ist `null`, solange eine Gebühr nicht berechnet ist.
- `needs_manual_quote` / `has_items_needing_quote`: Das Unternehmen bepreist den Auftrag manuell; der Auftrag kann angelegt werden und wird bezahlt, nachdem das Unternehmen den Preis festgelegt hat.
- `refused` / `refusal_message`: Der Service lehnt Sendungen ab, die er nicht bepreisen kann. Legen Sie den Auftrag nicht an; zeigen Sie stattdessen `refusal_message` an.
- Optionale Eingaben: `surcharges`, `products` (eine Zuordnung von Produkt-ID zu Menge, nur berücksichtigt, wenn `allow_purchase_supplies` true ist), `has_special_requirements`, `coupon_code`.

**Überprüfung:** `result` ist `true`, `refused` ist `false`, und entweder hat `total` einen Wert oder `needs_manual_quote` ist `true`.

## 8. Versandauftrag anlegen

Der Anlage-Aufruf bucht die Sendung auf dem Service. Die Integration speichert die zurückgegebene `id` zu ihrer eigenen Bestellung; jeder spätere Aufruf verwendet diese ID.

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

Senden Sie einen `Idempotency-Key`, der aus Ihrer eigenen stabilen ID abgeleitet ist (hier die Bestellnummer). Eine Wiederholung mit demselben Schlüssel und demselben Body liefert die erste Antwort mit `"replayed": true` und dem Header `Idempotency-Replayed: true` und legt keinen zweiten Auftrag an.

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

- Der Body-Schlüssel für Pakete lautet beim Anlegen `package` (bei der Preisermittlung `packages`). Jede Zeile mit `quantity` N ergibt N Pakete, und jedes Paket erhält eine eigene Tracking-Nummer.
- Pflichtfelder: `delivery_name`, `delivery_telephone`, `delivery_address_1`, `delivery_city`, `delivery_province`, `delivery_country`, `delivery_postcode`, `origin_type`, `package[].weight`; dazu `warehouse_id` für `warehouse` oder `pickup_name`, `pickup_telephone`, `pickup_address_1`, `pickup_city`, `pickup_province`, `pickup_country`, `pickup_postcode` für `pickup`.
- Optionale Felder: `reference` (gespeichert als `reference_number` des Auftrags), `delivery_email`, `delivery_address_2`, `scheduled_date`, `time_window`, `note`, `special_requirements` (ein Array von Textzeilen, nur berücksichtigt, wenn der Service sie zulässt), `products`, `surcharges`, `coupon_code`.
- `id`: Speichern Sie sie. `status` `0` ist Ausstehend (Zahlung ausstehend).
- `total_price`: der Betrag, den Schritt 9 belastet. Er ist `0`, solange der Auftrag auf ein manuelles Angebot wartet.
- `tracking_number` auf Auftragsebene ist `null`; die Tracking-Nummern stehen an den Paketen und werden in Schritt 10 gelesen.
- Der Endpunkt antwortet bei einem neuen Auftrag mit HTTP `201`.

**Überprüfung:** Die Antwort enthält `result: true` und eine `id`. Die Wiederholung derselben Anfrage mit demselben `Idempotency-Key` liefert dieselbe `id` mit `"replayed": true`.

## 9. Auftrag aus dem Kontoguthaben bezahlen

Versandaufträge werden vollständig aus dem Kontoguthaben des Kunden bezahlt. Ein bezahlter Auftrag wechselt von Ausstehend zu Bestätigt, und der Dienstleister beginnt mit der Bearbeitung.

Lesen Sie zuerst den Betrag:

**REST:** `GET /api/v1/customer/shipping-orders/{id}/payment-info` — [REST-Handbuch](/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 }
    ]
  }
}
```

Bezahlen Sie dann:

**REST:** `POST /api/v1/customer/shipping-orders/{id}/pay` — [REST-Handbuch](/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`: Deckt das Guthaben `remaining_balance` nicht, laden Sie das Konto vor dem Bezahlen auf.
- `payment_type`: Nur `remaining_balance` wird unterstützt; es wird immer der gesamte Restbetrag belastet.
- `data.status` `1` ist Bestätigt.

**Überprüfung:** Der Zahlungsaufruf liefert `result: true` und `status` `1`, und ein zweiter `payment-info`-Aufruf liefert `400`, weil der Auftrag vollständig bezahlt ist. Ein Zahlungsaufruf ohne ausreichendes Guthaben liefert `422` und belastet nichts.

## 10. Auftrag abrufen und Pakete verfolgen

Der Detailaufruf liefert den aktuellen Status und die Tracking-Nummer jedes Pakets. Speichern Sie die Paket-Tracking-Nummern zur Bestellung; das öffentliche Tracking akzeptiert jede davon.

**REST:** `GET /api/v1/customer/shipping-orders/{id}` — [REST-Handbuch](/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` Ausstehend, `1` Bestätigt, `2` Unterwegs, `3` Versendet, `4` Storniert, `5` Fehlgeschlagen, `6` Teilweise abgeholt, `7` Abgeholt, `8` In Bearbeitung.
- `can_edit` / `can_cancel`: ob Schritt 12 derzeit zulässig ist.
- `packages[].tracking_number`: die zu speichernden und zu verfolgenden Nummern.
- `shipping_code`: der Code, den die Einlieferungsbildschirme im Lager akzeptieren; drucken Sie ihn auf die Einlieferungspapiere.

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

Um alle Aufträge eines Service abzugleichen, zum Beispiel in einem nächtlichen Job, listen Sie sie mit einem Filter. Der Filter `id` passt auf die Auftrags-ID, eine Tracking-Nummer oder die Referenz.

**REST:** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/orders` — [REST-Handbuch](/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-Handbuch](/api/graphql/documentation#/customer/customerShippingOrders))

Das öffentliche Tracking benötigt kein Token und liefert die Ereigniszeitleiste eines Pakets:

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

**Überprüfung:** Die Details liefern den Auftrag dieses Kunden mit einer Tracking-Nummer pro Paket, und das öffentliche Tracking liefert `result: true` für eine Paket-Tracking-Nummer. Die Auftrags-ID eines anderen Kunden liefert `404`.

## 11. Webhooks empfangen

Webhooks übermitteln Auftragsanlage, Statusänderungen und Tracking-Ereignisse an Ihren Server, sodass die Integration keine Abfragen durchführen muss. Das Kundenkonto konfiguriert seine eigenen Webhook-URLs und sein Signatur-Secret.

Beim Anlegen eines Versandauftrags legt der Dienstleister zusätzlich einen verknüpften Abholauftrag für sein Dispositionsteam an. Die Webhooks werden für diesen verknüpften Auftrag gesendet: Seine `ref` ist `Shipping-Pickup-{shipping order id}` (zum Beispiel `Shipping-Pickup-9001`), und jedes seiner Pakete trägt die Tracking-Nummer des Versandpakets in `external_tracking_number`. Ordnen Sie eingehende Ereignisse anhand dieser beiden Felder zu.

| Einstellung | Ereignis | Vorgehen der Integration |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Das Ereignis über `ref` und `packages[].external_tracking_number` mit dem Versandauftrag verknüpfen |
| `tracking_event_webhook_url` | `tracking.event` | Das Ereignis an die Zeitleiste des Pakets anhängen |
| `order_status_change_webhook_url` | `order.status_change` | Den in Ihrem System angezeigten Status aktualisieren |

**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" \
  -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"
  }
}
```

- Nur die übermittelten Schlüssel werden geändert; ein leerer String löscht eine URL. `webhook_sign_secret` muss 16 bis 255 Zeichen lang sein, und solange das Secret leer ist, wird kein Webhook gesendet.
- `recipient_type` ist bei einem Kundenkonto `customer`.

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

Prüfen Sie die **v2**-Signatur über den Roh-Body: `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** und verarbeiten Sie das Ereignis danach.

```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:** Nach dem Einstellungsaufruf erzeugt eine Testanlage ein `order.created`-Ereignis, dessen `ref` für die neue Versandauftrags-ID `Shipping-Pickup-{id}` lautet, und die Signaturprüfung ist erfolgreich.

## 12. Auftrag ändern oder stornieren

Ein Auftrag kann korrigiert werden, solange er Ausstehend ist (vor der Zahlung), und storniert werden, solange er Ausstehend oder Bestätigt ist. Die Stornierung eines bezahlten Auftrags schreibt den bezahlten Betrag dem Kontoguthaben gut.

Senden Sie zum Ändern den vollständigen Auftrag erneut mit denselben Feldern wie in Schritt 8. Der Preis wird neu berechnet.

**REST:** `PUT /api/v1/customer/shipping-orders/{id}` — [REST-Handbuch](/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
  }
}
```

Zum Stornieren:

**REST:** `POST /api/v1/customer/shipping-orders/{id}/cancel` — [REST-Handbuch](/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` ist Storniert. Der verknüpfte Abholauftrag wird entfernt.
- `refund_amount`: der dem Kontoguthaben gutgeschriebene Betrag; `0` bei einem unbezahlten Auftrag.
- Lesen Sie `can_edit` und `can_cancel` aus Schritt 10, bevor Sie Benutzern diese Aktionen anbieten.

**Überprüfung:** Die Stornierung liefert `status` `4`, und die Details zeigen `status_name` `Cancelled`. Eine zweite Stornierung oder die Stornierung eines Auftrags, der Unterwegs oder weiter fortgeschritten ist, liefert `403` mit der Meldung `This order can no longer be cancelled.`; eine Änderung eines bezahlten Auftrags liefert `403`.

## 13. Fehlerbehandlung

| Situation | HTTP-Status | Code | Vorgehen der Integration |
|---|---|---|---|
| Fehlendes, abgelaufenes oder ungültiges Token; Anmeldung mit einem Konto, das kein Kundenkonto ist oder keine API-Berechtigung hat | `401` | — | Erneut anmelden; schlägt die Anmeldung selbst fehl, das Unternehmen bitten, Kontotyp und API-Berechtigung zu prüfen |
| Der Service ist diesem Kunden nicht zugewiesen | `403` | — | Die Service-Liste erneut lesen (Schritt 5) und nur zugewiesene Services buchen |
| Die API wird aus der Sitzung einer Plattform-App aufgerufen, für die Versandaufträge deaktiviert sind | `403` | `APP_CAPABILITY_DISABLED` | Das Unternehmen bitten, Versandaufträge für die App zu aktivieren |
| Unbekannter oder inaktiver Servicecode; Auftrags-ID für diesen Kunden nicht gefunden | `404` | — | Die Service-Liste aktualisieren; die gespeicherte Auftrags-ID prüfen |
| Pflichtfeld fehlt oder ist ungültig | `422` | — | `errors` im Body lesen, die Felder korrigieren und erneut senden |
| Herkunftsart wird vom Service nicht angeboten, oder das Lager steht nicht in der Liste des Service | `422` | — | Einen `origin_type` und eine `warehouse_id` aus den Schritten 5 und 6 verwenden |
| Der Service kann die Sendung nicht bepreisen und lehnt unbepreiste Sendungen ab | `422` | `unpriced_refused` | Es wurde nichts angelegt; `message` anzeigen und nicht unverändert erneut versuchen |
| Bestelltes Verbrauchsmaterial ist nicht vorrätig | `422` | — | `stock_shortages` lesen, die Mengen verringern und erneut senden |
| Derselbe `Idempotency-Key` mit einem anderen Body | `409` | `IDEMPOTENCY_CONFLICT` | Für einen neuen Auftrag einen neuen Schlüssel verwenden; einen Schlüssel nie für andere Inhalte wiederverwenden |
| Eine Wiederholung, während die erste Anfrage mit diesem Schlüssel noch verarbeitet wird | `409` | `IDEMPOTENCY_IN_PROGRESS` | `Retry-After` Sekunden warten und dann mit demselben Schlüssel und Body erneut versuchen |
| Zahlung ohne ausreichendes Guthaben | `422` | — | Das Konto aufladen und dann erneut bezahlen |
| Zahlungsinformationen oder Zahlung für einen vollständig bezahlten Auftrag | `400` | — | Den Auftrag als bezahlt behandeln; die Details lesen |
| Stornierung, nachdem der Auftrag Ausstehend oder Bestätigt verlassen hat | `403` | — | Anzeigen, dass der Auftrag nicht mehr storniert werden kann; sich an das Unternehmen wenden |
| Änderung nach der Zahlung | `403` | — | Stornieren und einen neuen Auftrag anlegen oder sich an das Unternehmen wenden |
| Serverfehler bei Preisermittlung, Anlage, Zahlung oder Stornierung | `500` | — | Einmal erneut versuchen; beim Anlegen mit demselben `Idempotency-Key` erneut versuchen |

## Testliste

Verwenden Sie eine Testreferenz wie `DEV-SHIP-001` oder `HLI-PO-1058`:

- [ ] Die Kundenanmeldung liefert `access_token`; eine Anfrage ohne das Token liefert `401`.
- [ ] Die Service-Liste ist nicht leer, und Sie haben einen `service_code` gespeichert.
- [ ] Die Konfiguration liefert Lager, Einheiten und zulässige Länder für diesen Service, und Ihr Formular verwendet sie.
- [ ] Die Preisermittlung liefert einen `total` (oder `needs_manual_quote: true`), und eine abgelehnte Sendung wird nicht angelegt.
- [ ] Das Anlegen liefert eine `id`; derselbe `Idempotency-Key` mit demselben Body liefert dieselbe `id` mit `"replayed": true`.
- [ ] Die Zahlung ist erfolgreich und der Status wird Bestätigt, oder Sie haben bestätigt, dass ein unzureichendes Guthaben `422` liefert und nichts belastet.
- [ ] Die Details zeigen den Auftrag dieses Kunden mit einer Tracking-Nummer pro Paket, und das öffentliche Tracking findet jedes Paket.
- [ ] Webhooks sind mit einem Signatur-Secret konfiguriert; eine Testanlage erzeugt `order.created` mit `ref` `Shipping-Pickup-{id}`, und die Signaturprüfung ist erfolgreich.
- [ ] Die Stornierung des Testauftrags liefert `status` `4` und den erwarteten `refund_amount`; eine zweite Stornierung liefert `403`.
