Webhook o doručení: ako ho prijať správne

eP24 vám pošle podpísanú udalosť vtedy, keď faktúra naozaj dorazí k odberateľovi (outbox.document.delivered), alebo keď sa odoslanie definitívne skončí neúspechom (outbox.document.failed). Prijať ju je pár riadkov kódu. Prijať ju správne stojí na dvoch veciach, na ktorých padne väčšina integrácií — a obe zlyhávajú tak, že to vyzerá na niečo iné.

1. Podpis počítajte nad surovým telom. Vždy.

Váš framework telo požiadavky ochotne rozparsuje a ponúkne vám pekný objekt. Keď z toho objektu vyrobíte JSON späť a podpíšete ten, podpis nebude sedieť nikdy — a chyba bude vyzerať ako zlý kľúč. Poradie kľúčov, medzery, spôsob zápisu čísel aj unicode escapy sa pri serializácii menia; HMAC je funkcia bajtov, nie významu.

// ZLE — telo prešlo parserom a späť: iné bajty, podpis nikdy nesedí
const event = await request.json();
const expected = hmac(secret, ts + "." + JSON.stringify(event));

// DOBRE — presne tie bajty, ktoré prišli
const raw = await request.text();
const expected = hmac(secret, ts + "." + raw);
const event = JSON.parse(raw);   // až po overení

V praxi to znamená: v Next.js request.text(), v ASP.NET Core čítanie req.Body pred model bindingom, v PHP file_get_contents("php://input"), v Django request.body (nie request.POST), v Spring @RequestBody byte[]. Telo parsujte až po overení podpisu.

2. Deduplikácia nie je voliteľná

„Aspoň raz“ nie je poznámka pod čiarou. Ak vaša odpoveď dorazí neskoro alebo sa cestou stratí, eP24 to počíta ako neúspech a pošle tú istú udalosť znova — aj keď ste ju už uložili a zaúčtovali. ERP, ktorý účtuje pri príchode udalosti, zaúčtuje dvakrát. Pri prijatých faktúrach to znamená aj druhý záväzok v účtovníctve, nielen riadok v logu.

Ochrana je jedno pole: X-EP24-Delivery je pri každom opakovaní tej istej udalosti rovnaké. Uložte ho s unikátnym indexom a v tej istej transakcii ako zmenu stavu. Nie „pozri, či už existuje, potom zapíš“ — to sú dve operácie a medzi nimi sa zmestí druhé doručenie.

-- Jedna transakcia: buď je udalosť zapísaná aj zaúčtovaná, alebo nič.
begin;

insert into webhook_deliveries (delivery_id, received_at)
values ($1, now())
on conflict (delivery_id) do nothing;      -- druhý pokus sa ticho zahodí

-- Ak insert nič nevložil, udalosť sme už spracovali: commit a 200.
update documents
set delivery_state = $2, delivered_at = $3
where id = $4 and (delivered_at is null or delivered_at < $3);

commit;

Duplikát je úspech: odpovedzte 2xx. Keď odpoviete chybou, eP24 to skúsi znova a znova, a nakoniec vám endpoint vypne.

Tri veci, na ktoré sa zabúda o niečo menej často

3. Prijaté faktúry nesú celé XML

Tretia udalosť, inbox.document.received, ide opačným smerom: faktúra prišla vám, a UBL cestuje priamo v tele udalosti — nie za druhým volaním. Pre prijímač to znamená tri veci navyše.

Telo býva rádovo väčšie. Medián je 4,4 kB, 95. percentil 90 kB, faktúry s prílohami výrazne viac; eP24 garantuje strop 4 MB na celé telo udalosti. Limit, ktorý vás zastaví najskôr, je predvolených 100 kB v express.json(). Ten 95. percentil síce prejde, ale len o desatinu — prvá faktúra s prílohou tú rezervu minie a vznikne 413, ktoré žiadne opakovanie nevyrieši. Zdvihnite ho a rátajte s tým, že opakovanie pošle celé telo znova.

payload môže byť null. Dokument bol priveľký alebo sa ho nepodarilo načítať; payloadOmittedReason povie ktoré. Metadáta a odkaz do eP24 prídu tak či tak — faktúru teda nezahoďte, ukážte ju človeku. Toto je vetva, ktorú integrácie zabúdajú, a jej cena je ticho chýbajúca faktúra.

Overte payloadSha256. Podpis hlavičky dokazuje, že udalosť dorazila nezmenená. Tento digest dokazuje niečo iné: že XML vnútri je ten dokument, ktorý eP24 myslela. Keď nesedí, odpovedzte 2xx — bajty boli podpísané, opakovanie nič nezmení — a faktúru označte na overenie.

const event = JSON.parse(raw);

if (event.event === "inbox.document.received") {
  const xml = event.received?.payload ?? null;

  if (xml === null) {
    // Faktúru NEZAHOĎTE: metadáta aj odkaz prišli, chýba len dokument.
    await ulozNaRucneSpracovanie(event, event.payloadOmittedReason);
  } else if (sha256(xml) !== event.payloadSha256) {
    await oznacNaOverenie(event);          // potvrdíme 2xx, ale neúčtujeme
  } else {
    await zalozPrijatuFakturu(xml, event.received.metadata);
  }
}

Peniaze v udalosti nie sú

Udalosť hovorí, čo sa stalo s dokumentom, nie čo to stálo — sumy ani identifikátory z peňaženky eP24 von neposiela. Na účtovanie to stačí, lebo vzťah je jednoznačný: jedno doručenie = jeden poplatok za cenu platnú v tej chvíli, a failed neznamená poplatok nikdy (eP24 účtuje až pri doručení, takže neúspešné odoslanie nebolo nikdy zaťažené a niet čo refundovať). Čo z udalosti neurobíte, je pomenovanie konkrétneho riadku v peňaženke; ten je vo výpise v portáli.

Vyskúšajte si, že vám to odmieta

Overovač, ktorý nikto nikdy nevidel niečo odmietnuť, je len nádej. Skript nižšie pošle proti vášmu endpointu zmenené telo aj hodinu starú pečiatku — obe musia skončiť na 401 — a rotáciu s dvoma podpismi, ktorá musí prejsť. Výsledok potom uvidíte v paneli simulátora.

EP24_WEBHOOK_SECRET=whsec_… npm run test:webhook -- https://vas-erp.example

Deväť požiadaviek, z toho tri, ktoré musia zlyhať. Zdroj: webhook-selftest.mjs, overovač je lib/ep24-webhook.ts a celý prijímač route.ts — sú krátke naschvál, aby sa dali prečítať celé.


Úplný kontrakt — hlavičky, schéma udalosti, plán opakovaní, prahy vypnutia a overenie v PHP aj C# — je v príručke, kapitola 8a.