# Versandetiketten

Der Label-Service kauft Versandlabels bei den Carriern, die mit einem Konto verbunden sind (zum Beispiel UPS und Canada Post), und führt jedes Label als Auftrag. Eine Integration listet die Versandarten des Kontos, fragt einen Preis für ein Paket an, legt den Label-Auftrag an, kauft das Label beim gewählten Carrier-Service, druckt das PDF, verfolgt das Paket und storniert nicht verwendete Labels. Er ist für Onlineshops, Lagerverwaltungssysteme und Auftragsverwaltungssysteme bestimmt, die Pakete über Carrier statt über eigene Fahrer versenden.

## 1. Was Sie damit bauen können

Die Beispiele in diesem Leitfaden beziehen sich auf ein Unternehmen: **Northbound Outfitters**, einen Onlineshop für Outdoor-Ausrüstung, der aus seinem Lager in 1200 Eglinton Ave E, Toronto, versendet. Sein Konto hat eine Versandart für Canada Post und eine für UPS. Ein typischer Auftrag ist ein Zeltkarton mit 4,2 kg und 60 × 30 × 25 cm nach Calgary; Aufträge in die Vereinigten Staaten gehen mit UPS.

- **Carrier-Auswahl im Checkout.** Der Shop fragt für den Warenkorb des Kunden Preise bei Canada Post an, zeigt die Services mit Preis und Laufzeit in Tagen an und versendet mit dem Service, den der Kunde bezahlt hat.
- **Labeldruck mit einem Klick im Lager.** Der Packplatz legt den Label-Auftrag an, sobald ein Karton gepackt ist, kauft das Label beim gewählten Service und druckt das Carrier-PDF auf einem Thermodrucker.
- **Grenzüberschreitende Sendungen mit Zolldaten.** Aufträge in die Vereinigten Staaten enthalten Artikelpositionen (Beschreibung, Menge, Wert, HS-Code), sodass das UPS-Label mit seinen Handelsdaten ausgestellt wird.
- **Automatische Statusmeldungen an den Kunden.** Der Shop speichert die Carrier-Tracking-Nummer, zeigt die öffentliche Tracking-Zeitleiste auf der Auftragsseite an und aktualisiert den Auftrag, wenn ein `tracking.event`-Webhook meldet, dass das Paket zugestellt wurde.

## 2. Was dieser Leitfaden abdeckt

Dieser Leitfaden behandelt den v1-Label-Service (`/api/v1/labelservice/...`): eine Versandart (ein Carrier-Konto) pro Aufruf. Verwenden Sie ihn, wenn die Integration bereits weiß, mit welcher Versandart sie versendet, oder wenn sie eine bestehende Label-Service-Integration pflegt.

Für neue Integrationen ist Uniorder (`/api/v1/uniorder/...`) der empfohlene einheitliche Einstiegspunkt. Die Uniorder-Leitfäden „Uniorder: eine API für jede Sendung“ und „Angebot und Auftrag in einem Ablauf“ fragen alle Carrier-Services des Kontos auf einmal an (zusammen mit der eigenen Zustellung des Unternehmens, sofern zutreffend) und kaufen das Label beim gewählten Service, indem dessen `rate_id` zurückgesendet wird. Dieselben Aufrufe drucken, verfolgen und stornieren anschließend jeden Auftrag.

Andere Leitfäden behandeln die übrigen Sendungsarten:

- Zustellung durch die eigenen Fahrer des Unternehmens: „Abholung und Zustellung (eigene Flotte)“.
- Ein Kundenkonto, das über die Services seines Unternehmens versendet: „Versandservice“.
- Waren, die in einem Lager aufbewahrt und auf Anforderung versendet werden: „Lagerung und Versand“.

## 3. Bevor Sie beginnen

- **Konto.** Verwenden Sie ein Unternehmenskonto (Kundenkonto), einen Mitarbeiter dieses Kontos oder ein Kundenkonto eines Unternehmens. Ein Unternehmenskonto sieht seine eigenen Versandarten. Ein Kundenkonto sieht nur die Versandarten, die ihm sein Unternehmen zugewiesen hat, und jedes gekaufte Label wird seinem Guthaben belastet; hat das Unternehmen für diesen Kunden „Etikettendienst automatisch pausieren“ aktiviert, wird ein Label abgelehnt, solange Guthaben plus Kredit es nicht decken.
- **API-Berechtigung.** Für das Konto muss der API-Zugriff aktiviert sein. Andernfalls liefert jeder Label-Service-Aufruf `401` mit `Unauthorized`.
- **Versandarten.** Auf dem Konto muss mindestens eine Versandart aktiv sein (bei Kunden: dem Kunden zugewiesen). Die Kennungen der Versandarten unterscheiden sich je Konto und dürfen nicht fest kodiert werden; lesen Sie sie in Schritt 5.
- **Testdaten.** Verwenden Sie eine Testversandart oder eine Carrier-Sandbox, sofern konfiguriert (Tarife enthalten dann `test_mode: true`), und ein Ziel, das Sie kontrollieren. Stornieren Sie jedes Testlabel, das über eine produktive Versandart gekauft wurde.
- **Umgang mit Tokens.** Melden Sie sich von Ihrem Server aus an und bewahren Sie das Token dort auf. Legen Sie Token oder Passwort nicht in einem Browser oder einer mobilen Anwendung ab.
- **Platzhalter.** Ersetzen Sie `YOUR_HOST` durch Ihren Plattform-Host und `ACCESS_TOKEN` durch das Token aus Schritt 4. Ersetzen Sie die Werte für `shipping_method` durch die IDs Ihres Kontos.

## 4. Anmelden

Jeder Label-Service-Aufruf benötigt ein Bearer-Token. Die Integration meldet sich einmal an, speichert `access_token` und `expires_at` auf dem Server und meldet sich vor Ablauf des Tokens erneut an.

**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":"shipping@northbound-outfitters.ca","password":"your_password"}'
```

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

- `access_token`: Senden Sie es bei jedem weiteren Aufruf im unten gezeigten Header.
- `expires_at`: Melden Sie sich vor diesem Zeitpunkt erneut an.

```
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. Versandarten listen

Die Liste der Versandarten zeigt der Integration, mit welchen Carrier-Konten sie versenden darf und welche Optionen jedes davon akzeptiert. Speichern Sie die `id` jeder Versandart, die Sie verwenden; sie ist die `shipping_method` jedes weiteren Aufrufs.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingMethodList \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"detail": true}'
```

```json
[
  {
    "id": 59,
    "name": "Canada Post",
    "unique_identifier": "CPC-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    },
    "package_type": {
      "parcel": {
        "name": "Parcel",
        "options": { "weight_options": true, "dimension_options": true }
      }
    },
    "from_contry_limit": ["CA"],
    "isUploadMethod": false
  },
  {
    "id": 61,
    "name": "UPS",
    "unique_identifier": "UPS-TOR",
    "options": {
      "signature_option": true,
      "insurance_option": true,
      "insurance_value": true,
      "multi_package": true
    }
  }
]
```

Jede Zeile enthält:

| Feld | Verwendung |
|---|---|
| `id` | `shipping_method` in jedem weiteren Aufruf |
| `name` | Anzeigename |
| `unique_identifier` | Stabiler Code |
| `options.signature_option` | Unterschrift verfügbar |
| `options.insurance_option` | Versicherung verfügbar |
| `options.multi_package` | Mehr als ein Packstück |
| `package_type` | Akzeptierte `package_type`-Codes und ob jeweils Gewicht und Maße erforderlich sind |
| `from_contry_limit` | Länder, in denen die Absenderadresse liegen darf |
| `services` | Carrier und Services hinter der Versandart; mit den Codes lässt sich eine Preisanfrage über `carriers` / `services` einschränken |

Senden Sie `"id": 59`, um nur eine Versandart zu lesen, oder `"detail": false`, um nur `id`, `name` und `unique_identifier` zu erhalten.

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

**Überprüfung:** Die Liste ist nicht leer. Sie haben eine `id` gewählt und wissen, ob diese Versandart Unterschrift, Versicherung und mehrere Pakete zulässt. Eine leere Liste bedeutet, dass auf dem Konto keine Versandart aktiviert ist.

## 6. Preis anfragen

Eine Preisanfrage erfragt beim Carrier Preise, ohne etwas anzulegen: Der für die Anfrage verwendete temporäre Auftrag wird gelöscht, und es wird nichts berechnet. Northbound Outfitters ruft sie im Checkout auf, um die Canada-Post-Services für den Warenkorb anzuzeigen. Der Body hat dieselbe Struktur wie in Schritt 7. `shipping_method` ist erforderlich.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "transit_days": 3,
      "test_mode": false
    },
    {
      "carrier_name": "canadapost",
      "currency": "CAD",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "transit_days": 2,
      "test_mode": false
    }
  ],
  "best_rate": {
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86,
    "transit_days": 3
  }
}
```

- `rates[]`: ein Eintrag pro Carrier-Service mit `price`, `currency`, `transit_days` und `price_detail` (Grundpreis, Treibstoffzuschlag, Steuern). Zeigen Sie diese dem Kunden an.
- `best_rate` / `shipping_price`: der erste Tarif, den die Versandart liefert.
- Eine Preisanfrage enthält weder eine `rate_id` noch eine Auftrags-`id`. Labels werden aus den Tarifen des in Schritt 7 angelegten Auftrags gekauft.

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

Für mehr als ein Packstück senden Sie `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`. `shipping_from` (Adressbuch-ID) oder `shipping_from_code` kann den `sender_*`-Block ersetzen. `carriers` und `services` beschränken die Preisanfrage auf die angegebenen Codes.

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

**Überprüfung:** `result` ist true, und Sie haben einen Preis (und die Laufzeit in Tagen, wenn der Carrier sie sendet). Gibt es keinen Tarif, korrigieren Sie Ziel, Paket oder Versandart, **bevor** Sie anlegen.

## 7. Label-Auftrag anlegen

Dieser Aufruf legt den Label-Auftrag an und erfragt beim Carrier die Tarife dieser Sendung. Er liefert die Auftrags-`id` und eine `rate_id` pro Service. Das Label ist zu diesem Zeitpunkt noch nicht gekauft, und es wird nichts berechnet; Schritt 8 kauft es. Northbound Outfitters ruft ihn auf, sobald der Karton gepackt ist, und speichert die `id` zu seinem Auftrag `NB-10482`.

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

Derselbe Body wie in Schritt 6. Senden Sie `Idempotency-Key`: Eine Wiederholung mit demselben Schlüssel und demselben Body liefert die erste Antwort, statt einen zweiten Auftrag anzulegen.

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-label" \
  -d '{
    "shipping_method": 59,
    "name": "Emily Tremblay",
    "telephone": "4035550182",
    "email": "emily.tremblay@example.com",
    "address_1": "1415 17 Ave SW",
    "city": "Calgary",
    "province": "AB",
    "postcode": "T2T0C8",
    "country": "CA",
    "weight": 4.2,
    "length": 60,
    "width": 30,
    "height": 25,
    "dimension_unit": 2,
    "weight_unit": 2,
    "signature_option": 0,
    "insurance_option": 0,
    "package_type": "parcel",
    "ref": "NB-10482",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA"
  }'
```

```json
{
  "result": true,
  "id": 128455,
  "shipping_price": "24.86",
  "price_details": { "shipping_fee": "24.86" },
  "rates": [
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
      "carrier_name": "canadapost",
      "service_code": "canadapost_expedited_parcel",
      "service_name": "CANADAPOST EXPEDITED PARCEL",
      "price": 24.86,
      "currency": "CAD",
      "transit_days": 3
    },
    {
      "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c11",
      "carrier_name": "canadapost",
      "service_code": "canadapost_xpresspost",
      "service_name": "CANADAPOST XPRESSPOST",
      "price": 38.12,
      "currency": "CAD",
      "transit_days": 2
    }
  ],
  "best_rate": {
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10",
    "service_code": "canadapost_expedited_parcel",
    "price": 24.86
  }
}
```

| Feld | Verwendung |
|---|---|
| `id` | Superroute-Auftrags-ID — zum Kaufen, Herunterladen und Stornieren |
| `rates[].rate_id` | Der in Schritt 8 zu kaufende Service; nur für diesen Auftrag gültig |
| `rates[].price` | Preis dieses Service |
| `shipping_price` | Preis von `best_rate` |

Eine Sendung in die Vereinigten Staaten läuft über die UPS-Versandart mit den für den Zoll erforderlichen Artikelpositionen. Dieses Beispiel verwendet die `packages`-Form, die die Artikel pro Karton enthält:

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitOrder \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10497-label" \
  -d '{
    "shipping_method": 61,
    "name": "Daniel Price",
    "telephone": "2065550117",
    "email": "daniel.price@example.com",
    "address_1": "500 Mercer St",
    "city": "Seattle",
    "province": "WA",
    "postcode": "98109",
    "country": "US",
    "package_type": "parcel",
    "paid_by": 1,
    "ref": "NB-10497",
    "sender_name": "Northbound Outfitters",
    "sender_telephone": "4165550140",
    "sender_address_1": "1200 Eglinton Ave E",
    "sender_city": "Toronto",
    "sender_province": "ON",
    "sender_postcode": "M3C1H9",
    "sender_country": "CA",
    "packages": [
      {
        "ref": "NB-10497-1",
        "weight": 2.6,
        "length": 45,
        "width": 30,
        "height": 20,
        "weight_unit": 2,
        "dimension_unit": 2,
        "items": [
          {
            "name": "Down sleeping bag",
            "description": "Down-filled sleeping bag, -7 C rating",
            "quantity": 1,
            "unit_price": 289.00,
            "currency": "CAD",
            "weight": 1.6,
            "hscode": "9404400000",
            "sku": "NB-SB-7C",
            "unit": "PCS"
          },
          {
            "name": "Camp stove",
            "description": "Canister camp stove",
            "quantity": 1,
            "unit_price": 79.00,
            "currency": "CAD",
            "weight": 1.0,
            "hscode": "7321111000",
            "sku": "NB-ST-01",
            "unit": "PCS"
          }
        ]
      }
    ]
  }'
```

**GraphQL:** `labelserviceSubmitOrder` ([GraphQL-Handbuch](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)). Der REST-Body wird in `input` übergeben:

```graphql
mutation {
  labelserviceSubmitOrder(input: {
    shipping_method: 59
    name: "Emily Tremblay"
    telephone: "4035550182"
    address_1: "1415 17 Ave SW"
    city: "Calgary"
    province: "AB"
    postcode: "T2T0C8"
    country: "CA"
    weight: 4.2
    length: 60
    width: 30
    height: 25
    dimension_unit: 2
    weight_unit: 2
    package_type: "parcel"
    ref: "NB-10482"
    sender_name: "Northbound Outfitters"
    sender_telephone: "4165550140"
    sender_address_1: "1200 Eglinton Ave E"
    sender_city: "Toronto"
    sender_province: "ON"
    sender_postcode: "M3C1H9"
    sender_country: "CA"
  })
}
```

**Überprüfung:** Die Antwort enthält eine `id` und mindestens eine `rates[].rate_id`. Speichern Sie beide. Derselbe `Idempotency-Key` mit demselben Body liefert dieselbe `id` und legt keinen zweiten Auftrag an.

## 8. Label kaufen und Sendungsdetails lesen

Dieser Aufruf kauft das Label beim gewählten Service, berechnet es und liefert die Carrier-Tracking-Nummern. Ist das Label bereits gekauft, liest er nur die Details, sodass ein wiederholter Aufruf nie zweimal kauft. Northbound Outfitters sendet die `rate_id` des Service, den der Kunde bezahlt hat.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingDetail \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "rate_id": "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  }'
```

```json
{
  "id": 128455,
  "shippingPrice": "24.86",
  "mainTrackingNumber": "7023210039414604",
  "trackingNumber": "7023210039414604",
  "needSubmitShippingInformation": false,
  "rate": {
    "carrier_name": "canadapost",
    "price": 24.86,
    "price_detail": [
      { "name": "Base charge", "amount": 18.40 },
      { "name": "Fuel surcharge", "amount": 3.60 },
      { "name": "GST", "amount": 1.10 }
    ],
    "tax_items": ["HST", "GST", "PST", "QST"]
  },
  "labelStatus": "ready",
  "shippingLabel": "JVBERi0xLjQKMS... (base64 encoded)"
}
```

| Feld | Verwendung |
|---|---|
| `mainTrackingNumber` | Carrier-Tracking-Nummer des ersten Pakets; geben Sie sie an den Kunden weiter |
| `trackingNumber` | Carrier-Tracking-Nummern aller Pakete, durch Kommas getrennt |
| `shippingPrice` | Berechneter Betrag |
| `labelStatus` | `ready`: `shippingLabel` enthält das PDF. `pending`: gekauft und berechnet, der Carrier hat die Datei noch nicht erstellt; rufen Sie später erneut auf. `failed`: der Abruf im Hintergrund wurde aufgegeben; ein erneuter Aufruf startet ihn neu |
| `needSubmitShippingInformation` | `true`, wenn für diese Versandart die Sendungsinformationen übermittelt werden müssen (Schritt 13) |

`type` kann `ORDER_ID` (Standard), `TRACKING_NUMBER` (die Superroute-Paketnummer) oder `THIRD_PARTY_TRACKING_NUMBER` (die Carrier-Nummer) sein. Senden Sie `rate_id`, damit das Label beim gewählten Service gekauft wird; ohne diesen Wert kauft die Versandart zu ihrem Standardtarif.

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

```graphql
mutation {
  labelserviceGetShippingDetail(
    id: "128455"
    type: "ORDER_ID"
    rate_id: "rat_5f0c2a9e41d84b7c9a3e6d21b8f47c10"
  )
}
```

**Überprüfung:** `mainTrackingNumber` ist nicht leer, und `labelStatus` ist `ready` (oder `pending` und wird bei einem späteren Aufruf `ready`). Ein zweiter Aufruf liefert dieselbe Tracking-Nummer und denselben `shippingPrice`.

## 9. PDF herunterladen

Das Lager druckt das Label des Carriers über diesen Aufruf. Wurde das Label noch nicht gekauft, kauft es der erste Aufruf zum Standardtarif, wie in Schritt 8; rufen Sie zuerst Schritt 8 auf, um den Service festzulegen.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "128455",
    "type": "ORDER_ID",
    "base64": 1
  }'
```

```json
"JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+..."
```

- Mit `base64: 1` ist der Body das PDF als ein Base64-String; dekodieren Sie ihn und senden Sie ihn an den Drucker.
- Mit `base64: 0` ist die Antwort die PDF-Datei selbst (`application/pdf`).

`type` kann `ORDER_ID` (Standard), `TRACKING_NUMBER` oder `THIRD_PARTY_TRACKING_NUMBER` (die Carrier-Nummer) sein. Dies ist das **offizielle Label des Carriers**. Die Anzahl der Packstücke ist durch die Buchung festgelegt.

**GraphQL:** `labelserviceGetShippingLabel` ([GraphQL-Handbuch](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)). GraphQL liefert immer den Base64-String.

**Überprüfung:** Das PDF lässt sich öffnen und zeigt den Carrier-Barcode / die Tracking-Nummer aus Schritt 8. Drucken Sie ein Testexemplar und entsorgen Sie es anschließend — übergeben Sie kein Testlabel an einen Carrier.

## 10. Verfolgen

Der Shop zeigt den Fortschritt des Pakets auf der Auftragsseite des Kunden an. Der öffentliche Tracking-Endpunkt benötigt kein Token und akzeptiert die Carrier-Nummer aus Schritt 8.

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

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

```json
{
  "result": true,
  "is_third_party_tracking": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 430,
      "otep_status": "in_transit",
      "description": "Item in transit",
      "location_city": "Mississauga",
      "updated_at_localized": "2026-09-29 18:42"
    }
  ]
}
```

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

```graphql
query {
  trackingPublic(trackingNumber: "7023210039414604") {
    result
    deliveried
    is_third_party_tracking
    data {
      tracking_event_status_id
      otep_status
      description
      updated_at_localized
    }
    third_party_info { tracking_number status carrier_tracking_link }
    proofs { file_id type full_url signed_url }
  }
}
```

- `is_third_party_tracking` ist true, wenn die Ereignisse vom Carrier stammen.
- `data`: das neueste Ereignis zuerst. Verzweigen Sie anhand von `tracking_event_status_id` / `otep_status`, nicht anhand von `description`. Frühe Ereignisse können noch „Informationen übermittelt“ lauten, bis der Carrier das Paket scannt.
- `deliveried` ist true und `500` bedeutet zugestellt; `proofs[]` kann dann eine Unterschrift (`type` `1`) oder ein Foto (`type` `2`) enthalten.

**Überprüfung:** Die Abfrage liefert die Sendung, die Sie gerade angelegt haben. Eine unbekannte oder stornierte Nummer liefert `404` mit `result: false`.

## 11. Ereignisbenachrichtigungen konfigurieren

Webhooks ersetzen Abfragen: Der Server des Shops empfängt jeden Carrier-Scan und aktualisiert den Auftrag, ohne Schritt 10 zeitgesteuert aufzurufen.

| Einstellung | Ereignis | Wann |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | Carrier-Scans, in Zustellung, zugestellt |
| `order_status_change_webhook_url` | `order.status_change` | Status in Ihrem System |
| `order_create_webhook_url` | `order.created` | Ein Label-Auftrag wurde angelegt (Schritt 7); für Label-Aufträge nur gesendet, wenn `order_created_webhook_all_types` den Wert `1` hat |

**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://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "tracking_event_webhook_url",
    "order_create_webhook_url",
    "order_created_webhook_all_types",
    "webhook_sign_secret",
    "webhook_verify_ssl"
  ],
  "recipient_type": "business",
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_create_webhook_url": "https://shop.northbound-outfitters.ca/hooks/superroute",
    "order_created_webhook_all_types": 1,
    "webhook_verify_ssl": 1
  }
}
```

- Nur die gesendeten Schlüssel werden geändert; ein unbekannter Schlüssel wird mit `400` abgelehnt.
- `changed_keys` listet, was gespeichert wurde. Das Secret wird immer maskiert zurückgegeben.

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

Jedes `tracking.event` enthält `order_id`, `tracking_event_status_id`, `tracking_event_key`, `tracking_number` und `external_tracking_number`; ordnen Sie es anhand von `order_id` (der `id` aus Schritt 7) Ihrem Auftrag zu.

Prüfen Sie **v2** ü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**.

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

`order_cancel_failed_webhook_url` (`order.cancel_failed`) wird für Label-Stornierungen in Schritt 12 nicht gesendet; eine abgelehnte Label-Stornierung wird in der Antwort dieses Aufrufs gemeldet.

**Überprüfung:** Ein Test-`submitOrder` erzeugt `order.created` mit der Auftrags-`id`, und der erste Carrier-Scan erzeugt `tracking.event`. Eine ungültige Signatur muss der Empfänger mit `401` ablehnen.

## 12. Stornieren

Ein Label, das nicht versendet wird, wird storniert, damit der Carrier es nicht berechnet; die Belastung wird dem Konto erstattet. Eine Stornierung ist nur möglich, solange der Carrier sie noch zulässt (in der Regel vor der Abholung).

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/cancelShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nb-10482-cancel" \
  -d '{"id": 128455}'
```

```json
{
  "result": true,
  "message": "Shipping Label cancelled successfully"
}
```

- Senden Sie genau einen der Werte `id` (die Auftrags-ID) oder `tracking_number` (die Superroute- oder die Carrier-Tracking-Nummer). Werden beide gesendet, lautet die Antwort `400`.
- `result: true`: Der Carrier hat die Stornierung akzeptiert, und die Labelgebühr wurde erstattet.

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

Ein Carrier, der das Paket bereits übernommen hat, lehnt ab: Die Antwort ist `400` mit `result: false` und der Meldung des Carriers. Ein Auftrag, dessen Label nie gekauft wurde, kann über diesen Aufruf nicht storniert werden.

**Überprüfung:** Die Antwort ist `result: true`, und das öffentliche Tracking für diese Nummer liefert `404`. Eine Wiederholung mit demselben `Idempotency-Key` liefert die gespeicherte Antwort; eine neue Stornierungsanfrage für denselben Auftrag liefert `400` `This order already cancelled`.

## 13. Sendungsinformationen übermitteln und Tagesabschluss (nur wenn diese Versandart es erfordert)

Manche Carrier benötigen vor der Abholung eine Übermittlung der Sendungen des Tages (ein Manifest). Schritt 8 meldet dies pro Auftrag in `needSubmitShippingInformation`. Sammeln Sie diese Auftrags-IDs im Laufe des Tages und übermitteln Sie sie nach dem letzten Label.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/submitShippingInformation \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ids": [128455, 128461, 128470]}'
```

```json
{
  "result": true,
  "message": "Processed 3 orders. Success: 3, Failed: 0",
  "data": {
    "total_processed": 3,
    "success_count": 3,
    "failure_count": 0,
    "details": [
      { "order_id": 128455, "result": true, "message": "Successful" },
      { "order_id": 128461, "result": true, "message": "Successful" },
      { "order_id": 128470, "result": true, "message": "Successful" }
    ]
  }
}
```

- `details[]`: eine Zeile pro Auftrag; übermitteln Sie die Aufträge mit `result: false` erneut, nachdem Sie die Ursache aus `message` behoben haben.
- `404` `No eligible orders found for shipping information submission`: Keine der IDs hat ein gekauftes Label, das noch übermittelt werden muss.

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

Schließen Sie dann den Tag ab. Der Aufruf benötigt keinen Body und umfasst alle Label-Aufträge des Aufrufers.

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

```bash
curl -X POST https://YOUR_HOST/api/v1/labelservice/endofday \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "success": 0,
  "failed": 0,
  "success_ids": [],
  "failed_ids": []
}
```

- `400` mit `There are orders need to submit shipping information`: Einige gekaufte Labels müssen noch übermittelt werden; übermitteln Sie sie mit `submitShippingInformation` und rufen Sie erneut auf.

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

Überspringen Sie diesen Schritt, wenn kein Auftrag des Tages `needSubmitShippingInformation: true` gemeldet hat.

**Überprüfung:** `submitShippingInformation` meldet `failure_count: 0`, und `endofday` antwortet mit `200`. Führen Sie dies zuerst mit einer Testversandart aus.

## 14. Fehlerbehandlung

Fehler des Label-Service enthalten eine `message`; ein `code` ist nur vorhanden, wenn die Tabelle einen ausweist.

| Situation | HTTP-Status | Code | Vorgehen der Integration |
|---|---|---|---|
| Token fehlt oder ist abgelaufen, oder API-Zugriff ist nicht aktiviert | `401` | — (`Unauthorized`) | Erneut anmelden; besteht das Problem weiter, das Unternehmen bitten, den API-Zugriff zu aktivieren |
| `shipping_method` fehlt oder steht dem Aufrufer nicht zur Verfügung | `400` | — | Die Liste der Versandarten neu laden (Schritt 5) und eine `id` daraus verwenden |
| `package_type` wird von der Versandart nicht angeboten | `400` | — | Einen Schlüssel aus `package_type` von Schritt 5 verwenden |
| Ungültige Adresse oder ungültiges Paket, oder der Carrier liefert keinen Tarif | `400` | — (Carrier-Meldung) | Die Meldung anzeigen, die Daten korrigieren und erneut anfragen |
| `auto_deduplication` ist `1` und die `ref` existiert bereits | `400` | — (`exist_order_ids`) | Den bestehenden Auftrag aus `exist_order_ids` verwenden, statt einen neuen anzulegen |
| Derselbe `Idempotency-Key` mit einem anderen Body | `409` | `IDEMPOTENCY_CONFLICT` | Für eine andere Anfrage einen neuen Schlüssel verwenden |
| Derselbe `Idempotency-Key`, während die erste Anfrage noch läuft | `409` | `IDEMPOTENCY_IN_PROGRESS` | `Retry-After` Sekunden warten und mit demselben Schlüssel und Body erneut versuchen |
| Guthaben plus Kredit des Kunden decken das Label nicht | `400` | `INSUFFICIENT_BALANCE` | Mit den Angaben aus `insufficient_balance` (`shortfall`, `add_funds_url`) aufladen und Schritt 8 erneut aufrufen |
| Label gekauft, Carrier-Datei noch nicht bereit | `400` beim ersten Kauf, danach `200` | `shipment_label_not_ready` | Warten, solange `labelStatus` `pending` ist; Schritt 8 erneut aufrufen, wenn es `failed` ist |
| Auftrags-ID oder Nummer gehört nicht dem Aufrufer | `401` | — (`Not Auth`) | ID und `type` prüfen; das Konto verwenden, das den Auftrag angelegt hat |
| Stornierung vom Carrier abgelehnt, oder der Auftrag ist bereits storniert | `400` | — | Das Label als versendet (oder bereits storniert) behandeln; nicht erneut versuchen |
| Tracking-Nummer unbekannt oder storniert | `404` | — | Die Zeitleiste für diese Nummer nicht mehr anzeigen |
| `endofday` mit noch nicht übermittelten Sendungen | `400` | — | `submitShippingInformation` für diese Aufträge ausführen und dann erneut aufrufen |

## Testliste

Verwenden Sie ein Ziel, das Sie kontrollieren, und eine Versandart, die storniert werden kann:

- [ ] Die Liste der Versandarten ist nicht leer; Sie haben eine `id` notiert.
- [ ] Die Preisanfrage liefert einen Preis für diese Versandart und dieses Ziel.
- [ ] Das Anlegen liefert eine Auftrags-`id` und `rates[].rate_id`; derselbe `Idempotency-Key` legt keinen zweiten Auftrag an.
- [ ] `getShippingDetail` mit der gewählten `rate_id` liefert `mainTrackingNumber`; ein zweiter Aufruf berechnet nicht erneut.
- [ ] Das Label-PDF lässt sich öffnen und zeigt die Carrier-Tracking-Nummer.
- [ ] Das öffentliche Tracking findet die Sendung über diese Nummer.
- [ ] `tracking.event` (und `order.created`, sofern aktiviert) geht ein; die v2-Signatur wird bestätigt.
- [ ] Eine grenzüberschreitende Testsendung mit `items` wird vom Carrier akzeptiert.
- [ ] Die Stornierung ist erfolgreich, **oder** Sie haben bestätigt, dass diese Versandart nach der Buchung nicht storniert werden kann.
- [ ] Erfordert die Versandart einen Tagesabschluss, wird ein Testlauf ohne Fehler abgeschlossen.
