# Abholung und Zustellung (eigene Flotte)

Dieser Leitfaden beschreibt die API für lokale Zustellung eines Unternehmenskontos: Aufträge, die die eigenen Fahrer des Unternehmens an einen Empfänger zustellen (`type` `D`) oder bei einem Absender abholen (`type` `P`). Eine Gruppe von Endpunkten fragt Preise an, legt an, erstellt Labels, verfolgt und storniert beide Arten von Halten, und Webhooks melden jede Änderung an Ihr System. Er richtet sich an Entwickler von Auftragsverwaltungssystemen, ERP-Systemen und Onlineshops, die Aufträge an die eigene Flotte des Unternehmens übergeben.

## 1. Was Sie damit bauen können

Die folgenden Beispiele beziehen sich auf ein Unternehmen: **Farine & Fils**, einen Bäckereilieferanten mit einem Depot in 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), der Großhandelsaufträge auf der ganzen Insel Montreal zustellt und die leeren Brotkisten abholt, die seine Kunden zurückgeben. Eine typische Zustellung ist ein Kistenstapel von 12 kg und 60 × 40 × 30 cm für Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), unter dem Großhandelsauftrag `WHS-20931`. Eine typische Abholung ist ein Stapel leerer Kisten von 4 kg bei Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), unter der Referenz `CRT-20931`.

- **Großhandelsaufträge, die das ERP an die Disposition übergibt.** Jeder bestätigte Großhandelsauftrag wird zu einem Zustellauftrag mit dem morgendlichen Lieferzeitfenster des Cafés, und das ERP speichert die zurückgegebene Tracking-Nummer an der Auftragszeile.
- **Abholungen zurückgegebener Kisten.** Meldet ein Kunde leere Kisten, legt das ERP einen Abholauftrag für die Adresse des Kunden an, und ein Fahrer holt die Kisten auf der nächsten Route ab.
- **Labeldruck im Depot.** Das ERP lädt das Label-PDF jedes Auftrags herunter und druckt es an der Laderampe, sodass jeder Kistenstapel seinen Tracking-Barcode trägt.
- **Ein Kundenportal mit aktuellem Status.** Jedes Café sieht den Status seiner Zustellungen und Abholungen mit dem Zustellnachweis, gespeist durch Webhooks statt durch Abfragen.

## 2. Was dieser Leitfaden abdeckt

Verwenden Sie diesen Leitfaden, wenn die eigenen Fahrer des Unternehmens den Auftrag befördern: Zustellungen ab dem Depot und Abholungen an der Adresse eines Kunden, einzeln oder im Stapel angelegt über die Endpunkte `/api/v1/client/...` und `/api/v1/orders/...`.

Für neue Integrationen ist Uniorder (`/api/v1/uniorder/...`) der empfohlene einheitliche Einstiegspunkt: Es bietet dieselben Zustellungen mit eigener Flotte über eine API an, zusammen mit Carrier-Labels, aus einem einzigen Angebot. Die Übersicht finden Sie unter **Uniorder: eine API für jede Sendung**, die einzelnen Anfragen unter **Angebot und Auftrag in einem Ablauf**. Die Endpunkte dieses Leitfadens bleiben für darauf aufbauende Integrationen verfügbar und unverändert.

Verwenden Sie **Versandetiketten**, wenn ein Paket von einem externen Carrier mit einem über die Plattform gekauften Label befördert wird. Verwenden Sie **Versandservice** für Aufträge, die ein Kundenkonto für die Services eines Unternehmens bucht, und **Lagerung und Versand** für Waren, die in einem Lager aufbewahrt und auf Anforderung versendet werden; Uniorder gilt für diese beiden nicht.

## 3. Bevor Sie beginnen

- **Konto.** Verwenden Sie ein Unternehmenskonto (Kundenkonto) oder ein Mitarbeiterkonto des Unternehmens mit API-Berechtigung. Das Anlegen von Aufträgen erfordert zusätzlich die Berechtigung zur Auftragserteilung; ohne sie liefert `POST /api/v1/client/orderCreate` den Wert `401`.
- **Servicegebiet.** Die Zustell- oder Abholadresse muss in einer aktiven Region des Unternehmens liegen. Verwenden Sie für Tests Adressen innerhalb des Gebiets, wie die in diesem Leitfaden.
- **Testdaten.** Verwenden Sie Testreferenzen wie `WHS-20931` und `CRT-20931`, und stornieren Sie die Testaufträge am Ende (Schritt 12).
- **Tokens.** Fordern Sie das Zugriffstoken von Ihrem Server an und bewahren Sie es dort auf. Senden Sie es niemals an einen Browser oder eine mobile App.
- **Platzhalter.** Ersetzen Sie `YOUR_HOST` durch den API-Host Ihrer Umgebung und `ACCESS_TOKEN` durch das Token aus Schritt 4.
- **Einheiten.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Beide sind standardmäßig `1`.

## 4. Anmelden

Jeder Aufruf in diesem Leitfaden, mit Ausnahme des öffentlichen Trackings, erfolgt im Namen des Unternehmenskontos. Melden Sie sich einmal von Ihrem Server aus an, speichern Sie das zurückgegebene Token und senden Sie es bei jeder Anfrage mit.

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

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

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
}
```

- `access_token`: Setzen Sie es in den Header jeder weiteren Anfrage:

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

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

## 5. Preis für eine Zustellung oder Abholung anfragen (optional)

Eine Preisauskunft zeigt den Preis eines Halts, bevor der Auftrag existiert, zum Beispiel um die Zustellgebühr auf einer Großhandelsrechnung auszuweisen. Sie legt nichts an, und das Anlegen eines Auftrags setzt keine vorherige Preisauskunft voraus. Setzen Sie `type` auf `D` (Zustellung) oder `P` (Abholung); `to_postcode` ist die Postleitzahl des Halts.

**REST:** `POST /api/v1/orders/rate` — [REST-Handbuch](/api/documentation#/paths/v1-orders-rate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H4R2N6",
    "from_country": "CA",
    "to_postcode": "H2V4G7",
    "to_country": "CA",
    "packages": [{
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "18.50",
    "tax_details": [
      { "tax_name": "GST", "tax_rate": "5.00", "tax": "0.93" },
      { "tax_name": "QST", "tax_rate": "9.975", "tax": "1.85" }
    ]
  }
}
```

- `shipping_price`: der Preis des Halts vor Steuern. Ein leerer Preis bedeutet, dass die Postleitzahl nicht in einer aktiven Region liegt oder die Preistabelle keine Zeile dafür enthält.
- `price_details.tax_details`: die Steuern, die für den Auftrag anfallen; weisen Sie sie in der Rechnungszeile aus.
- `currency`: die Währung aller Beträge in der Antwort.

Um die Abholung der Kisten anzufragen, senden Sie dieselbe Anfrage mit `"type": "P"`, `"to_postcode": "H4G1V5"` sowie Gewicht und Maßen des Kistenstapels.

**GraphQL:** `ordersRate` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersRate)). Das Ergebnis ist ein JSON-Skalar und hat kein Selection-Set.

```graphql
mutation {
  ordersRate(
    type: "P"
    from_postcode: "H4R2N6"
    from_country: "CA"
    to_postcode: "H4G1V5"
    to_country: "CA"
    packages: [{ weight: 4, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Überprüfung:** `result` ist `true` und `shipping_price` ist sowohl für `type` `D` als auch für `type` `P` eine Zahl. Das Anlegen eines Auftrags hängt nicht von diesem Schritt ab.

## 6. Zustellauftrag anlegen

Jeder bestätigte Großhandelsauftrag wird zu einem Zustellauftrag. Das ERP speichert die zurückgegebene `id` und `tracking_number` an seiner Auftragszeile; jeder spätere Aufruf verwendet einen dieser Werte.

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

Senden Sie einen `Idempotency-Key`-Header, eindeutig pro Großhandelsauftrag, damit eine Wiederholung nach einer Zeitüberschreitung keinen zweiten Auftrag anlegen kann.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whs-20931-delivery" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "WHS-20931",
    "name": "Marie Tremblay",
    "company_name": "Café Lumière",
    "telephone": "5145550142",
    "email": "commandes@cafelumiere.example",
    "address_1": "5400 Avenue du Parc",
    "city": "Montréal",
    "province": "QC",
    "postcode": "H2V4G7",
    "country": "Canada",
    "schedule_date": "2026-10-02",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "packages": 1,
    "packagesDetail": [{
      "ref": "WHS-20931-1",
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Deliver to the back door on Rue Saint-Viateur"
  }'
```

```json
{
  "result": true,
  "id": 12345,
  "ref": "WHS-20931",
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": { "shipping_fee": "18.50" },
  "tracking_number": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012", "external_tracking_number": "" }
  ]
}
```

| Feld | Bedeutung |
|---|---|
| `type` | `D` Zustellung oder `P` Abholung |
| `need_pick_up` | `0` — die Ware ist bereits im Depot. `1` — ein Fahrer muss das Paket abholen |
| `ref` | Externe Referenz für Suche und Abgleich |
| `name` / Adresse | Zustellung: Empfänger. Abholung: Abholhalt |
| `schedule_date`, `time_window_start`, `time_window_end` | Zustelldatum (`Y-m-d`) und das Zeitfenster, in dem der Halt bedient werden muss (`Y-m-d H:i:s`) |
| `packagesDetail` | Ein Eintrag pro Paket; `ref` kennzeichnet das Paket in Ihrem System |
| `auto_deduplication` | `1` lehnt ein zweites Paket mit derselben Paket-`ref` ab |

In der Antwort:

- `id`: die Auftrags-ID; speichern Sie sie für die Auftragsdetails und den Stornierungsaufruf.
- `tracking_number`: eine Tracking-Nummer pro Paket; drucken und verfolgen Sie damit.
- `warning`: vorhanden, wenn der Auftrag mit einem Hinweis angelegt wurde, zum Beispiel bei einer Adresse außerhalb des Zustellgebiets, die das Unternehmen behält oder zurückhält. Ein behaltener Auftrag außerhalb des Gebiets kann `shipping_price: null` liefern.

**GraphQL:** `clientOrderCreate` ([GraphQL-Handbuch](/api/graphql/documentation#/client/clientOrderCreate)). Das Ergebnis ist ein JSON-Skalar mit demselben Inhalt wie die REST-Antwort.

```graphql
mutation {
  clientOrderCreate(
    type: "D"
    need_pick_up: 0
    ref: "WHS-20931"
    name: "Marie Tremblay"
    company_name: "Café Lumière"
    telephone: "5145550142"
    address_1: "5400 Avenue du Parc"
    city: "Montréal"
    province: "QC"
    postcode: "H2V4G7"
    country: "Canada"
    packages: 1
    packagesDetail: [{ ref: "WHS-20931-1", weight: 12, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**Überprüfung:** Senden Sie denselben Body mit demselben `Idempotency-Key` erneut. Die Antwort enthält dieselbe `id`, und es wird kein zweiter Auftrag angelegt.

## 7. Abholauftrag anlegen

Ein Abholauftrag schickt einen Fahrer, um Ware an einer Adresse abzuholen; hier die leeren Kisten bei Épicerie Wellington. Er verwendet denselben Endpunkt wie eine Zustellung: Die Adresse ist der Abholhalt, `type` ist `P` und `need_pick_up` ist `1`.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crt-20931-pickup" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "CRT-20931",
    "name": "Luc Gagnon",
    "company_name": "Épicerie Wellington",
    "telephone": "5145550187",
    "email": "luc@epiceriewellington.example",
    "address_1": "4100 Rue Wellington",
    "city": "Verdun",
    "province": "QC",
    "postcode": "H4G1V5",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "CRT-20931-1",
      "weight": 4,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Empty crates are stacked at the loading door"
  }'
```

```json
{
  "result": true,
  "id": 12346,
  "ref": "CRT-20931",
  "shipping_price": "12.00",
  "currency": "CAD",
  "tracking_number": ["SR123456789029"],
  "packages": [
    { "id": 67891, "ref": "CRT-20931-1", "tracking_number": "SR123456789029", "external_tracking_number": "" }
  ]
}
```

- `id` und `tracking_number`: Speichern Sie sie wie bei einer Zustellung zur Kistenrückgabe.
- `pickup_instruction`: wird dem Fahrer am Abholhalt angezeigt; `delivery_instruction` ist das Gegenstück bei einer Zustellung.

**Überprüfung:** Die Auftragsdetails (Schritt 8) zeigen für diesen Auftrag `type` `P` und `need_pickup` `1`.

## 8. Auftrag abrufen

Die Auftragsdetails bestätigen, was gespeichert wurde, und liefern den aktuellen Status; über den Listenendpunkt gleicht das ERP seine eigenen Datensätze mit der Plattform ab.

**REST:** `GET /api/v1/orders/{orderId}` — [REST-Handbuch](/api/documentation#/paths/v1-orders-orderId/get)

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

```json
{
  "business_name": "Farine & Fils",
  "order": {
    "id": 12345,
    "ref": "WHS-20931",
    "type": "D",
    "need_pickup": 0,
    "orders_status_id": 2,
    "name": "Marie Tremblay",
    "address_1": "5400 Avenue du Parc",
    "postcode": "H2V4G7",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "shipping_price": "18.50"
  },
  "tracking_numbers": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012" }
  ]
}
```

- `order.orders_status_id`: der Auftragsstatus; `2` ist Neu, `12` ist Storniert.
- `order.type` und `order.need_pickup`: bestätigen, dass der Halt als Zustellung oder als Abholung gespeichert wurde.
- `tracking_numbers`: die Tracking-Nummern der Pakete des Auftrags.

**REST:** `GET /api/v1/orders/list` — [REST-Handbuch](/api/documentation#/paths/v1-orders-list/get)

```bash
curl "https://YOUR_HOST/api/v1/orders/list?page=1&per_page=50" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

Die Liste liefert alle Aufträge des Kontos, die neuesten zuerst, jeweils mit ihren Paketen und Artikelpositionen. Senden Sie `page` und `per_page` gemeinsam, um zu paginieren (`per_page` höchstens 1000); ohne diese Parameter werden die neuesten 1000 Aufträge mit einem `truncated`-Kennzeichen geliefert.

**GraphQL:** `orders` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/orders)) für einen Auftrag und `ordersList` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersList)) für die Liste. Beide liefern einen JSON-Skalar.

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

**Überprüfung:** Der Auftrag gehört zum authentifizierten Konto, `ref` stimmt mit dem beim Anlegen gesendeten Wert überein, und `tracking_numbers` stimmt mit der Antwort beim Anlegen überein.

## 9. Lokales Label drucken

Das Label trägt den Tracking-Barcode, den der Fahrer im Depot und am Halt scannt. Drucken Sie ein Label pro Paket und bringen Sie es am Kistenstapel an.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [REST-Handbuch](/api/documentation#/paths/v1-shipping-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

```json
"JVBERi0xLjcKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwo..."
```

- `type`: wie `id` gelesen wird: `TRACKING_NUMBER` (Standard), `ORDER_ID` oder `REF`.
- `base64`: `0` (Standard) liefert das PDF als Datenstrom. `1` macht den gesamten Antwort-Body zu einem JSON-String auf oberster Ebene, der das Base64-PDF enthält, und nicht zu einem Objekt mit einem Feld `pdf_data`. Rufen Sie stattdessen `POST /api/v2/shipping/getShippingLabel` — [REST-Handbuch](/api/documentation#/paths/v2-shipping-getShippingLabel/post) auf, um das Label in einem regulären JSON-Objekt zu erhalten.
- `packages`: optional; die Anzahl der zu druckenden Labels. Ein Wert, der von der Paketanzahl des Auftrags abweicht, aktualisiert den Auftrag.
- `hide_sender_address` / `hide_receiver_address`: `1` lässt diese Adresse auf dem Label leer.

**GraphQL:** `shippingGetShippingLabel` ([GraphQL-Handbuch](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([GraphQL-Handbuch](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) gibt immer JSON (`pdf_data`) zurück.

**Überprüfung:** Das dekodierte PDF lässt sich öffnen. Das Zustelllabel zeigt die Adresse von Café Lumière; das Abhollabel zeigt die Adresse von Épicerie Wellington. Eine ausgeblendete Adresse ist auf dem Label leer.

## 10. Auftrag verfolgen

Das öffentliche Tracking liefert die Ereigniszeitleiste eines Pakets. Es benötigt kein Zugriffstoken, sodass ein Kundenportal sie direkt anzeigen kann; der Nachweis der Zustellung oder Abholung wird mitgeliefert.

**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,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

Dieselbe URL akzeptiert Ihre `ref`, wenn sie als externe Nummer gespeichert wurde.

Verzweigen Sie anhand von `tracking_event_status_id`, nicht anhand von `description`; dieser Text richtet sich nach `Accept-Language`.

| `tracking_event_status_id` | Seite | Bedeutung |
|---|---|---|
| `100` | beide | Auftrag eingegangen |
| `300` / `301` | Zustellung | Im Lager |
| `450` | Zustellung | In Zustellung |
| `500` | Zustellung | Zugestellt |
| `501` | Zustellung | Zustellung fehlgeschlagen, neue Planung erforderlich |
| `460` | Abholung | Unterwegs zur Abholung |
| `510` | Abholung | Abgeholt |
| `512` | Abholung | Abholung fehlgeschlagen, später erneut versuchen |
| `513` | Abholung | Problem bei der Abholung |

- `data`: die neuesten zuerst; die erste Zeile ist der aktuelle Stand.
- `deliveried`: `true` nach `500`.
- `proofs[]`: kann bei `500` oder `510` `type` `1` (Unterschrift) oder `2` (Foto) mit `file_id` und `signed_url` enthalten. Ein nach diesem Ereignis hochgeladenes Foto ist in diesen Daten nicht enthalten; abonnieren Sie `pod.files_updated` (Schritt 11).

**GraphQL:** `trackingPublic` ([GraphQL-Handbuch](/api/graphql/documentation#/tracking/trackingPublic)). Das Ergebnis ist typisiert und benötigt ein Selection-Set.

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

**Überprüfung:** Direkt nach dem Anlegen ist das neueste Ereignis `100` und `deliveried` ist `false`. Eine unbekannte Nummer liefert `result: false` mit `404`; zeigen Sie einen Nicht-gefunden-Zustand an und erzeugen Sie keine Tracking-Ereignisse.

## 11. Webhooks empfangen

Webhooks übertragen jede Änderung an Ihren Server, sodass das ERP und das Kundenportal ohne Abfragen aktuell bleiben. Registrieren Sie die Callback-URLs, die dieser Ablauf benötigt:

| Einstellung | Ereignis | Verwendung |
|---|---|---|
| `order_create_webhook_url` | `order.created` | `id` und `tracking_number` speichern |
| `order_status_change_webhook_url` | `order.status_change` | Status für den Kunden |
| `tracking_event_webhook_url` | `tracking.event` | Zeitleiste der Abholung oder Zustellung |
| `pod_files_webhook_url` | `pod.files_updated` | Foto oder Unterschrift nach Abholung oder Zustellung |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Eine von Ihnen gesendete Stornierung wurde abgelehnt |
| `order_create_async_postback_url` | `order.create_async` | Ergebnis eines asynchronen Stapels (Schritt 13) |

**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 '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "order_status_change_webhook_url",
    "tracking_event_webhook_url",
    "pod_files_webhook_url",
    "order_cancel_failed_webhook_url",
    "webhook_sign_secret"
  ],
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_verify_ssl": 1
  }
}
```

- `changed_keys`: die Einstellungen, die dieser Aufruf geändert hat.
- `settings.webhook_sign_secret`: wird maskiert zurückgegeben; bewahren Sie den vollständigen Wert nur auf Ihrem Server auf.

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

Prüfen Sie auf der Empfängerseite die **v2**-Signatur über den Roh-Body: `HMAC_SHA256(timestamp + "." + raw_body, secret)` wird mit `X-Webhook-Signature-V2` verglichen, wobei der Zeitstempel `X-Webhook-Timestamp` ist. 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:** Legen Sie einen Testauftrag an und empfangen Sie `order.created` mit derselben `id` und `tracking_number`. Der Empfänger lehnt eine ungültige Signatur mit `401` ab, und eine zweite Übermittlung derselben `X-Webhook-Event-Id` wird nicht zweimal verarbeitet.

## 12. Auftrag stornieren

Stornieren Sie einen Auftrag, wenn der Großhandelsauftrag zurückgezogen wird oder die Kistenabholung nicht mehr benötigt wird. Der Aufruf ist idempotent: Die Stornierung eines bereits stornierten Auftrags ist erneut erfolgreich.

**REST:** `POST /api/v1/orders/cancel` — [REST-Handbuch](/api/documentation#/paths/v1-orders-cancel/post) — senden Sie genau einen der Werte `order_id`, `tracking_number`, `external_tracking_number`.

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

```json
{
  "result": true,
  "id": 12345,
  "message": "Order 12345 has been cancelled successful.",
  "already_cancelled": false
}
```

- `result`: `true`, wenn der Auftrag storniert ist.
- `already_cancelled`: `true`, wenn der Auftrag bereits vor diesem Aufruf storniert war; behandeln Sie dies als Erfolg.
- `code`: vorhanden, wenn die Stornierung abgelehnt wird; siehe Schritt 14.

**GraphQL:** `ordersCancel` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersCancel)). Das Ergebnis ist typisiert und benötigt ein Selection-Set.

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

**Überprüfung:** Die Auftragsdetails zeigen `orders_status_id` `12`, und dieselbe Stornierung liefert `already_cancelled: true`. Wird eine Stornierung abgelehnt, wird `order.cancel_failed` an `order_cancel_failed_webhook_url` gesendet.

## 13. Aufträge im Stapel anlegen (optional)

Das ERP kann die Großhandelsaufträge und Kistenabholungen des Tages in einer Anfrage senden. Jede Zeile verwendet dieselben Felder wie in den Schritten 6 und 7 und kann `type` `D` oder `P` sein.

**REST:** `POST /api/v1/client/batchOrderCreate` — [REST-Handbuch](/api/documentation#/paths/v1-client-batchOrderCreate/post) — antwortet, sobald jede Zeile verarbeitet wurde.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/batchOrderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-10-01" \
  -d '{
    "per_order_transaction": 1,
    "orders": [
      {
        "type": "D",
        "need_pick_up": 0,
        "ref": "WHS-20932",
        "name": "Sophie Roy",
        "company_name": "Boulangerie du Marché",
        "telephone": "5145550163",
        "address_1": "7070 Avenue Henri-Julien",
        "city": "Montréal",
        "province": "QC",
        "postcode": "H2S3S3",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "WHS-20932-1", "weight": 10, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      },
      {
        "type": "P",
        "need_pick_up": 1,
        "ref": "CRT-20932",
        "name": "Luc Gagnon",
        "company_name": "Épicerie Wellington",
        "telephone": "5145550187",
        "address_1": "4100 Rue Wellington",
        "city": "Verdun",
        "province": "QC",
        "postcode": "H4G1V5",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "CRT-20932-1", "weight": 4, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      }
    ]
  }'
```

```json
[
  { "result": true, "id": 12347, "ref": "WHS-20932", "tracking_number": ["SR123456789036"], "packages": [{ "id": 67892, "ref": "WHS-20932-1", "tracking_number": "SR123456789036", "external_tracking_number": "" }] },
  { "result": true, "id": 12348, "ref": "CRT-20932", "tracking_number": ["SR123456789043"], "packages": [{ "id": 67893, "ref": "CRT-20932-1", "tracking_number": "SR123456789043", "external_tracking_number": "" }] }
]
```

- Jede Zeile hat ihr eigenes `result`; ordnen Sie es anhand von `ref` Ihrer Auftragszeile zu. Eine abgelehnte Zeile enthält `message` und `skipped_ref` und kann `code` enthalten (zum Beispiel `INSUFFICIENT_BALANCE` oder `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` schreibt jede Zeile einzeln fest, sodass eine fehlgeschlagene Zeile die anderen nicht zurücksetzen kann.
- Stapel mit mehr als 100 Aufträgen erhalten einen `X-Batch-Size-Warning`-Antwortheader; senden Sie diese an den asynchronen Endpunkt.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [REST-Handbuch](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — nimmt denselben Body entgegen und liefert sofort eine Job-Kennung:

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

Fragen Sie `GET /api/v1/client/async/{id}` — [REST-Handbuch](/api/documentation#/paths/v1-client-async-id/get) — mit der `asyncId` ab, oder empfangen Sie `order.create_async` unter `order_create_async_postback_url`. Das Job-Ergebnis ist dieselbe zeilenweise Liste wie beim synchronen Endpunkt.

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

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

**Überprüfung:** Ein Stapel mit zwei Zeilen liefert zwei Ergebnisse, jeweils mit ihrer `ref`. Der asynchrone Job liefert dieselben Zeilen, sobald er ausgeführt wurde.

## 14. Fehlerbehandlung

| Situation | HTTP-Status | Code | Vorgehen der Integration |
|---|---|---|---|
| Ein Pflichtfeld fehlt oder ist fehlerhaft (Anlegen) | 400 | `VALIDATION_FAILED` | Das in `message` genannte Feld korrigieren und die Anfrage erneut senden. |
| Das Kontoguthaben deckt den Auftrag nicht | 400 | `INSUFFICIENT_BALANCE` | `insufficient_balance` lesen (erforderlich, verfügbar, Fehlbetrag); aufladen und erneut versuchen. Es wurde kein Auftrag angelegt. |
| Die Adresse liegt außerhalb des Servicegebiets, und das Unternehmen löscht solche Aufträge | 400 | `OUT_OF_DELIVERY_AREA` | Eine Adresse innerhalb des Servicegebiets übermitteln. Es wurde kein Auftrag angelegt. |
| Eine Paket-`ref` oder externe Tracking-Nummer existiert bereits (bei aktivierter Deduplizierung) | 200 (`result` `false`) oder 409 mit `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | `exist_package_ref` lesen und den bestehenden Auftrag verknüpfen, statt einen neuen anzulegen. |
| Ein `Idempotency-Key` wird mit einem anderen Body wiederverwendet | 409 | `IDEMPOTENCY_CONFLICT` | Für eine andere Anfrage einen neuen Schlüssel verwenden. |
| Eine Anfrage mit demselben `Idempotency-Key` wird noch ausgeführt | 409 | `IDEMPOTENCY_IN_PROGRESS` | Warten und dann mit demselben Schlüssel erneut versuchen. |
| Stornierung ohne Auftragskennung | 400 | `MISSING_IDENTIFIER` | Einen der Werte `order_id`, `tracking_number`, `external_tracking_number` senden. |
| Stornierung eines nicht existierenden Auftrags | 400 | `ORDER_NOT_FOUND` | Die gespeicherte `id` oder Tracking-Nummer prüfen. |
| Die Nummer passt zu mehr als einem aktiven Auftrag | 409 | `MULTIPLE_ORDERS_MATCHED` | Über `order_id` stornieren, mit einem der Werte aus `matched_order_ids`. |
| Der Auftrag gehört zu einem anderen Konto | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Mit dem Konto stornieren, das den Auftrag angelegt hat. |
| Der Status des Auftrags lässt keine Stornierung mehr zu | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Den Auftrag unverändert lassen; die Rücksendung gesondert bearbeiten. |
| Der Auftrag liegt bei einem Drittanbieter-Carrier, der ihn nicht stornieren kann | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` oder `THIRD_PARTY_CANCEL_FAILED` | Der Auftrag ist unverändert; wenden Sie sich an das Unternehmen. |
| Das Token fehlt oder ist abgelaufen, oder das Konto darf keine Aufträge erteilen | 401 | — | Erneut anmelden; die Berechtigungen des Kontos prüfen. |

## Testliste

Verwenden Sie Testreferenzen wie `WHS-20931` und `CRT-20931`:

- [ ] (Optional) Die Preisauskunft liefert für eine Postleitzahl im Gebiet mit `type` `D` einen Preis.
- [ ] (Optional) Die Preisauskunft liefert für eine Postleitzahl im Gebiet mit `type` `P` einen Preis.
- [ ] Das Anlegen einer Zustellung liefert `id` + `tracking_number`; derselbe `Idempotency-Key` legt keinen zweiten Auftrag an.
- [ ] Das Anlegen einer Abholung liefert `id` + `tracking_number`; die Auftragsdetails zeigen `type` `P` und `need_pickup` `1`.
- [ ] Auftragsdetails und Liste zeigen beide Aufträge unter diesem Konto.
- [ ] Das lokale Label-PDF lässt sich öffnen und zeigt den Empfänger oder die Abholadresse.
- [ ] Das öffentliche Tracking liefert die Zeitleiste ohne Token; das neueste Ereignis ist `100`.
- [ ] `order.created` geht ein, und seine v2-Signatur wird bestätigt.
- [ ] Die Stornierung liefert `result: true`, und eine zweite Stornierung liefert `already_cancelled: true`.
- [ ] Ein Stapel aus einer Zustellung und einer Abholung liefert zwei Ergebnisse, jeweils mit ihrer `ref`.
