# Webhooky: příjem událostí

> Smlouva pro příjemce: jaké události koncový bod odebírá, jak se ověří podpis, jak se opakuje doručení a proč musí být příjem idempotentní.

- Sekce: Pro vývojáře
- Stránka: https://shopzi.cz/dokumentace/vyvojari/webhooky
- Ověřeno: 2026-10-20

---

Webhook je opačný směr než API: ne váš program se ptá nás, ale obchod zavolá vám. Koncový bod se
registruje v **Nastavení → API a MCP** v části **Webhooky**, přijímáme jen `https`
a adresy do vnitřní sítě odmítáme.

Tenhle článek popisuje **smlouvu příjemce**, tedy co má vaše strana umět, aby zprávu přijala
správně a bezpečně.

> **Hromadné změny událost neposílají** Události vypouštějí jednotlivé změny: jedna objednávka, jeden produkt, jeden doklad. Hromadné úkony to schválně nedělají, a to import produktů, hromadná změna stavu i hromadné odstranění: jedna zpráva na řádek by u tisícové dávky znamenala tisíce webhooků během několika vteřin, váš příjemce by narazil na vlastní strop a koncový bod by se po deseti selháních sám vypnul. Po hromadné změně si katalog načtěte z REST API. Docházející zásoba je ze stejného důvodu jedna denní zpráva se seznamem, ne zpráva na položku, a synchronizace feedů dodavatelů posílá místo zpráv na produkt souhrny za běh (`stock.changed`, `catalog_item.gone`).

## Registrace koncového bodu [#registrace-koncoveho-bodu]

Koncový bod zakládá vlastník nebo manažer obchodu v **Nastavení → API a MCP**
v části **Webhooky**, v kartě **Odchozí webhooky**:

1. Klikněte na **Přidat koncový bod**.
2. Do pole **Adresa koncového bodu** vložte adresu, na kterou mají zprávy chodit, třeba URL
   z modulu Custom webhook v Make.com nebo Production URL uzlu Webhook v n8n. Adresa musí začínat
   `https://` a nesmí vést do vnitřní sítě.
3. Do pole **Popis (nepovinné)** můžete napsat, k čemu koncový bod slouží, ať ho v seznamu poznáte.
4. Zaškrtněte události ve skupinách **Objednávky**, **Katalog a sklad**, **Zákazníci a doklady**
   a **Feedy dodavatelů**.
   Předvybraná je `order.paid`; aspoň jedna událost musí zůstat zaškrtnutá.
5. Klikněte na **Přidat koncový bod**. Hned potom se ukáže **Podpisové tajemství** s prefixem
   `whsec_` a tlačítkem **Kopírovat**. Vložte ho k příjemci;
   znovu ho zobrazí jen tlačítko **Zobrazit tajemství** v administraci.

U hotového koncového bodu jsou tlačítka **Odeslat test**, **Zobrazit tajemství**, **Nové tajemství**,
**Pozastavit** (u pozastaveného **Zapnout**), **Log doručení** a **Smazat**. Smazání odstraní
i log doručení a odesílání přestane okamžitě.

## Webhooky běží pod REST API [#webhooky-bezi-pod-rest-api]

Zpráva webhooku říká „stalo se tohle“ a podrobnosti si příjemce dočte klíčem z REST API. Webhooky
se proto řídí tím, jestli je **REST API obchodu otevřené**, podle stejných pravidel jako volání
klíčem: plocha REST API je obchodu zapnutá, obchod má nárok na externí přístup (aktivní placený
tarif, jednorázovou licenci, nebo zkušební období s uloženou kartou) a SHOPZI externí přístup
obchodu nepozastavilo ani REST API dočasně nevypnulo všem obchodům. Pravidla podrobně popisuje článek
[Autentizace a oprávnění](/dokumentace/vyvojari/autentizace-a-opravneni).

Když je REST API zavřené:

* **Nové události se neodesílají.** Co se v obchodě mezitím stane, nepřijde ani po znovuotevření;
  od té chvíle chodí jen nové události. Stav, který jste během pauzy nedostali, si dočtěte z REST
  API.
* **Zprávy čekající ve frontě nebo na další pokus se neodešlou.** V **Logu doručení** skončí ve
  stavu **Vzdáno** s důvodem „Webhooky jsou pozastavené, externí API je vypnuté nebo tarif API
  nezahrnuje“. Po znovuotevření je jde poslat odkazem **Odeslat znovu**.
* **Testovací zprávu, opakované odeslání ani nový koncový bod** administrace nedovolí a karta
  webhooků ukáže, že jsou pozastavené a proč, i s odkazem **Přejít na přepínače**.

## Události [#udalosti]

Koncový bod odebírá jen ty typy, které mu zaškrtnete. Celkem jich
je 23:

| Typ události | Kdy se posílá |
| --- | --- |
| `order.created` | Objednávka vytvořena |
| `order.paid` | Objednávka zaplacena |
| `order.fulfilled` | Objednávka vyřízena |
| `order.shipped` | Objednávka odeslána |
| `order.delivered` | Objednávka doručena |
| `order.cancelled` | Objednávka zrušena |
| `order.refunded` | Objednávka refundována |
| `product.created` | Produkt vytvořen |
| `product.updated` | Produkt upraven |
| `product.deleted` | Produkt smazán |
| `customer.created` | Nový zákazník |
| `stock.low` | Docházející zásoba |
| `document.issued` | Vystaven doklad |
| `return.requested` | Žádost o vrácení / reklamace |
| `supplier_order.sent` | Objednávka odeslána dodavateli (dropship) |
| `supplier_order.confirmed` | Dodavatel objednávku potvrdil |
| `supplier_order.shipped` | Dodavatel odeslal zákazníkovi |
| `supplier_order.rejected` | Dodavatel nemůže dodat |
| `supplier_sync.completed` | Synchronizace feedu něco změnila |
| `supplier_sync.failed` | Synchronizace feedu selhala |
| `supplier_approval.pending` | Nové položky ke schválení z feedu |
| `catalog_item.gone` | Dodavatel vyřadil zboží z feedu |
| `stock.changed` | Změna skladu z feedu (souhrn za běh) |

## Tvar zprávy [#tvar-zpravy]

Každá zpráva je `POST` s tělem v JSON (`Content-Type: application/json; charset=utf-8`) a obálka
má vždy tytéž čtyři klíče:

```json
{
  "id": "7b1e4c2a-3f5d-4e8b-9c61-2d0a5f7e9b13",
  "type": "order.paid",
  "created_at": "2026-09-01T08:14:02.118Z",
  "data": {}
}
```

* `id` je identifikátor zprávy, tentýž jako v hlavičce `webhook-id`.
  Nemění se při opakování doručení ani při ručním odeslání znovu.
* `type` je typ události z tabulky výš a podle něj se rozhodujte.
* `created_at` je čas vzniku události, ISO 8601 v UTC s milisekundami.
* `data` nese údaje události; podrobnosti si dočtěte z REST API.

Vedle podpisových hlaviček nese zpráva i hlavičku `shopzi-event` s typem události, podle které jde
v Make nebo v n8n směrovat bez čtení těla. Podepsaná není, takže rozhoduje vždycky `type`
v podepsaném těle. Hlavička `User-Agent` má hodnotu `ShopziWebhooks/1.0 (+https://shopzi.cz)`.

## Co nese pole data [#co-nese-pole-data]

Obsah `data` je schválně hubený: identifikátory, stav a peníze, ne celý záznam. Celý záznam má
stabilní tvar v REST API, kam si pro něj sáhnete klíčem. Částka `total_minor` je celé číslo
v haléřích, stejně jako v REST API.

| Událost                                                                                                | Klíč v `data`        | Pole                                                                                                                                                                           |
| ------------------------------------------------------------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `order.*` (všech sedm)                                                                                 | `order`              | `id`, `order_number`, `status`, `payment_status`, `total_minor`, `currency_code`, `customer_id`, `is_quotation`, `placed_at`, `created_at`                                     |
| `product.created`, `product.updated`                                                                   | `product`            | `id`, `title`, `slug`, `status`, `product_type`, `created_at`, `updated_at`                                                                                                    |
| `product.deleted`                                                                                      | `product`            | `id`, `title`, `slug` (záznam už neexistuje)                                                                                                                                   |
| `customer.created`                                                                                     | `customer`           | `id`, `email`, `first_name`, `last_name`, `is_business`, `company_name`, `newsletter_subscribed`, `created_at`                                                                 |
| `document.issued`                                                                                      | `document`           | `id`, `doc_type`, `doc_number`, `order_id`, `customer_id`, `duzp`, `created_at`                                                                                                |
| `return.requested`                                                                                     | `return`             | `id`, `return_number`, `order_id`, `customer_id`, `regime`, `status`, `created_at`                                                                                             |
| `stock.low`                                                                                            | (bez obalu)          | `count` a `items`, v každé položce `variant_id`, `sku`, `title`, `available`, `threshold`                                                                                      |
| `supplier_order.sent`, `supplier_order.confirmed`, `supplier_order.shipped`, `supplier_order.rejected` | `supplier_order`     | `id`, `order_id`, `order_number`, `catalog_supplier_id`, `status`, `tracking_number`, `carrier`, `updated_at`                                                                  |
| `supplier_sync.completed`, `supplier_sync.failed`                                                      | `supplier_sync_run`  | `id`, `subscription_id`, `trigger`, `status`, `started_at`, `finished_at`, `items_seen`, `created`, `updated`, `hidden`, `unhidden`, `held`, `skipped`, `errors`, `error`      |
| `supplier_approval.pending`                                                                            | `supplier_approvals` | `added` (kolik přibylo), `pending_total` (kolik čeká celkem), `subscription_id`, `source` (`sync` nebo `supplier_order`)                                                       |
| `stock.changed`                                                                                        | (bez obalu)          | `supplier_sync_run_id`, `subscription_id`, `source` (`supplier_feed`), `part`, `parts` a `variants`, v každé položce `variant_id`, `product_id`, `stock`                       |
| `catalog_item.gone`                                                                                    | (bez obalu)          | `supplier_sync_run_id`, `subscription_id`, `source` (`supplier_feed`), `part`, `parts` a `items`, v každé položce `catalog_item_id`, `external_id`, `product_id`, `variant_id` |

`supplier_sync.completed` odchází jen z běhu, který něco změnil nebo odložil ke schválení: hodinový
běh, který nic nového nenašel, událost nevypustí. `supplier_sync.failed` odchází z každého běhu, který
selhal. `supplier_approval.pending` přichází jednou za dávku (běh synchronizace, objednávka čekající
na odeslání dodavateli), ne za každou položku; samotnou frontu si přečtěte přes
`/api/v1/supplier-approvals?status=pending`.

`stock.changed` a `catalog_item.gone` jsou souhrny za jeden běh synchronizace feedu: synchronizace
cen a skladu schválně nevypouští `product.updated`, protože u stovek obchodů by šlo o miliony zpráv
za hodinu. `stock.changed` vyjmenuje varianty, kterým se v běhu změnil sklad dodavatele,
`catalog_item.gone` zboží, které dodavatel z feedu vyřadil (každé vyřazení přijde jednou). Jedna
zpráva nese nejvýš 500 položek; větší běh pošle několik zpráv, `part` a `parts` říkají kolikátá je.

Časy uvnitř `data` mají tvar jako v REST API: ISO 8601 v UTC se zónou `Z` a s mikrosekundami
(`2026-09-01T08:14:02.118204Z`), takže čas z webhooku a čas téhož záznamu z REST API jsou tentýž
řetězec. Obálka má u `created_at` jen milisekundy. Datum bez času (`duzp` u dokladu) má tvar
`RRRR-MM-DD`.

> **Změna od 29. září 2026** Do té doby chodily časy v `data` v textovém tvaru databáze, tedy `2026-09-01 08:14:02.118204+00` (mezera místo `T` a posun bez minut). Parser data a času si poradí s oběma tvary; kdo čas z webhooku porovnával nebo ukládal jako text, má si ho teď převést na skutečný čas.

Událost, která se jednomu záznamu stane jednou (objednávka v daném stavu, nový produkt, nový
zákazník, vystavený doklad, žádost o vrácení), má identifikátor odvozený z toho záznamu a na jeden
koncový bod odejde nejvýš jednou; kdyby ji obchod vypustil znovu, nese stejné `id`. Totéž platí pro
dropship objednávku u dodavatele: každý její stav (odeslána dodavateli, potvrzena dodavatelem,
odeslána zákazníkovi, dodavatel nemůže dodat) má jeden identifikátor. Adresa zákazníka ve zprávě
o dropship objednávce není, ta je u objednávky. Běh synchronizace má jeden identifikátor pro
`supplier_sync.completed` nebo `supplier_sync.failed` a jeden pro `supplier_approval.pending`,
souhrny běhu (`stock.changed`, `catalog_item.gone`) mají identifikátor z běhu a pořadí dávky.
`product.updated`
a `stock.low` dostávají pokaždé nový identifikátor, protože každá úprava a každé denní hlášení je
nová událost.

`order.refunded` má identifikátor odvozený z vrácení peněz, ne z objednávky. Každá úspěšná
refundace je samostatná zpráva: dvě částečné refundace téže objednávky jsou dvě zprávy s různým
`id`, a kdyby obchod tutéž refundaci vypustil znovu, nese stejné `id`. Zprávu pošle každé vrácení
peněz, které je hotové:

* refundace přes platební bránu (Comgate, Stripe), kterou brána potvrdí rovnou,
* refundace, kterou brána zpracovává a dokončí až později: zpráva odejde ve chvíli, kdy brána
  potvrdí, že peníze odešly,
* ruční refundace, kterou obchodník u vratky zapíše, když peníze pošle sám (dobírka, převod).

Refundace, kterou brána teprve zpracovává nebo kterou odmítla, zprávu nepošle. Zpráva nese stav
objednávky po refundaci: `payment_status` je `partially_refunded`, nebo `refunded`, když se vrátila
celá zaplacená částka. U objednávky, kterou obchod jako zaplacenou nevedl (třeba dobírka, kterou
nespároval), zůstane `payment_status` beze změny. Vrácené částky podle vratek najdete
v `/api/v1/returns` (`refund.refunded_minor`).

## Hlavičky a podpis [#hlavicky-a-podpis]

Podepisujeme podle otevřené specifikace **Standard Webhooks**. Zpráva nese
hlavičky `webhook-id`, `webhook-timestamp` a `webhook-signature`.

Podepisovaný řetězec je `{id}.{timestamp}.{payload}`, kde `payload` je **surové tělo požadavku,
bajt po bajtu**. Klíčem je base64 dekódovaná část tajemství za
prefixem `whsec_`, algoritmus je HMAC-SHA256 a posílá se jako
`v1,<base64>`. Podle specifikace smí hlavička nést víc podpisů oddělených mezerou. SHOPZI dnes
posílá jeden, ale ověření má projít všechny a zprávu přijmout, když sedí kterýkoli s verzí `v1`.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function overitPodpis(raw, headers, secret, toleranceSec) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const presented = headers["webhook-signature"];
  if (!id || !timestamp || !presented) return false;

  const stari = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(stari) || stari > toleranceSec) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${raw}`, "utf8")
    .digest("base64");

  return presented.split(" ").some((part) => {
    if (!part.startsWith("v1,")) return false;
    const value = part.slice(3);
    if (value.length !== expected.length) return false;
    return timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}
```

> **Surové tělo, ne rozparsovaný objekt** Podepsané jsou bajty, které dorazily. Kdo tělo napřed rozparsuje na JSON a pak ho zpátky serializuje, podepisuje jiný řetězec a podpis mu nikdy nesedne. V n8n je to přepínač Raw Body, v Express `express.raw()`, ve frameworku, který parsuje sám, obvykle vypnutí parseru na téhle jedné cestě.

## Razítko a přehrávání [#razitko-a-prehravani]

Tolerance je 5 minut na obě strany. Zprávu s razítkem mimo toleranci
zahoďte, i kdyby podpis seděl: bez téhle kontroly je jednou odchycený požadavek přehratelný navždy.
Ověřujte v pořadí razítko, pak podpis, a porovnávejte v konstantním čase.

## Opakování a idempotence [#opakovani-a-idempotence]

Doručujeme **nejméně jednou**. Za úspěch se počítá jen odpověď 2xx, a to do 10 sekund. Jiný stavový
kód, vypršený čas i nedostupná adresa jsou nezdar, stejně jako přesměrování (3xx): přesměrování
nenásledujeme, adresu koncového bodu zadejte přímo. Po nezdaru posíláme znovu, a to
po 1 minutě, 5 minutách, 30 minutách, 2 hodinách a 6 hodinách. Pokusů je celkem 6, pak
zprávu vzdáme.

Odpověď `410 Gone` bereme jako vzkaz, že koncový bod už neexistuje, a další pokus po ní nepřijde.
Koncový bod, kterému za sebou skončí nezdarem 10 doručení,
se sám vypne: v administraci dostane označení **Vypnuto pro opakované chyby** a obchod dostane
upozornění **Odchozí webhook byl vypnut**. Úspěšné doručení počítadlo nezdarů vynuluje a stejně tak
ruční zapnutí tlačítkem **Zapnout**.

Z toho plyne jediné pravidlo, které nesmíte porušit: **táž zpráva vám může dorazit vícekrát**.
Deduplikujte podle `webhook-id`, které je součástí podpisu, a ne podle
obsahu těla.

> **Odpovězte hned a zpracujte potom** Zapište zprávu do své fronty a odpovězte 2xx. Na odpověď čekáme 10 sekund, takže kdo si v obsluze webhooku pustí půlminutovou synchronizaci, dostane místo úspěchu nezdar a opakování, a pak řeší dvě běžící naráz.

## Testovací zpráva a log doručení [#testovaci-zprava-a-log-doruceni]

Testovací zprávu posílá tlačítko **Odeslat test** u koncového bodu v **Nastavení → API a MCP**,
v části **Webhooky**. Použijte ho dřív, než napojení pustíte naostro: ověří, že vaše adresa odpovídá,
že jde ověřit podpis a že fronta odpoví včas. Make si z ní navíc načte strukturu dat. Testovací
zpráva je podepsaná jako každá jiná, má typ `order.created` a v `data` nese `"test": true`, větu
`note` a smyšlenou objednávku `TEST-1` na 1 299 Kč (`total_minor: 129900`). Odchází hned, i když
koncový bod událost `order.created` neodebírá, a na skutečné objednávky nesahá.

Historii pokusů ukáže **Log doručení** u téhož koncového bodu: stav (**Ve frontě**, **Doručeno**,
**Neúspěch, zkusíme znovu**, **Vzdáno**), stavový kód odpovědi a počet pokusů. **Podrobnosti** ukážou
ID zprávy, odeslané tělo a odpověď protistrany, **Odeslat znovu** pošle tutéž zprávu znovu se
stejným `id`.

Tajemství se dá kdykoli přegenerovat tlačítkem **Nové tajemství** (a potvrzením **Vygenerovat nové**).
Staré tím okamžitě přestane platit, takže se nová hodnota nasazuje na vaší straně jako první.

## Časté potíže [#caste-potize]

* **„Adresa musí začínat [https://.“](https://.“)** Nešifrované `http` nepřijímáme, stejně jako adresy do vnitřní
  sítě nebo na localhost.
* **Nic nechodí a karta hlásí, že jsou webhooky pozastavené.** REST API obchodu je zavřené (vypnutý
  přepínač, tarif bez externího přístupu, zásah SHOPZI). Po znovuotevření chodí jen nové události;
  vzdané zprávy pošlete znovu z **Logu doručení**.
* **Podpis nikdy nesedí.** Ověřujete rozparsované a znovu serializované tělo místo surového, nebo
  jste po **Novém tajemství** nenasadili novou hodnotu. Zkontrolujte i to, že klíč HMAC je base64
  dekódovaná část tajemství za `whsec_`, ne celý řetězec jako text.
* **Zprávy se odmítají jako staré.** Hodiny příjemce jdou o víc než 5 minut
  jinak, nebo zprávu zpracováváte z fronty příliš pozdě. Razítko ověřujte hned při příjmu.
* **V logu je nezdar s přesměrováním.** Adresa vrací 3xx, například na variantu s lomítkem na
  konci nebo na jinou doménu. Přesměrování nenásledujeme, zadejte cílovou adresu přímo.
* **Koncový bod je „Vypnuto pro opakované chyby“.** Za sebou skončilo nezdarem
  10 doručení. Opravte příjemce a klikněte na **Zapnout**;
  počítadlo nezdarů se tím vynuluje.

Obchodnickou verzi téhle stránky, tedy postup klikáním včetně napojení na Make.com a n8n, najdete
v článku [API klíče, webhooky a napojení](/dokumentace/nastaveni/api-klice-a-webhooky). Hotový
příjemce v Node.js je v rychlém startu na úvodní stránce
[Dokumentace pro vývojáře](/dokumentace/vyvojari).
