# Webhooky – změny stavu zásilek

Webhook je adresa URL ve vašem systému, na kterou Zaslat posílá HTTP požadavek pokaždé, když se
změní stav některé z vašich zásilek. Nemusíte se tak na stav zásilek opakovaně dotazovat přes
`shipments/tracking`; informace přijde sama krátce poté, co zpracujeme trackingová data dopravce.

Webhooky spravujete přes [API v2](/api/v2/content) v sekci
[Webhooks](/api-v2/webhooks) API Reference. Všechny požadavky posíláte s hlavičkou `x-apikey`.

## Registrace webhooku

Endpoint `webhook/create` zaregistruje adresu, na kterou chcete události dostávat:

```bash
curl -X POST https://test.zaslat.cz/api/v2/public/webhook/create \
  -H "x-apikey: VAS_API_KLIC" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.example.com/webhooks/zaslat"}'
```

Odpověď obsahuje identifikátor webhooku a **podpisový klíč** v poli `secret`:

```json
{
  "id": 42,
  "url": "https://www.example.com/webhooks/zaslat",
  "event": "shipment.status_changed",
  "active": true,
  "createdAt": "2026-10-07T14:03:11+02:00",
  "failureCount": 0,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "disabledAt": null,
  "secret": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
```

:::warning[Podpisový klíč se vrací jen jednou]
Pole `secret` je součástí pouze odpovědi na `webhook/create`. Uložte si jej; v detailu ani v seznamu
webhooků se už nezobrazí. Pokud klíč ztratíte, webhook smažte a zaregistrujte znovu.
Vlastní klíč (16 až 128 znaků) můžete poslat v poli `secret` požadavku.
:::

Pravidla pro adresu:

- Pouze `https://`, bez přihlašovacích údajů v URL.
- Doménové jméno se musí přeložit na veřejnou IP adresu. Privátní, lokální a rezervované adresy
  (například `10.x`, `192.168.x`, `127.0.0.1`, `169.254.x`) jsou odmítnuty se stavem `400`.
- Na jeden účet lze zaregistrovat nejvýše 10 webhooků. Stejnou adresu lze pro jednu událost
  zaregistrovat jen jednou; opakovaný pokus vrátí `409`.

## Správa webhooků

| Endpoint | Popis |
| --- | --- |
| `GET webhook/list` | Seznam všech webhooků účtu bez podpisových klíčů. |
| `GET webhook/detail/{id}` | Detail jednoho webhooku. |
| `PATCH webhook/update/{id}` | Změna adresy (`url`) nebo pozastavení a obnovení (`active`). Posílejte jen pole, která měníte. |
| `DELETE webhook/delete/{id}` | Trvalé smazání webhooku; události čekající na doručení se zahodí. |
| `GET webhook/deliveries/{id}` | Posledních 50 pokusů o doručení se stavovým kódem a popisem chyby. |
| `POST webhook/test/{id}` | Odeslání testovací události (viz níže). |

Pozastavený webhook (`"active": false`) si zachová nastavení, ale události na něj neposíláme a po
obnovení je zpětně nedoručujeme.

## Událost `shipment.status_changed`

Událost vzniká při změně souhrnného stavu zásilky v Zaslat (například `INTRANSIT` → `ONDELIVERY`
→ `DELIVERED`), nikoli při každém záznamu trackingu dopravce. Slovník stavů vychází ze
sledování zásilek (`CREATED`, `EXPORTED`, `ONPICKUP`, `INTRANSIT`, `ONDELIVERY`, `DELIVERED`,
`CANCELLED`; význam popisuje stránka [Sledování zásilek a stavy](/api/v1/tracking)) a navíc
rozlišuje dva stavy:

- `PARCELSHOP` – zásilka je uložena na výdejním místě nebo v boxu a čeká na vyzvednutí příjemcem.
- `DONE_AUTO` – zásilku jsme uzavřeli automaticky, protože dopravce 90 dní neposlal žádný nový stav.

Na vaši adresu přijde požadavek `POST` s tělem `application/json`:

```json
{
  "event": "shipment.status_changed",
  "eventId": "7f2a0b9c-4d3e-4f61-9a8b-2c1d0e5f6a7b",
  "version": 1,
  "occurredAt": "2026-10-07T14:03:11+02:00",
  "test": false,
  "data": {
    "id": 4815162,
    "trackingNumber": "IZ3F9A2C71B4",
    "carrierNumber": "13606100012345",
    "referenceNumber": "ORDER-2026-1042",
    "carrier": { "code": "dpd", "name": "DPD" },
    "status": "DELIVERED",
    "trackingUrl": "https://www.zaslat.cz/cs/sledovani-zasilek/IZ3F9A2C71B4",
    "lastTrackingEvent": {
      "date": "2026-10-07 13:58:00",
      "status": "DELIVERED",
      "name": "Zásilka byla doručena příjemci",
      "location": "Praha 5"
    }
  }
}
```

| Pole | Význam |
| --- | --- |
| `event` | Typ události. |
| `eventId` | Jedinečný identifikátor události. Opakované doručení téže události má stejné `eventId`; použijte jej k odfiltrování duplicit. |
| `version` | Verze schématu obálky. Zvýší se jen při nekompatibilní změně pole `data`. |
| `occurredAt` | Čas změny stavu (ISO 8601 včetně časového pásma). |
| `test` | `true` u událostí z testovacího endpointu, `false` u skutečných zásilek. |
| `data.id`, `data.trackingNumber` | Identifikátory zásilky v Zaslat. |
| `data.carrierNumber` | Číslo balíku u dopravce; `null`, dokud zásilka není exportována k dopravci. |
| `data.referenceNumber` | Vaše referenční číslo zadané při vytvoření zásilky. |
| `data.carrier` | Kód a název dopravce; `null`, pokud dopravce ještě není přiřazen. |
| `data.status` | Nový stav zásilky. |
| `data.trackingUrl` | Veřejná stránka sledování zásilky. |
| `data.lastTrackingEvent` | Poslední záznam trackingu od dopravce; `null`, pokud zatím žádný není. |

Událost obsahuje pouze identifikátory a nový stav. Podrobnosti o zásilce včetně adres si v případě
potřeby načtěte přes API.

## Ověření podpisu

Každý požadavek nese hlavičku `X-Zaslat-Signature` ve tvaru `sha256=<hex>`, kde `<hex>` je
HMAC-SHA256 nezměněného těla požadavku vypočtený podpisovým klíčem webhooku. Na své straně podpis
spočítejte z přesně přijatých bajtů těla a porovnejte jej s hlavičkou porovnáním v konstantním
čase. Požadavek s neplatným podpisem odmítněte.

Hlavičky `X-Zaslat-Event` a `X-Zaslat-Event-Id` opakují hodnoty `event` a `eventId` z těla
a hodí se pro směrování požadavku ještě před jeho zpracováním. API klíč se na webhook nikdy
neposílá.

```php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $_SERVER['HTTP_X_ZASLAT_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
```

```javascript
const crypto = require("crypto");

const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const received = req.headers["x-zaslat-signature"] ?? "";

if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
  res.status(401).end();
  return;
}
```

:::tip
Podpis počítejte z původního těla požadavku, nikoli z JSON, který jste znovu serializovali.
Změna pořadí klíčů nebo mezer podpis zneplatní.
:::

## Doručování a opakování

Váš endpoint musí odpovědět stavem `2xx` do 10 sekund. Přesměrování nenásledujeme a počítá se
jako selhání.

| Odpověď | Co se stane |
| --- | --- |
| `2xx` | Událost je doručena. |
| `3xx`, `5xx`, `408`, `429`, vypršení času nebo chyba spojení | Doručení opakujeme s rostoucím odstupem 1, 5, 15 a 30 minut, poté 1, 2, 4, 8, 12 a 24 hodin od předchozího pokusu (celkem 10 opakování), vždy se stejným `eventId`. |
| Ostatní `4xx` | Událost považujeme za trvale odmítnutou a dále ji neopakujeme. |

Pokud všechny pokusy o doručení selhávají nepřetržitě 24 hodin, webhook automaticky pozastavíme:
pole `active` se změní na `false`, čas pozastavení najdete v poli `disabledAt` a na notifikační
e-mail účtu odešleme upozornění. Události vzniklé během pozastavení se neposílají ani zpětně
nedoručují. Po opravě endpointu webhook obnovíte požadavkem `PATCH webhook/update/{id}` s tělem
`{"active": true}`; tím se vynuluje i čítač `failureCount`.

Stav doručování sledujte v polích `failureCount`, `lastSuccessAt` a `lastFailureAt` detailu
webhooku a v endpointu `webhook/deliveries/{id}`, který vrací posledních 50 pokusů včetně
stavového kódu a popisu chyby. Záznamy starší než 30 dní se mažou.

## Testovací událost

Endpoint `POST webhook/test/{id}` ihned odešle na adresu webhooku jednu událost
`shipment.status_changed` se smyšlenými daty zásilky a `"test": true`, podepsanou stejně jako
skutečná událost, a vrátí, jak váš endpoint odpověděl:

```bash
curl -X POST https://test.zaslat.cz/api/v2/public/webhook/test/42 \
  -H "x-apikey: VAS_API_KLIC"
```

```json
{
  "eventId": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "delivered": true,
  "statusCode": 200,
  "error": null
}
```

Testovací událost se odešle i na pozastavený webhook a nikdy neovlivní čítače selhání ani
automatické pozastavení; v přehledu doručení je označena `"test": true`. Neúspěšný test vrátí
také stav `200`, výsledek poznáte z polí `delivered`, `statusCode` a `error`. Endpoint je omezen
na 10 požadavků za 60 sekund na účet; po překročení limitu dostanete `429`.
