# Etykiety przewoźnika

Usługa etykiet kupuje etykiety wysyłkowe od przewoźników połączonych z kontem (na przykład UPS i Canada Post) i przechowuje każdą etykietę jako zamówienie. Integracja pobiera listę metod wysyłki konta, wycenia paczkę, tworzy zamówienie etykiety, kupuje etykietę w wybranej usłudze przewoźnika, drukuje PDF, śledzi paczkę i anuluje niewykorzystane etykiety. Usługa jest przeznaczona dla sklepów internetowych, systemów magazynowych i systemów zarządzania zamówieniami, które wysyłają paczki przez przewoźników, a nie przez własnych kierowców.

## 1. Co można zbudować

Przykłady w tym przewodniku dotyczą jednej firmy: **Northbound Outfitters**, internetowego sklepu ze sprzętem turystycznym, który wysyła towar ze swojego magazynu przy 1200 Eglinton Ave E w Toronto. Na koncie firmy jest metoda Canada Post i metoda UPS. Typowe zamówienie to karton z namiotem o wadze 4,2 kg i wymiarach 60 × 30 × 25 cm, wysyłany do Calgary; zamówienia do Stanów Zjednoczonych są wysyłane przez UPS.

- **Wybór przewoźnika przy składaniu zamówienia.** Sklep wycenia koszyk klienta w Canada Post, wyświetla usługi z ceną i liczbą dni tranzytu, a następnie wysyła towar usługą, za którą klient zapłacił.
- **Druk etykiety jednym kliknięciem w magazynie.** Stanowisko pakowania tworzy zamówienie etykiety po zapakowaniu kartonu, kupuje etykietę w wybranej usłudze i drukuje PDF przewoźnika na drukarce termicznej.
- **Przesyłki transgraniczne z danymi celnymi.** Zamówienia do Stanów Zjednoczonych zawierają pozycje towarowe (opis, ilość, wartość, kod HS), dzięki czemu etykieta UPS jest wystawiana wraz z danymi handlowymi.
- **Automatyczne aktualizacje statusu dla klienta.** Sklep zapisuje numer śledzenia przewoźnika, wyświetla publiczną oś czasu śledzenia na stronie zamówienia i aktualizuje zamówienie, gdy webhook `tracking.event` zgłosi, że paczka została dostarczona.

## 2. Zakres tego przewodnika

Ten przewodnik opisuje usługę etykiet v1 (`/api/v1/labelservice/...`): jedna metoda wysyłki (jedno konto przewoźnika) na wywołanie. Należy z niej korzystać, gdy integracja już wie, którą metodą wysyła, lub gdy utrzymuje istniejącą integrację z usługą etykiet.

Dla nowych integracji zalecanym jednolitym punktem wejścia jest Uniorder (`/api/v1/uniorder/...`). Przewodniki Uniorder, „Uniorder: jedno API dla każdej przesyłki” oraz „Wycena i zamówienie w jednym przepływie”, wyceniają jednocześnie wszystkie usługi przewoźników konta (wraz z dostawą własną firmy, jeśli ma zastosowanie) i kupują etykietę w usłudze wybranej przez odesłanie jej `rate_id`. Te same wywołania służą następnie do drukowania, śledzenia i anulowania każdego zamówienia.

Pozostałe rodzaje przesyłek opisują inne przewodniki:

- Dostawa przez własnych kierowców firmy: „Odbiór i dostawa (własna flota)”.
- Konto klienta wysyłające za pomocą usług oferowanych przez jego firmę: „Usługi wysyłkowe”.
- Towar przechowywany w magazynie i wysyłany na żądanie: „Magazynowanie i wydanie”.

## 3. Przed rozpoczęciem

- **Konto.** Należy użyć konta firmy (klienta), konta pracownika tej firmy lub konta klienta firmy. Konto firmy widzi własne metody wysyłki. Konto klienta widzi tylko metody przypisane mu przez firmę, a każda kupiona przez nie etykieta obciąża jego saldo; gdy firma włączyła dla tego klienta funkcję „Automatyczne wstrzymanie usługi etykiet”, etykieta jest odrzucana, dopóki saldo wraz z limitem kredytowym jej nie pokrywa.
- **Uprawnienie API.** Na koncie musi być włączony dostęp do API. Bez niego każde wywołanie usługi etykiet zwraca `401` z komunikatem `Unauthorized`.
- **Metody wysyłki.** Na koncie musi być aktywna co najmniej jedna metoda wysyłki (w przypadku klientów: przypisana klientowi). Identyfikatory metod różnią się w zależności od konta i nie mogą być zapisane na stałe w kodzie; należy je odczytać w kroku 5.
- **Dane testowe.** Należy użyć testowej metody wysyłki lub środowiska sandbox przewoźnika, jeśli zostało skonfigurowane (stawki zawierają wtedy `test_mode: true`), oraz adresu docelowego pod własną kontrolą. Każdą etykietę testową kupioną w metodzie produkcyjnej należy anulować.
- **Obsługa tokenu.** Logowanie należy wykonywać z własnego serwera i tam przechowywać token. Tokenu ani hasła nie wolno umieszczać w przeglądarce ani w aplikacji mobilnej.
- **Symbole zastępcze.** `YOUR_HOST` należy zastąpić hostem platformy, a `ACCESS_TOKEN` tokenem z kroku 4. Wartości `shipping_method` należy zastąpić identyfikatorami własnego konta.

## 4. Uwierzytelnienie

Każde wywołanie usługi etykiet wymaga tokenu typu bearer. Integracja loguje się raz, zapisuje `access_token` i `expires_at` na serwerze i loguje się ponownie przed wygaśnięciem tokenu.

**REST:** `POST /api/v1/user/login` — [Podręcznik REST](/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`: należy go wysyłać w każdym kolejnym wywołaniu jako poniższy nagłówek.
- `expires_at`: przed tym czasem należy zalogować się ponownie.

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL używa tego samego nagłówka na `POST /api/graphql`.

**GraphQL:** `userLogin` ([Podręcznik GraphQL](/api/graphql/documentation#/user/userLogin))

**Weryfikacja:** logowanie zwraca `access_token`. Kolejne żądania bez tego tokenu zwracają `401`.

## 5. Lista metod wysyłki

Lista metod informuje integrację, z których kont przewoźników może korzystać i jakie opcje przyjmuje każde z nich. Należy zapisać `id` każdej używanej metody; jest to `shipping_method` w każdym kolejnym wywołaniu.

**REST:** `POST /api/v1/labelservice/getShippingMethodList` — [Podręcznik REST](/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
    }
  }
]
```

Każdy wiersz zawiera:

| Pole | Użycie |
|---|---|
| `id` | `shipping_method` w każdym kolejnym wywołaniu |
| `name` | Nazwa wyświetlana |
| `unique_identifier` | Stały kod |
| `options.signature_option` | Podpis dostępny |
| `options.insurance_option` | Ubezpieczenie dostępne |
| `options.multi_package` | Więcej niż jedna sztuka |
| `package_type` | Akceptowane kody `package_type` oraz informacja, czy każdy z nich wymaga wagi i wymiarów |
| `from_contry_limit` | Kraje, w których może znajdować się adres nadawcy |
| `services` | Przewoźnicy i usługi dostępne w ramach metody; ich kody mogą ograniczyć wycenę za pomocą `carriers` / `services` |

Aby odczytać tylko jedną metodę, należy wysłać `"id": 59`, a aby otrzymać tylko `id`, `name` i `unique_identifier` — `"detail": false`.

**GraphQL:** `labelserviceGetShippingMethodList` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingMethodList)) (skalar JSON).

**Weryfikacja:** lista nie jest pusta. Wybrano jedno `id` i wiadomo, czy ta metoda pozwala na podpis, ubezpieczenie i wiele paczek. Pusta lista oznacza, że na koncie nie włączono żadnej metody.

## 6. Wycena

Wycena pobiera ceny od przewoźnika bez tworzenia czegokolwiek: tymczasowe zamówienie użyte do zapytania jest usuwane i nic nie jest pobierane. Northbound Outfitters wywołuje ją przy składaniu zamówienia, aby wyświetlić usługi Canada Post dla koszyka. Treść żądania ma taką samą strukturę jak w kroku 7. Pole `shipping_method` jest wymagane.

**REST:** `POST /api/v1/labelservice/rate` — [Podręcznik REST](/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[]`: jedna pozycja na każdą usługę przewoźnika, z polami `price`, `currency`, `transit_days` i `price_detail` (opłata podstawowa, dopłata paliwowa, podatki). Te dane należy pokazać klientowi.
- `best_rate` / `shipping_price`: pierwsza stawka zwrócona przez metodę.
- Wycena nie zawiera `rate_id` ani `id` zamówienia. Etykiety kupuje się na podstawie stawek zamówienia utworzonego w kroku 7.

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

Dla więcej niż jednej sztuki należy wysłać `packages: [{ ref, weight, length, width, height, weight_unit, dimension_unit }]`. `shipping_from` (id z książki adresowej) lub `shipping_from_code` może zastąpić blok `sender_*`. `carriers` i `services` ograniczają wycenę do wymienionych kodów.

**GraphQL:** `labelserviceRate` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceRate)).

**Weryfikacja:** `result` ma wartość true i dostępna jest cena (oraz liczba dni tranzytu, jeśli przewoźnik ją przesyła). Jeśli nie ma stawki, należy poprawić adres docelowy / paczkę / metodę **przed** utworzeniem zamówienia.

## 7. Utworzenie zamówienia etykiety

To wywołanie tworzy zamówienie etykiety i pobiera od przewoźnika stawki dla tej przesyłki. Zwraca `id` zamówienia i jedno `rate_id` na każdą usługę. Na tym etapie etykieta nie jest jeszcze kupiona i nic nie jest pobierane; zakup następuje w kroku 8. Northbound Outfitters wywołuje je po zapakowaniu kartonu i zapisuje `id` przy swoim zamówieniu `NB-10482`.

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

Ta sama treść żądania co w kroku 6. Należy wysłać `Idempotency-Key`: ponowienie z tym samym kluczem i tą samą treścią zwraca pierwszą odpowiedź zamiast tworzyć drugie zamówienie.

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

| Pole | Użycie |
|---|---|
| `id` | Id zamówienia Superroute — zakup, pobranie i anulowanie |
| `rates[].rate_id` | Usługa do zakupu w kroku 8; ważna tylko dla tego zamówienia |
| `rates[].price` | Cena tej usługi |
| `shipping_price` | Cena `best_rate` |

Przesyłka do Stanów Zjednoczonych jest wysyłana metodą UPS z pozycjami towarowymi wymaganymi do odprawy celnej. Ten przykład używa formy `packages`, która zawiera pozycje dla każdego kartonu:

```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` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceSubmitOrder)). Treść żądania REST umieszcza się w `input`:

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

**Weryfikacja:** odpowiedź zawiera `id` i co najmniej jedno `rates[].rate_id`. Należy zapisać obie wartości. Ten sam `Idempotency-Key` z tą samą treścią zwraca to samo `id` i nie tworzy drugiego zamówienia.

## 8. Zakup etykiety i odczyt szczegółów przesyłki

To wywołanie kupuje etykietę w wybranej usłudze, pobiera opłatę i zwraca numery śledzenia przewoźnika. Gdy etykieta jest już kupiona, wywołanie jedynie odczytuje szczegóły, więc powtórzone wywołanie nigdy nie kupuje drugi raz. Northbound Outfitters wysyła `rate_id` usługi, za którą zapłacił klient.

**REST:** `POST /api/v1/labelservice/getShippingDetail` — [Podręcznik REST](/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)"
}
```

| Pole | Użycie |
|---|---|
| `mainTrackingNumber` | Numer śledzenia przewoźnika dla pierwszej paczki; należy go przekazać klientowi |
| `trackingNumber` | Numery śledzenia przewoźnika dla wszystkich paczek, rozdzielone przecinkami |
| `shippingPrice` | Pobrana kwota |
| `labelStatus` | `ready`: `shippingLabel` zawiera PDF. `pending`: kupiona i opłacona, przewoźnik nie wygenerował jeszcze pliku; należy wywołać ponownie później. `failed`: pobieranie w tle zostało przerwane; ponowne wywołanie uruchamia je od nowa |
| `needSubmitShippingInformation` | `true`, gdy ta metoda wymaga przesłania informacji o przesyłce (krok 13) |

`type` może mieć wartość `ORDER_ID` (domyślnie), `TRACKING_NUMBER` (numer paczki Superroute) lub `THIRD_PARTY_TRACKING_NUMBER` (numer przewoźnika). Należy wysłać `rate_id`, aby etykieta została kupiona w wybranej usłudze; bez niego metoda kupuje po stawce domyślnej.

**GraphQL:** `labelserviceGetShippingDetail` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingDetail))

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

**Weryfikacja:** `mainTrackingNumber` nie jest puste, a `labelStatus` ma wartość `ready` (lub `pending`, która przy kolejnym wywołaniu zmienia się na `ready`). Drugie wywołanie zwraca ten sam numer śledzenia i tę samą wartość `shippingPrice`.

## 9. Pobranie PDF

Magazyn drukuje etykietę przewoźnika z tego wywołania. Jeśli etykieta nie została jeszcze kupiona, pierwsze wywołanie kupuje ją po stawce domyślnej, tak jak w kroku 8; aby ustalić usługę, należy najpierw wywołać krok 8.

**REST:** `POST /api/v1/labelservice/getShippingLabel` — [Podręcznik REST](/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+..."
```

- Przy `base64: 1` treść odpowiedzi to PDF jako jeden ciąg base64; należy go zdekodować i wysłać do drukarki.
- Przy `base64: 0` odpowiedzią jest sam plik PDF (`application/pdf`).

`type` może mieć wartość `ORDER_ID` (domyślnie), `TRACKING_NUMBER` lub `THIRD_PARTY_TRACKING_NUMBER` (numer przewoźnika). Jest to **oficjalna etykieta przewoźnika**. Liczba sztuk jest ustalona przez rezerwację.

**GraphQL:** `labelserviceGetShippingLabel` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceGetShippingLabel)). GraphQL zawsze zwraca ciąg base64.

**Weryfikacja:** PDF się otwiera i pokazuje kod kreskowy / numer śledzenia przewoźnika z kroku 8. Należy wydrukować jedną kopię testową, a następnie ją zniszczyć — etykiety testowej nie wolno przekazywać przewoźnikowi.

## 10. Śledzenie

Sklep pokazuje postęp paczki na stronie zamówienia klienta. Publiczny endpoint śledzenia nie wymaga tokenu i przyjmuje numer przewoźnika z kroku 8.

**REST:** `GET /api/v1/tracking/{trackingNumber}` — [Podręcznik REST](/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` ([Podręcznik GraphQL](/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` ma wartość true, gdy zdarzenia pochodzą od przewoźnika.
- `data`: najnowsze zdarzenie jest pierwsze. Logikę należy opierać na `tracking_event_status_id` / `otep_status`, a nie na `description`. Wczesne zdarzenia mogą nadal mieć postać „informacje przekazane”, dopóki przewoźnik nie zeskanuje paczki.
- `deliveried` ma wartość true, a `500` oznacza dostarczenie; `proofs[]` może wtedy zawierać podpis (`type` `1`) lub zdjęcie (`type` `2`).

**Weryfikacja:** wyszukiwanie zwraca właśnie utworzoną przesyłkę. Nieznany lub anulowany numer zwraca `404` z `result: false`.

## 11. Konfiguracja powiadomień o zdarzeniach

Webhooki zastępują odpytywanie: serwer sklepu otrzymuje każde skanowanie przewoźnika i aktualizuje zamówienie bez cyklicznego wywoływania kroku 10.

| Ustawienie | Zdarzenie | Kiedy |
|---|---|---|
| `tracking_event_webhook_url` | `tracking.event` | Skanowania przewoźnika, w doręczeniu, dostarczone |
| `order_status_change_webhook_url` | `order.status_change` | Status w systemie integracji |
| `order_create_webhook_url` | `order.created` | Utworzono zamówienie etykiety (krok 7); dla zamówień etykiet wysyłane tylko, gdy `order_created_webhook_all_types` ma wartość `1` |

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

- Zmieniane są tylko wysłane klucze; nieznany klucz jest odrzucany z kodem `400`.
- `changed_keys` wymienia zapisane ustawienia. Sekret jest zawsze zwracany w postaci zamaskowanej.

**GraphQL:** `webhookSettingsUpdate` ([Podręcznik GraphQL](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

Każde zdarzenie `tracking.event` zawiera `order_id`, `tracking_event_status_id`, `tracking_event_key`, `tracking_number` i `external_tracking_number`; należy je dopasować do własnego zamówienia po `order_id` (`id` z kroku 7).

Weryfikacja **v2** odbywa się na surowej treści: `HMAC_SHA256(timestamp + "." + raw_body, secret)` porównywane z `X-Webhook-Signature-V2`. Deduplikację należy prowadzić po `X-Webhook-Event-Id`. Odpowiedź **2xx musi zostać wysłana w mniej niż 3 sekundy**.

```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`) nie jest wysyłany przy anulowaniu etykiet w kroku 12; odrzucone anulowanie etykiety jest zgłaszane w odpowiedzi tego wywołania.

**Weryfikacja:** jedno testowe wywołanie `submitOrder` generuje `order.created` z `id` zamówienia, a pierwsze skanowanie przewoźnika generuje `tracking.event`. Nieprawidłowy podpis musi zostać odrzucony przez odbiorcę kodem `401`.

## 12. Anulowanie

Etykietę, która nie zostanie nadana, należy anulować, aby przewoźnik jej nie rozliczył; pobrana opłata jest zwracana na konto. Anulowanie jest możliwe tylko dopóki przewoźnik na to pozwala (zwykle przed odbiorem).

**REST:** `POST /api/v1/labelservice/cancelShippingLabel` — [Podręcznik REST](/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"
}
```

- Należy wysłać dokładnie jedno z pól: `id` (id zamówienia) lub `tracking_number` (numer śledzenia Superroute lub przewoźnika). Wysłanie obu zwraca `400`.
- `result: true`: przewoźnik przyjął anulowanie, a opłata za etykietę została zwrócona.

**GraphQL:** `labelserviceCancelShippingLabel` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceCancelShippingLabel)).

Przewoźnik, który ma już paczkę, odmawia: odpowiedź to `400` z `result: false` i komunikatem przewoźnika. Zamówienia, dla którego etykieta nigdy nie została kupiona, nie można anulować tym wywołaniem.

**Weryfikacja:** odpowiedź to `result: true`, a publiczne śledzenie dla tego numeru zwraca `404`. Ponowienie z tym samym `Idempotency-Key` zwraca zapisaną odpowiedź; nowe żądanie anulowania tego samego zamówienia zwraca `400` `This order already cancelled`.

## 13. Przesłanie informacji o przesyłkach i zamknięcie dnia (tylko jeśli ta metoda tego wymaga)

Niektórzy przewoźnicy wymagają przekazania przesyłek z danego dnia (manifestu) przed odbiorem. Krok 8 zgłasza to dla każdego zamówienia w `needSubmitShippingInformation`. Identyfikatory tych zamówień należy zbierać w ciągu dnia i przesłać je po ostatniej etykiecie.

**REST:** `POST /api/v1/labelservice/submitShippingInformation` — [Podręcznik REST](/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[]`: jeden wiersz na zamówienie; zamówienia z `result: false` należy przesłać ponownie po usunięciu przyczyny podanej w `message`.
- `404` `No eligible orders found for shipping information submission`: żaden z identyfikatorów nie ma kupionej etykiety, która nadal wymaga przesłania.

**GraphQL:** `labelserviceSubmitShippingInformation` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceSubmitShippingInformation)).

Następnie należy zamknąć dzień. Wywołanie nie ma treści żądania i obejmuje wszystkie zamówienia etykiet wywołującego.

**REST:** `POST /api/v1/labelservice/endofday` — [Podręcznik REST](/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` z komunikatem `There are orders need to submit shipping information`: niektóre kupione etykiety nadal wymagają przesłania; należy je przesłać za pomocą `submitShippingInformation` i wywołać ponownie.

**GraphQL:** `labelserviceEndofday` ([Podręcznik GraphQL](/api/graphql/documentation#/labelservice/labelserviceEndofday)).

Ten krok należy pominąć, gdy żadne zamówienie z danego dnia nie zgłosiło `needSubmitShippingInformation: true`.

**Weryfikacja:** `submitShippingInformation` zgłasza `failure_count: 0`, a `endofday` odpowiada `200`. Należy najpierw uruchomić to na metodzie testowej.

## 14. Obsługa błędów

Błędy usługi etykiet zawierają `message`; `code` występuje tylko tam, gdzie wskazuje to tabela.

| Sytuacja | Status HTTP | Kod | Działanie integracji |
|---|---|---|---|
| Brak tokenu lub token wygasł albo dostęp do API nie jest włączony | `401` | — (`Unauthorized`) | Zalogować się ponownie; jeśli błąd się powtarza, poprosić firmę o włączenie dostępu do API |
| Brak `shipping_method` lub metoda niedostępna dla wywołującego | `400` | — | Ponownie pobrać listę metod (krok 5) i użyć `id` z tej listy |
| Metoda nie oferuje danego `package_type` | `400` | — | Użyć klucza z `package_type` z kroku 5 |
| Nieprawidłowy adres lub paczka albo przewoźnik nie zwraca stawki | `400` | — (komunikat przewoźnika) | Wyświetlić komunikat, poprawić dane i ponownie wykonać wycenę |
| `auto_deduplication` ma wartość `1`, a `ref` już istnieje | `400` | — (`exist_order_ids`) | Użyć istniejącego zamówienia z `exist_order_ids` zamiast tworzyć nowe |
| Ten sam `Idempotency-Key` z inną treścią | `409` | `IDEMPOTENCY_CONFLICT` | Dla innego żądania użyć nowego klucza |
| Ten sam `Idempotency-Key`, gdy pierwsze żądanie jest nadal przetwarzane | `409` | `IDEMPOTENCY_IN_PROGRESS` | Odczekać `Retry-After` sekund i ponowić z tym samym kluczem i treścią |
| Saldo klienta wraz z limitem kredytowym nie pokrywa etykiety | `400` | `INSUFFICIENT_BALANCE` | Doładować saldo na podstawie szczegółów `insufficient_balance` (`shortfall`, `add_funds_url`) i ponownie wywołać krok 8 |
| Etykieta kupiona, plik przewoźnika jeszcze niegotowy | `400` przy pierwszym zakupie, później `200` | `shipment_label_not_ready` | Czekać, dopóki `labelStatus` ma wartość `pending`; przy wartości `failed` ponownie wywołać krok 8 |
| Id zamówienia lub numer nie należy do wywołującego | `401` | — (`Not Auth`) | Sprawdzić id i `type`; użyć konta, które utworzyło zamówienie |
| Przewoźnik odrzucił anulowanie lub zamówienie jest już anulowane | `400` | — | Traktować etykietę jako nadaną (lub już anulowaną); nie ponawiać |
| Numer śledzenia nieznany lub anulowany | `404` | — | Przestać wyświetlać oś czasu dla tego numeru |
| `endofday` z nieprzesłanymi jeszcze przesyłkami | `400` | — | Wykonać `submitShippingInformation` dla tych zamówień, a następnie wywołać ponownie |

## Lista testów

Należy użyć adresu docelowego pod własną kontrolą i metody, którą można anulować:

- [ ] Lista metod nie jest pusta; zapisano jedno `id`.
- [ ] Wycena zwraca cenę dla tej metody i adresu docelowego.
- [ ] Submit zwraca `id` zamówienia i `rates[].rate_id`; ten sam `Idempotency-Key` nie tworzy drugiego zamówienia.
- [ ] `getShippingDetail` z wybranym `rate_id` zwraca `mainTrackingNumber`; drugie wywołanie nie pobiera opłaty ponownie.
- [ ] PDF etykiety otwiera się i pokazuje numer śledzenia przewoźnika.
- [ ] Publiczne śledzenie znajduje przesyłkę po tym numerze.
- [ ] Dociera `tracking.event` (oraz `order.created`, jeśli włączone); podpis v2 jest poprawnie weryfikowany.
- [ ] Przewoźnik przyjmuje testową przesyłkę transgraniczną z `items`.
- [ ] Anulowanie się powiodło **lub** potwierdzono, że tej metody nie można anulować po rezerwacji.
- [ ] Jeśli metoda wymaga zamknięcia dnia, przebieg testowy kończy się bez błędu.
