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í.
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
Koncový bod zakládá vlastník nebo manažer obchodu v Nastavení → API a MCP v části Webhooky, v kartě Odchozí webhooky:
- Klikněte na Přidat koncový bod.
- 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ě. - Do pole Popis (nepovinné) můžete napsat, k čemu koncový bod slouží, ať ho v seznamu poznáte.
- 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á. - 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
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í.
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
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
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:
{
"id": "7b1e4c2a-3f5d-4e8b-9c61-2d0a5f7e9b13",
"type": "order.paid",
"created_at": "2026-09-01T08:14:02.118Z",
"data": {}
}idje identifikátor zprávy, tentýž jako v hlavičcewebhook-id. Nemění se při opakování doručení ani při ručním odeslání znovu.typeje typ události z tabulky výš a podle něj se rozhodujte.created_atje čas vzniku události, ISO 8601 v UTC s milisekundami.datanese ú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
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áří 2026Do 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
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.
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ý objektPodepsané 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í
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
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 potomZapiš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í
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
- „Adresa musí začínat https://.“ Nešifrované
httpnepř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í. Hotový příjemce v Node.js je v rychlém startu na úvodní stránce Dokumentace pro vývojáře.