Registrácia webhookov, tvar správy, overenie podpisu a katalóg udalostí.Registering webhooks, payload shape, signature verification and the event catalogue.
2 min čítania2 min read
#webhook
#hmac-sha256
#x-webhook-signature
#udalosti
#retry
#webhook
#hmac-sha256
#x-webhook-signature
#events
#retry
Namiesto opakovaného dotazovania nechajte platformu, nech vám zavolá sama.
Ako events uveďte zoznam konkrétnych udalostí z katalógu nižšie. Sekret na
podpisovanie doručovaných správ vygeneruje platforma a vráti ho len v tejto
odpovedi na vytvorenie — pri neskoršom zozname webhookov už nikde nie je.
Uložte si ho hneď; ak ho stratíte, treba webhook zmazať a založiť nový.
Podpis počítame z reťazca {timestamp}.{telo} pomocou HMAC-SHA256 a vášho
secretu. Vždy ho overte — inak vám ktokoľvek môže podstrčiť falošnú
udalosť. Na porovnanie použite funkciu odolnú voči časovej analýze.
overenie podpisu
import { createHmac, timingSafeEqual } from "node:crypto";export function isValidWebhook( rawBody: string, // telo PRESNE tak, ako prišlo — nie re-serializované signature: string, // X-Webhook-Signature timestamp: string, // X-Webhook-Timestamp secret: string,): boolean { // Staršie ako 5 minút zahodíme — ochrana proti prehratiu. // Hlavička je ISO 8601, nie epoch: Number() by nad ňou dal NaN a porovnanie // by vždy prešlo, takže by ochrana ticho nefungovala. const sentAt = Date.parse(timestamp); if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b);}
using System.Globalization;using System.Security.Cryptography;using System.Text;static bool IsValidWebhook( string rawBody, string signature, string timestamp, string secret){ // Staršie ako 5 minút zahodíme — ochrana proti prehratiu. if (!DateTimeOffset.TryParse(timestamp, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var sentAt)) return false; if (Math.Abs((DateTimeOffset.UtcNow - sentAt).TotalMilliseconds) > 300_000) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var payload = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"); var expected = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));}
function isValidWebhook( string $rawBody, string $signature, string $timestamp, string $secret): bool { // Staršie ako 5 minút zahodíme — ochrana proti prehratiu. $sentAt = strtotime($timestamp); if ($sentAt === false || abs(time() - $sentAt) > 300) { return false; } $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret); return hash_equals($expected, $signature);}
Doručovanie a opakovanie
Čakáme na odpoveď najviac 30 sekúnd. Odpovedzte 2xx hneď, ako správu
prijmete, a spracujte ju až potom na pozadí. Ak endpoint zlyhá alebo
neodpovie, doručenie zaradíme na opakovanie.
Opakované pokusy sú podpísané rovnako ako prvý — rovnaký recept, rovnaký
tvar hlavičiek. Líšia sa len v timestamp: každý pokus nesie čerstvý čas, ktorý
je zároveň v podpísanom tele, takže päťminútové okno platí od daného pokusu, nie
od pôvodnej udalosti. Overovanie napísané podľa prvého doručenia teda platí pre
všetky pokusy.
Katalóg udalostí
Udalosť
Kedy príde
invoice.accepted
Odchádzajúcu faktúru prevzala protistrana (kladná MLS správa). Vtedy sa odpočíta rezervovaný kredit.
invoice.delivered
Pri príjemcovi mimo siete Peppol: prijalo sa daňové hlásenie. Pri prichádzajúcej faktúre: prevzali sme dokument.
invoice.rejected
Protistrana faktúru odmietla zápornou MLS správou — alebo pri príjemcovi mimo Peppolu odmietla daňové hlásenie finančná správa.
mls.received
Prišla odpoveď k daňovému hláseniu, kým sa na potvrdenie od protistrany ešte čaká. Stav faktúry sa nemení.
Instead of polling, let the platform call you.
Registration
curl -X POST https://testpostman.slovakodata.com/api/v1/webhooks \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://erp.mojafirma.sk/hooks/epostar", "events": ["invoice.delivered", "invoice.rejected", "invoice.accepted"] }'# 201 Created — the platform generates the secret, returned only once{ "id": "b7e2f9a1-3c4d-4e11-9a2b-7f6c1d8e0a35", "url": "https://erp.mojafirma.sk/hooks/epostar", "events": ["invoice.delivered", "invoice.rejected", "invoice.accepted"], "secret": "whsec_...", "is_active": true, "created_at": "2026-07-13T09:10:00.000Z"}
Pass a list of specific events from the catalogue below. The platform
generates the signing secret and returns it only in this creation response —
it is never shown again in the webhook list. Store it right away; if you
lose it, delete the webhook and create a new one.
The signature is HMAC-SHA256 over the string {timestamp}.{body} using your
secret. Always verify it — otherwise anyone can forge an event. Compare
with a timing-safe function.
signature check
import { createHmac, timingSafeEqual } from "node:crypto";export function isValidWebhook( rawBody: string, // the body EXACTLY as received — not re-serialized signature: string, // X-Webhook-Signature timestamp: string, // X-Webhook-Timestamp secret: string,): boolean { // Discard anything older than 5 minutes — replay protection. // The header is ISO 8601, not epoch: Number() would yield NaN and the // comparison would always pass, silently disabling the protection. const sentAt = Date.parse(timestamp); if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b);}
using System.Globalization;using System.Security.Cryptography;using System.Text;static bool IsValidWebhook( string rawBody, string signature, string timestamp, string secret){ // Discard anything older than 5 minutes — replay protection. if (!DateTimeOffset.TryParse(timestamp, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var sentAt)) return false; if (Math.Abs((DateTimeOffset.UtcNow - sentAt).TotalMilliseconds) > 300_000) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var payload = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"); var expected = Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));}
We wait at most 30 seconds for a response. Return 2xx as soon as you
receive the message and process it in the background afterwards. If your
endpoint fails or times out, the delivery is queued for a retry.
Retries are signed exactly like the first attempt — same recipe, same header
shape. Only the timestamp differs: every attempt carries a fresh one, which is
also inside the signed body, so the five-minute window applies to that attempt
rather than to the original event. Verification written against the first
delivery therefore holds for every retry.
Event catalogue
Event
When it fires
invoice.accepted
The other side took the outgoing invoice (positive MLS message). The reserved credit is deducted at that point.
invoice.delivered
For a recipient outside the Peppol network: the tax report was accepted. For an inbound invoice: we took the document.
invoice.rejected
The other side rejected the invoice with a negative MLS message — or, for a recipient outside Peppol, the tax authority rejected the tax report.
mls.received
A tax-reporting response arrived while confirmation from the other side is still pending. The invoice state does not change.