# Odbiór i dostawa (własna flota)

Ten przewodnik opisuje API dostaw lokalnych konta firmowego: zamówienia, które własni kierowcy firmy dostarczają do odbiorcy (`type` `D`) lub odbierają od nadawcy (`type` `P`). Jeden zestaw endpointów obsługuje wycenę, tworzenie, etykiety, śledzenie i anulowanie obu rodzajów punktów, a webhooki przekazują każdą zmianę do Państwa systemu. Przewodnik jest przeznaczony dla programistów systemów zarządzania zamówieniami, systemów ERP i sklepów internetowych, które przekazują zlecenia własnej flocie firmy.

## 1. Co można zbudować

Poniższe przykłady dotyczą jednej firmy: **Farine & Fils**, dostawcy dla piekarni z magazynem pod adresem 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6), który dostarcza zamówienia hurtowe na terenie całej wyspy Montreal i odbiera puste skrzynki na pieczywo zwracane przez klientów. Typowa dostawa to jeden stos skrzynek o wadze 12 kg i wymiarach 60 × 40 × 30 cm dla Café Lumière, 5400 Avenue du Parc, Montréal (H2V 4G7), w ramach zamówienia hurtowego `WHS-20931`. Typowy odbiór to jeden stos pustych skrzynek o wadze 4 kg z Épicerie Wellington, 4100 Rue Wellington, Verdun (H4G 1V5), z numerem referencyjnym `CRT-20931`.

- **Zamówienia hurtowe przekazywane z ERP do dyspozytorni.** Każde potwierdzone zamówienie hurtowe staje się zamówieniem dostawy z porannym oknem dostawy kawiarni, a ERP zapisuje zwrócony numer śledzenia przy pozycji zamówienia.
- **Odbiory zwracanych skrzynek.** Gdy klient zgłasza puste skrzynki, ERP tworzy zamówienie odbioru na adres klienta, a kierowca odbiera skrzynki podczas najbliższej trasy.
- **Druk etykiet w magazynie.** ERP pobiera plik PDF etykiety każdego zamówienia i drukuje go przy rampie załadunkowej, dzięki czemu każdy stos skrzynek ma kod kreskowy śledzenia.
- **Portal klienta ze statusem na bieżąco.** Każda kawiarnia widzi status swoich dostaw i odbiorów wraz z potwierdzeniem doręczenia, aktualizowany przez webhooki zamiast odpytywania.

## 2. Zakres tego przewodnika

Ten przewodnik dotyczy sytuacji, w których zamówienie przewożą własni kierowcy firmy: dostaw z magazynu i odbiorów z adresu klienta, tworzonych pojedynczo lub w partiach przez endpointy `/api/v1/client/...` i `/api/v1/orders/...`.

W nowych integracjach zalecanym pojedynczym punktem wejścia jest Uniorder (`/api/v1/uniorder/...`): oferuje te same dostawy własną flotą w jednym API, razem z etykietami przewoźników, na podstawie jednej wyceny. Przegląd zawiera przewodnik **Uniorder: jedno API dla każdej przesyłki**, a żądania krok po kroku — przewodnik **Wycena i zamówienie w jednym przepływie**. Endpointy opisane w tym przewodniku pozostają dostępne i niezmienione dla integracji, które z nich korzystają.

Przewodnik **Etykiety przewoźnika** dotyczy paczek wysyłanych przez zewnętrznego przewoźnika z etykietą kupioną za pośrednictwem platformy. Przewodnik **Usługi wysyłkowe** dotyczy zamówień, które konto klienta rezerwuje w ramach usług firmy, a przewodnik **Magazynowanie i wydanie** — towarów przechowywanych w magazynie i wysyłanych na żądanie; Uniorder nie obejmuje tych dwóch przypadków.

## 3. Przed rozpoczęciem

- **Konto.** Należy użyć konta firmowego (klienta) lub konta pracownika firmy z uprawnieniem do API. Tworzenie zamówień wymaga dodatkowo uprawnienia do składania zamówień; bez niego `POST /api/v1/client/orderCreate` zwraca `401`.
- **Obszar obsługi.** Adres dostawy lub odbioru musi znajdować się w aktywnym regionie firmy. Do testów należy używać adresów z obszaru obsługi, takich jak adresy w tym przewodniku.
- **Dane testowe.** Należy używać testowych numerów referencyjnych, takich jak `WHS-20931` i `CRT-20931`, a na koniec anulować zamówienia testowe (krok 12).
- **Tokeny.** Token dostępu należy pobierać na serwerze i tam go przechowywać. Nigdy nie wolno przesyłać go do przeglądarki ani aplikacji mobilnej.
- **Symbole zastępcze.** `YOUR_HOST` należy zastąpić hostem API danego środowiska, a `ACCESS_TOKEN` — tokenem z kroku 4.
- **Jednostki.** `weight_unit`: `1` g, `2` kg, `3` oz, `4` lb. `dimension_unit`: `1` mm, `2` cm, `3` m, `4` in. Wartość domyślna obu to `1`.

## 4. Uwierzytelnienie

Każde wywołanie w tym przewodniku, z wyjątkiem publicznego śledzenia, jest wykonywane w imieniu konta firmowego. Należy zalogować się raz z serwera, zapisać zwrócony token i wysyłać go w każdym żądaniu.

**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":"dispatch@farineetfils.example","password":"your_password"}'
```

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

- `access_token`: należy umieścić go w nagłówku każdego kolejnego żądania:

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL używa tego samego nagłówka w `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. Wycena dostawy lub odbioru (opcjonalnie)

Wycena pokazuje cenę punktu przed utworzeniem zamówienia, na przykład w celu wykazania opłaty za dostawę na fakturze hurtowej. Niczego nie tworzy, a utworzenie zamówienia nie wymaga wcześniejszej wyceny. `type` należy ustawić na `D` (dostawa) lub `P` (odbiór); `to_postcode` to kod pocztowy punktu.

**REST:** `POST /api/v1/orders/rate` — [Podręcznik REST](/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`: cena punktu bez podatku. Pusta cena oznacza, że kod pocztowy nie znajduje się w aktywnym regionie lub cennik nie zawiera dla niego wiersza.
- `price_details.tax_details`: podatki, którymi zostanie obciążone zamówienie; należy wykazać je w pozycji faktury.
- `currency`: waluta wszystkich kwot w odpowiedzi.

Aby wycenić odbiór skrzynek, należy wysłać to samo żądanie z `"type": "P"`, `"to_postcode": "H4G1V5"` oraz wagą i wymiarami stosu skrzynek.

**GraphQL:** `ordersRate` ([Podręcznik GraphQL](/api/graphql/documentation#/orders/ordersRate)). Wynik jest skalarem JSON i nie przyjmuje zestawu selekcji.

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

**Weryfikacja:** `result` ma wartość `true`, a `shipping_price` jest liczbą zarówno dla `type` `D`, jak i dla `type` `P`. Utworzenie zamówienia nie zależy od tego kroku.

## 6. Utworzenie zamówienia dostawy

Każde potwierdzone zamówienie hurtowe staje się jednym zamówieniem dostawy. ERP zapisuje zwrócone `id` i `tracking_number` przy swojej pozycji zamówienia; każde późniejsze wywołanie używa jednego z nich.

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

Należy wysyłać nagłówek `Idempotency-Key`, unikalny dla każdego zamówienia hurtowego, aby ponowienie po przekroczeniu limitu czasu nie mogło utworzyć drugiego zamówienia.

```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": "" }
  ]
}
```

| Pole | Znaczenie |
|---|---|
| `type` | `D` dostawa lub `P` odbiór |
| `need_pick_up` | `0` — towar jest już w magazynie. `1` — kierowca musi odebrać paczkę |
| `ref` | Zewnętrzny numer referencyjny używany do wyszukiwania i uzgadniania |
| `name` / adres | Dostawa: odbiorca. Odbiór: punkt odbioru |
| `schedule_date`, `time_window_start`, `time_window_end` | Data dostawy (`Y-m-d`) i okno czasowe, w którym punkt musi zostać obsłużony (`Y-m-d H:i:s`) |
| `packagesDetail` | Jeden wpis na paczkę; `ref` identyfikuje paczkę w Państwa systemie |
| `auto_deduplication` | `1` odrzuca drugą paczkę z tym samym `ref` paczki |

W odpowiedzi:

- `id`: identyfikator zamówienia; należy go zapisać na potrzeby szczegółów zamówienia i wywołania anulowania.
- `tracking_number`: jeden numer śledzenia na paczkę; służy do drukowania i śledzenia.
- `warning`: występuje, gdy zamówienie zostało utworzone z komunikatem, na przykład dla adresu poza obszarem dostawy, który firma zachowuje lub wstrzymuje. Zachowane zamówienie spoza obszaru może zwrócić `shipping_price: null`.

**GraphQL:** `clientOrderCreate` ([Podręcznik GraphQL](/api/graphql/documentation#/client/clientOrderCreate)). Wynik jest skalarem JSON o tej samej treści co odpowiedź REST.

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

**Weryfikacja:** należy ponownie wysłać tę samą treść z tym samym `Idempotency-Key`. Odpowiedź zawiera to samo `id` i nie zostaje utworzone drugie zamówienie.

## 7. Utworzenie zamówienia odbioru

Zamówienie odbioru kieruje kierowcę pod adres w celu odebrania towaru; w tym przypadku pustych skrzynek z Épicerie Wellington. Korzysta z tego samego endpointu co dostawa: adres jest punktem odbioru, `type` ma wartość `P`, a `need_pick_up` — `1`.

**REST:** `POST /api/v1/client/orderCreate` — [Podręcznik REST](/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` i `tracking_number`: należy je zapisać przy zwrocie skrzynek, tak jak w przypadku dostawy.
- `pickup_instruction`: wyświetlana kierowcy w punkcie odbioru; jej odpowiednikiem w dostawie jest `delivery_instruction`.

**Weryfikacja:** szczegóły zamówienia (krok 8) pokazują dla tego zamówienia `type` `P` i `need_pickup` `1`.

## 8. Odczyt zamówienia

Szczegóły zamówienia potwierdzają zapisane dane i zwracają bieżący status; endpoint listy pozwala systemowi ERP uzgodnić własne rekordy z platformą.

**REST:** `GET /api/v1/orders/{orderId}` — [Podręcznik REST](/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`: status zamówienia; `2` oznacza Nowe, `12` — Anulowane.
- `order.type` i `order.need_pickup`: potwierdzają, że punkt został zapisany jako dostawa lub odbiór.
- `tracking_numbers`: numery śledzenia paczek zamówienia.

**REST:** `GET /api/v1/orders/list` — [Podręcznik REST](/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"
```

Lista zwraca wszystkie zamówienia konta, od najnowszych, każde z paczkami i pozycjami. Aby stronicować, należy przekazać razem `page` i `per_page` (`per_page` maksymalnie 1000); bez nich zwracanych jest 1000 najnowszych zamówień z flagą `truncated`.

**GraphQL:** `orders` ([Podręcznik GraphQL](/api/graphql/documentation#/orders/orders)) dla jednego zamówienia i `ordersList` ([Podręcznik GraphQL](/api/graphql/documentation#/orders/ordersList)) dla listy. Oba zwracają skalar JSON.

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

**Weryfikacja:** zamówienie należy do uwierzytelnionego konta, `ref` zgadza się z wartością wysłaną przy tworzeniu, a `tracking_numbers` zgadza się z odpowiedzią na utworzenie.

## 9. Druk lokalnej etykiety

Etykieta zawiera kod kreskowy śledzenia, który kierowca skanuje w magazynie i w punkcie. Należy wydrukować jedną etykietę na paczkę i przymocować ją do stosu skrzynek.

**REST:** `POST /api/v1/shipping/getShippingLabel` — [Podręcznik REST](/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`: sposób interpretacji `id`: `TRACKING_NUMBER` (domyślnie), `ORDER_ID` lub `REF`.
- `base64`: `0` (domyślnie) przesyła strumieniowo plik PDF. `1` sprawia, że cała treść odpowiedzi jest ciągiem JSON najwyższego poziomu zawierającym PDF w base64, a nie obiektem z polem `pdf_data`. Aby otrzymać etykietę w zwykłym obiekcie JSON, należy zamiast tego wywołać `POST /api/v2/shipping/getShippingLabel` — [Podręcznik REST](/api/documentation#/paths/v2-shipping-getShippingLabel/post).
- `packages`: opcjonalnie; liczba etykiet do wydrukowania. Wartość różna od liczby paczek zamówienia aktualizuje zamówienie.
- `hide_sender_address` / `hide_receiver_address`: `1` pozostawia dany adres pusty na etykiecie.

**GraphQL:** `shippingGetShippingLabel` ([Podręcznik GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabel)). `shippingGetShippingLabelV2` ([Podręcznik GraphQL](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)) zawsze zwraca JSON (`pdf_data`).

**Weryfikacja:** zdekodowany PDF otwiera się. Etykieta dostawy pokazuje adres Café Lumière; etykieta odbioru pokazuje adres Épicerie Wellington. Ukryty adres jest na etykiecie pusty.

## 10. Śledzenie zamówienia

Publiczne śledzenie zwraca oś czasu zdarzeń paczki. Nie wymaga tokenu dostępu, więc portal klienta może ją wyświetlać bezpośrednio; wraz z nią przekazywane jest potwierdzenie doręczenia lub odbioru.

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

Ten sam adres URL przyjmuje również Państwa `ref`, jeśli został zapisany jako numer zewnętrzny.

Logikę należy opierać na `tracking_event_status_id`, a nie na `description`; ten ciąg zależy od `Accept-Language`.

| `tracking_event_status_id` | Strona | Znaczenie |
|---|---|---|
| `100` | obie | Zamówienie przyjęte |
| `300` / `301` | dostawa | W placówce |
| `450` | dostawa | W doręczeniu |
| `500` | dostawa | Doręczone |
| `501` | dostawa | Nieudane doręczenie, wymaga nowego planu |
| `460` | odbiór | W drodze po odbiór |
| `510` | odbiór | Odebrane |
| `512` | odbiór | Nieudany odbiór, ponowić później |
| `513` | odbiór | Problem z odbiorem |

- `data`: od najnowszych; pierwszy wiersz to bieżący stan.
- `deliveried`: `true` po `500`.
- `proofs[]`: przy `500` lub `510` może zawierać `type` `1` (podpis) lub `2` (zdjęcie), z `file_id` i `signed_url`. Zdjęcie przesłane po tym zdarzeniu nie znajduje się w tej odpowiedzi; należy zasubskrybować `pod.files_updated` (krok 11).

**GraphQL:** `trackingPublic` ([Podręcznik GraphQL](/api/graphql/documentation#/tracking/trackingPublic)). Wynik jest typowany i wymaga zestawu selekcji.

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

**Weryfikacja:** zaraz po utworzeniu najnowsze zdarzenie to `100`, a `deliveried` ma wartość `false`. Nieznany numer zwraca `result: false` z `404`; należy wyświetlić stan „nie znaleziono” i nie generować zdarzeń śledzenia.

## 11. Odbieranie webhooków

Webhooki przesyłają każdą zmianę na Państwa serwer, dzięki czemu ERP i portal klienta pozostają aktualne bez odpytywania. Należy zarejestrować adresy URL wywołań zwrotnych potrzebne w tym przepływie:

| Ustawienie | Zdarzenie | Zastosowanie |
|---|---|---|
| `order_create_webhook_url` | `order.created` | Zapisanie `id` i `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | Status widoczny dla klienta |
| `tracking_event_webhook_url` | `tracking.event` | Oś czasu odbioru lub dostawy |
| `pod_files_webhook_url` | `pod.files_updated` | Zdjęcie lub podpis po odbiorze lub dostawie |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | Wysłane anulowanie zostało odrzucone |
| `order_create_async_postback_url` | `order.create_async` | Wynik asynchronicznej partii (krok 13) |

**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 '{
    "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`: ustawienia zmienione przez to wywołanie.
- `settings.webhook_sign_secret`: zwracany w postaci zamaskowanej; pełną wartość należy przechowywać wyłącznie na serwerze.

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

Po stronie odbiorcy należy zweryfikować podpis **v2** na surowej treści: `HMAC_SHA256(timestamp + "." + raw_body, secret)` porównany z `X-Webhook-Signature-V2`, gdzie znacznikiem czasu jest `X-Webhook-Timestamp`. Duplikaty należy eliminować na podstawie `X-Webhook-Event-Id`. Należy odpowiedzieć kodem **2xx w ciągu 3 sekund** i przetworzyć zdarzenie później.

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

**Weryfikacja:** należy utworzyć jedno zamówienie testowe i odebrać `order.created` z tym samym `id` i `tracking_number`. Odbiorca odrzuca nieprawidłowy podpis kodem `401`, a drugie dostarczenie tego samego `X-Webhook-Event-Id` nie jest przetwarzane dwukrotnie.

## 12. Anulowanie zamówienia

Zamówienie należy anulować, gdy zamówienie hurtowe zostało wycofane lub odbiór skrzynek nie jest już potrzebny. Wywołanie jest idempotentne: anulowanie zamówienia, które zostało już anulowane, ponownie kończy się powodzeniem.

**REST:** `POST /api/v1/orders/cancel` — [Podręcznik REST](/api/documentation#/paths/v1-orders-cancel/post) — należy wysłać dokładnie jedno z pól `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`, gdy zamówienie zostało anulowane.
- `already_cancelled`: `true`, gdy zamówienie zostało anulowane przed tym wywołaniem; należy traktować to jako powodzenie.
- `code`: występuje, gdy anulowanie zostało odrzucone; zob. krok 14.

**GraphQL:** `ordersCancel` ([Podręcznik GraphQL](/api/graphql/documentation#/orders/ordersCancel)). Wynik jest typowany i wymaga zestawu selekcji.

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

**Weryfikacja:** szczegóły zamówienia pokazują `orders_status_id` `12`, a to samo anulowanie zwraca `already_cancelled: true`. Gdy anulowanie zostanie odrzucone, zdarzenie `order.cancel_failed` jest wysyłane na `order_cancel_failed_webhook_url`.

## 13. Tworzenie zamówień w partiach (opcjonalnie)

ERP może wysłać zamówienia hurtowe i odbiory skrzynek z danego dnia w jednym żądaniu. Każdy wiersz przyjmuje te same pola co w krokach 6 i 7 i może mieć `type` `D` lub `P`.

**REST:** `POST /api/v1/client/batchOrderCreate` — [Podręcznik REST](/api/documentation#/paths/v1-client-batchOrderCreate/post) — odpowiada po przetworzeniu wszystkich wierszy.

```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": "" }] }
]
```

- Każdy wiersz ma własne `result`; należy dopasować go do pozycji zamówienia na podstawie `ref`. Odrzucony wiersz zawiera `message` i `skipped_ref` oraz może zawierać `code` (na przykład `INSUFFICIENT_BALANCE` lub `OUT_OF_DELIVERY_AREA`).
- `per_order_transaction`: `1` zatwierdza każdy wiersz osobno, więc jeden nieudany wiersz nie może wycofać pozostałych.
- Partie liczące ponad 100 zamówień otrzymują nagłówek odpowiedzi `X-Batch-Size-Warning`; należy je wysyłać do endpointu asynchronicznego.

**REST:** `POST /api/v1/client/batchOrderCreateAsync` — [Podręcznik REST](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — przyjmuje tę samą treść i natychmiast zwraca identyfikator zadania:

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

Należy odpytywać `GET /api/v1/client/async/{id}` — [Podręcznik REST](/api/documentation#/paths/v1-client-async-id/get) — z `asyncId` lub odebrać `order.create_async` na `order_create_async_postback_url`. Wynik zadania jest tą samą listą wierszy co w endpoincie synchronicznym.

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

**GraphQL:** `clientBatchOrderCreate` ([Podręcznik GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreate)), `clientBatchOrderCreateAsync` ([Podręcznik GraphQL](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)) i `clientAsync` ([Podręcznik GraphQL](/api/graphql/documentation#/client/clientAsync)).

**Weryfikacja:** partia dwóch wierszy zwraca dwa wyniki, każdy z własnym `ref`. Zadanie asynchroniczne po wykonaniu zwraca te same wiersze.

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

| Sytuacja | Status HTTP | Kod | Działanie integracji |
|---|---|---|---|
| Brak wymaganego pola lub pole ma nieprawidłowy format (tworzenie) | 400 | `VALIDATION_FAILED` | Poprawić pole wskazane w `message` i ponownie wysłać żądanie. |
| Saldo konta nie pokrywa zamówienia | 400 | `INSUFFICIENT_BALANCE` | Odczytać `insufficient_balance` (wymagane, dostępne, brakujące); doładować saldo, a następnie ponowić. Zamówienie nie zostało utworzone. |
| Adres znajduje się poza obszarem obsługi, a firma usuwa takie zamówienia | 400 | `OUT_OF_DELIVERY_AREA` | Podać adres w obszarze obsługi. Zamówienie nie zostało utworzone. |
| `ref` paczki lub zewnętrzny numer śledzenia już istnieje (przy włączonej deduplikacji) | 200 (`result` `false`) lub 409 przy `strict_duplicate_check` `1` | `DUPLICATE_TRACKING_NUMBER` | Odczytać `exist_package_ref` i powiązać istniejące zamówienie zamiast tworzyć nowe. |
| `Idempotency-Key` użyty ponownie z inną treścią | 409 | `IDEMPOTENCY_CONFLICT` | Dla innego żądania użyć nowego klucza. |
| Żądanie z tym samym `Idempotency-Key` jest nadal przetwarzane | 409 | `IDEMPOTENCY_IN_PROGRESS` | Odczekać, a następnie ponowić z tym samym kluczem. |
| Anulowanie bez identyfikatora zamówienia | 400 | `MISSING_IDENTIFIER` | Wysłać jedno z pól `order_id`, `tracking_number`, `external_tracking_number`. |
| Anulowanie zamówienia, które nie istnieje | 400 | `ORDER_NOT_FOUND` | Sprawdzić zapisane `id` lub numer śledzenia. |
| Numer pasuje do więcej niż jednego aktywnego zamówienia | 409 | `MULTIPLE_ORDERS_MATCHED` | Anulować według `order_id`, używając jednej z wartości `matched_order_ids`. |
| Zamówienie należy do innego konta | 401 | `ORDER_CANCEL_UNAUTHORIZED` | Anulować z konta, które utworzyło zamówienie. |
| Status zamówienia nie pozwala już na anulowanie | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | Pozostawić zamówienie bez zmian; zwrot obsłużyć osobno. |
| Zamówienie jest u zewnętrznego przewoźnika, który nie może go anulować | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` lub `THIRD_PARTY_CANCEL_FAILED` | Zamówienie pozostaje bez zmian; należy skontaktować się z firmą. |
| Brak tokenu lub token wygasł albo konto nie może składać zamówień | 401 | — | Zalogować się ponownie; sprawdzić uprawnienia konta. |

## Lista testów

Należy używać testowych numerów referencyjnych, takich jak `WHS-20931` i `CRT-20931`:

- [ ] (Opcjonalnie) Wycena zwraca cenę dla kodu pocztowego z obszaru obsługi z `type` `D`.
- [ ] (Opcjonalnie) Wycena zwraca cenę dla kodu pocztowego z obszaru obsługi z `type` `P`.
- [ ] Utworzenie dostawy zwraca `id` + `tracking_number`; ten sam `Idempotency-Key` nie tworzy drugiego zamówienia.
- [ ] Utworzenie odbioru zwraca `id` + `tracking_number`; szczegóły zamówienia pokazują `type` `P` i `need_pickup` `1`.
- [ ] Szczegóły zamówienia i lista pokazują oba zamówienia na tym koncie.
- [ ] PDF lokalnej etykiety otwiera się i pokazuje adres odbiorcy lub adres odbioru.
- [ ] Publiczne śledzenie zwraca oś czasu bez tokenu; najnowsze zdarzenie to `100`.
- [ ] Przychodzi `order.created`, a jego podpis v2 zostaje pomyślnie zweryfikowany.
- [ ] Anulowanie zwraca `result: true`, a drugie anulowanie zwraca `already_cancelled: true`.
- [ ] Partia z jedną dostawą i jednym odbiorem zwraca dwa wyniki, każdy z własnym `ref`.
