# Abholung und Zustellung

Abholung und letzte Meile mit eigener Flotte nutzen dieselben APIs. Abhol- oder Zustellauftrag anlegen → lokales Label drucken → tracken → Ereignisbenachrichtigungen abonnieren → Testauftrag stornieren.

`type` wählt den Halt: `D` Zustellung, `P` Abholung. Führen Sie die Schritte der Reihe nach aus. Eine Preisauskunft ist optional und vor dem Anlegen nicht erforderlich.

Ersetzen Sie `YOUR_HOST` und `ACCESS_TOKEN` durch die Werte Ihrer Umgebung.

## 1. Anmelden

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

Setzen Sie das zurückgegebene `access_token` in den Header der Anfrage:

```
Authorization: Bearer ACCESS_TOKEN
```

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

[REST-Handbuch](/api/documentation#/paths/v1-user-login/post) · [GraphQL-Handbuch](/api/graphql/documentation#/user/userLogin)

**Überprüfung:** Login liefert `access_token`. Ein späterer Aufruf ohne dieses Token liefert `401`.

## 2. Preis anfragen (optional)

Dieser Schritt ist optional. Er liefert nur eine Preisauskunft; es wird kein Auftrag angelegt. Das Anlegen setzt keine vorherige Auskunft voraus. Setzen Sie `type` auf `D` (Zustellung) oder `P` (Abholung).

**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": "H9S2H9",
    "from_country": "CA",
    "to_postcode": "H4B2T5",
    "to_country": "CA",
    "packages": [{
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }]
  }'
```

`weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in.

```json
{
  "result": true,
  "shipping_price": "6.99",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "6.99",
    "tax_details": [{ "tax_name": "HST", "tax_rate": "13.00", "tax": "0.91" }]
  }
}
```

**GraphQL** (JSON-Skalar — kein Selection-Set) (`ordersRate` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersRate))):

```graphql
mutation {
  ordersRate(
    type: "D"
    from_postcode: "H9S2H9"
    from_country: "CA"
    to_postcode: "H4B2T5"
    to_country: "CA"
    packages: [{ weight: 1, weight_unit: 2, length: 30, width: 20, height: 10, dimension_unit: 2 }]
  )
}
```

**Überprüfung:** wenn Sie diesen Aufruf ausführen, ist `result` true und `shipping_price` eine Zahl. Wiederholen Sie mit `type` `P` für eine Abholauskunft. Kein Preis bedeutet, dass die Postleitzahl nicht in einem aktiven Gebiet liegt. Das Anlegen hängt nicht von diesem Schritt ab.

## 3. Auftrag anlegen

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

Die Anfrage muss `Idempotency-Key` enthalten, damit ein Retry keinen zweiten Auftrag anlegen kann.

### Zustellung (`type` `D`)

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-local-001" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "DEV-LOCAL-001",
    "name": "Jane Recipient",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Leave at the side door"
  }'
```

### Abholung (`type` `P`)

Derselbe Endpunkt. Die Adresse ist die Abholstelle.

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-pickup-001" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "DEV-PICKUP-001",
    "name": "Jane Sender",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-P-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Ring the side bell"
  }'
```

| Feld | Bedeutung |
|---|---|
| `type` | `D` Zustellung oder `P` Abholung |
| `need_pick_up` | `0` — bereits im Lager. `1` — ein Fahrer muss das Paket abholen |
| `ref` | Externe Referenz zum Suchen und Abgleichen |
| `name` / address | Zustellung: Empfänger. Abholung: Abholstelle |
| `auto_deduplication` | `1` lehnt ein zweites Paket mit derselben Paket-`ref` ab |

**GraphQL:** `clientOrderCreate` ([GraphQL-Handbuch](/api/graphql/documentation#/client/clientOrderCreate)) (JSON-Skalar).

```json
{ "result": true, "id": 12345, "tracking_number": "SR123456789012", "orders_status_id": 2 }
```

`orders_status_id` `2` ist Neu. Speichern Sie `id`, `tracking_number` und `ref`.

**Überprüfung:** denselben Body mit demselben `Idempotency-Key` noch einmal senden. Sie müssen dieselbe `id` erhalten und dürfen keinen zweiten Auftrag anlegen.

Der Auftrag wird **nicht** angelegt, wenn:

| `code` | Was tun |
|---|---|
| `INSUFFICIENT_BALANCE` | `insufficient_balance` enthält required / available / shortfall. Aufladen, dann erneut versuchen. |
| `OUT_OF_DELIVERY_AREA` | Die Adresse liegt außerhalb des bedienten Gebiets und das Unternehmen löscht solche Aufträge. Eine Adresse innerhalb des bedienten Gebiets übermitteln. |
| `IDEMPOTENCY_CONFLICT` | Derselbe Idempotenzschlüssel wurde mit einem anderen Request-Body wiederverwendet. Einen neuen Schlüssel ausstellen. |

Ein behaltener Auftrag außerhalb des Gebiets kann trotzdem `result: true` mit `shipping_price: null` und einer `warning` zurückgeben. Dieses Feld lesen.

## 4. Auftrag abrufen

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

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

**REST:** `GET /api/v1/orders/list` — alle Aufträge des Kontos, die neuesten zuerst, jeder mit seinen Paketen und Artikelpositionen. Senden Sie `page` und `per_page` gemeinsam, um zu paginieren (`per_page` höchstens 1000); ohne sie erhalten Sie die neuesten 1000 Aufträge und ein `truncated`-Kennzeichen. [REST-Handbuch](/api/documentation#/paths/v1-orders-list/get)

**GraphQL:** `ordersList` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersList))

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

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

**Überprüfung:** der Auftrag gehört zum authentifizierten Konto. `ref` stimmt mit dem angelegten Wert überein. `tracking_number` stimmt mit Schritt 3 überein.

## 5. Lokales Label drucken

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

`type`: `TRACKING_NUMBER`, `ORDER_ID` oder `REF`. `base64: 0` (Standard) streamt ein PDF. Bei `base64: 1` ist der gesamte Antwortkörper eine JSON-Zeichenkette auf oberster Ebene, die das Base64-PDF enthält, und kein Objekt mit einem Feld `pdf_data`. Rufen Sie stattdessen `POST /api/v2/shipping/getShippingLabel` auf, wenn Sie das Etikett in einem gewöhnlichen JSON-Objekt erhalten möchten.

**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 PDF öffnet sich. Ein Zustelllabel zeigt den Empfänger; ein Abhollabel zeigt die Abholadresse. Ein ausgeblendetes Adressfeld erscheint auf dem Label leer.

## 6. Verfolgen

Öffentlicher Tracking-Endpunkt; es ist kein Zugriffstoken erforderlich.

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

[REST-Handbuch](/api/documentation#/operations/getPublicTracking) · [GraphQL-Handbuch](/api/graphql/documentation#/tracking/trackingPublic)

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

**GraphQL** (typisiert — braucht 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 }
  }
}
```

Verzweigen Sie nach `tracking_event_status_id`, nicht nach `description` (dieser Text folgt `Accept-Language`):

| `tracking_event_status_id` | Seite | Bedeutung |
|---|---|---|
| `100` | beide | Auftrag eingegangen |
| `300` / `301` | Zustellung | Im Depot |
| `450` | Zustellung | Unterwegs zur Zustellung |
| `500` | Zustellung | Zugestellt |
| `501` | Zustellung | Zustellung fehlgeschlagen, neuer Plan nötig |
| `460` | Abholung | Unterwegs zur Abholung |
| `510` | Abholung | Abgeholt |
| `512` | Abholung | Abholung fehlgeschlagen, später erneut versuchen |
| `513` | Abholung | Abholproblem |

`data` ist neueste zuerst. Die erste Zeile ist der aktuelle Stand. `deliveried: true` nach `500`.

Bei `500` oder `510` kann `proofs[]` `type` `1` (Unterschrift) oder `2` (Foto) enthalten, plus `file_id` und `signed_url`. Ein Foto, das nach diesem Ereignis hochgeladen wird, steht nicht in diesem Payload — in Schritt 7 `pod.files_updated` abonnieren.

**Überprüfung:** direkt nach dem Anlegen ist das neueste Ereignis `100` und `deliveried` ist false. Eine unbekannte Nummer ist `result: false` / 404 — nicht gefunden anzeigen, keine Tracking-Ereignisse erfinden.

## 7. Ereignisbenachrichtigungen konfigurieren

Konfigurieren Sie die Callback-URLs, die dieser Ablauf erfordert:

| Einstellung | Ereignis | Wann |
|---|---|---|
| `order_create_webhook_url` | `order.created` | `id` und `tracking_number` speichern |
| `order_status_change_webhook_url` | `order.status_change` | Für den Kunden sichtbarer Status |
| `tracking_event_webhook_url` | `tracking.event` | Zeitlinie von Abholung oder Zustellung |
| `pod_files_webhook_url` | `pod.files_updated` | Foto/Unterschrift nach Abholung oder Zustellung |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Eine von Ihnen gesendete Stornierung wurde abgelehnt |

**REST:** `PUT /api/v1/webhook-settings` — [REST-Handbuch](/api/documentation#/paths/v1-webhook-settings/put)

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

```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.example.com/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.example.com/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.example.com/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

Prüfen Sie **v2** über den Rohkörper: `HMAC_SHA256(timestamp + "." + raw_body, secret)` gegen `X-Webhook-Signature-V2`. Deduplizieren Sie über `X-Webhook-Event-Id`. Antworten Sie **2xx in unter 3 Sekunden**.

```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:** einen Testauftrag anlegen und `order.created` mit derselben `id` / `tracking_number` sehen. Eine ungültige Signatur muss der Empfänger mit `401` ablehnen. Eine zweite Übermittlung derselben `X-Webhook-Event-Id` darf nicht zweimal verarbeitet werden.

## 8. Testauftrag stornieren

**REST:** `POST /api/v1/orders/cancel` — [REST-Handbuch](/api/documentation#/paths/v1-orders-cancel/post) — genau eines von `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"}'
```

Bereits storniert: `200` mit `already_cancelled: true`. **GraphQL:** `ordersCancel` ([GraphQL-Handbuch](/api/graphql/documentation#/orders/ordersCancel)).

**Überprüfung:** das öffentliche Tracking behandelt die Sendung nicht mehr als aktiv. Wird die Stornierung abgelehnt (`409`, z. B. `ORDER_ALREADY_IN_DELIVERY`), feuert `order.cancel_failed`.

## 9. Stapel anlegen (optional)

- `POST /api/v1/client/batchOrderCreate` — [REST-Handbuch](/api/documentation#/paths/v1-client-batchOrderCreate/post) — wartet, bis jede Zeile verarbeitet wurde.
- `POST /api/v1/client/batchOrderCreateAsync` — [REST-Handbuch](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — gibt sofort eine Job-Kennung zurück; pollen Sie `GET /api/v1/client/async/{id}` — [REST-Handbuch](/api/documentation#/paths/v1-client-async-id/get) oder nehmen Sie `order.create_async`.

Dieselben Felder wie Schritt 3, als Array von Aufträgen. Jede Zeile darf `type` `D` oder `P` sein. **GraphQL:** `clientBatchOrderCreate` ([GraphQL-Handbuch](/api/graphql/documentation#/client/clientBatchOrderCreate)) / `clientBatchOrderCreateAsync` ([GraphQL-Handbuch](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)).

**Überprüfung:** jede Zeile hat ihr eigenes `result`. Verwenden Sie den asynchronen Endpunkt, wenn Sie mehr als etwa 100 Zeilen übermitteln.

## Testliste

Verwenden Sie eine Test-`ref` wie `DEV-LOCAL-001` / `DEV-PICKUP-001`:

- [ ] (Optional) Die Preisauskunft liefert einen Preis für eine Postleitzahl im Gebiet mit `type` `D`.
- [ ] (Optional) Die Preisauskunft liefert einen Preis für eine Postleitzahl im Gebiet mit `type` `P`.
- [ ] Anlegen einer Zustellung liefert `id` + `tracking_number`; derselbe `Idempotency-Key` legt keinen zweiten Auftrag an.
- [ ] Anlegen einer Abholung liefert `id` + `tracking_number`; `need_pick_up` ist `1`.
- [ ] Liste / Detail zeigt den Auftrag unter diesem Konto.
- [ ] Das lokale Label-PDF öffnet sich und zeigt den Empfänger oder die Abholadresse.
- [ ] Öffentliches Tracking liefert die Zeitlinie ohne Token; neuestes Ereignis ist `100`.
- [ ] `order.created` kommt an; v2-Signatur prüft erfolgreich.
- [ ] Stornieren liefert `result: true` (oder `already_cancelled: true` beim Retry).
