# Webhooky

URL: https://postman.slovakodata.com/developers/webhooky/
Aktualizované: 2026-10-01

Registrácia webhookov, tvar správy, overenie podpisu a katalóg udalostí.

Namiesto opakovaného dotazovania nechajte platformu, nech vám zavolá sama.

### Registrácia

```bash
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 — secret vygeneruje platforma, v odpovedi príde len raz
{
  "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"
}
```

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ý.

URL musí začínať `https://` a smerovať na verejne dostupný server. Platforma
odmietne s chybou `400` adresu bez `https`, s menom a heslom v URL, jednoslovný
názov hosta (napr. `https://erp/hook`), interné domény (`localhost`, `.local`,
`.internal`) aj IP adresy z privátnych a rezervovaných rozsahov. To isté overuje
pri každom doručení: ak sa meno hosta preloží na takúto adresu, správa sa
nedoručí a neopakuje. Presmerovania (`3xx`) platforma nenasleduje.

**Správa webhookov beží cez API kľúč, nie cez prístupový token.** Token zo
`/sapi/auth/token` je vyhradený výlučne pre cesty `/sapi/*` — na `/api/v1/*`
vráti `403`. API kľúč si vytvoríte v portáli v **Nastavenia → API Kľúče**.

### Tvar správy

```text
{
  "event": "invoice.delivered",
  "timestamp": "2026-07-13T09:15:03.871Z",
  "data": {
    "invoice_id": "9f2c1e84-3b7a-4d16-b0c5-1e8a72d40f31",
    "mls_c3_status": null,
    "mls_c5_status": null,
    "status": "DELIVERED",
    "selfBilling": false,
    "invoiceTypeCode": "380",
    "document_type": "INVOICE",
    "invoice_number": "2026001",
    "supplier_name": "Dodávateľ s.r.o.",
    "buyer_name": "Odberateľ a.s.",
    "total_amount": "120.00",
    "currency": "EUR",
    "issue_date": "2026-08-23",
    "document_url": "https://postman.slovakodata.com/api/v1/invoices/9f2c1e84-3b7a-4d16-b0c5-1e8a72d40f31/xml"
  }
}

# Hlavičky
X-Webhook-Signature: 4f1c0a...      # HMAC-SHA256, hex
X-Webhook-Timestamp: 2026-07-13T09:15:03.871Z
```

**Nevyplnené údaje dokladu sa v `data` neposielajú ako `null` — chýbajú úplne.** Čítajte
ich teda ako voliteľné a nespoliehajte sa na ich prítomnosť. Výnimkou sú `mls_c3_status`
a `mls_c5_status`: tie prichádzajú aj bez hodnoty a `null` znamená, že príslušná MLS
odpoveď ešte nedorazila — ako v ukážke vyššie, kde prístupový bod odberateľa správu
prijal, ale MLS ešte neposlal. `invoice_id` je v každom
payloade; `selfBilling` a `document_url` nesú **len faktúrne a MLS udalosti**
(`invoice.accepted`, `invoice.rejected`, `invoice.delivered`, `invoice.received`,
`mls.received`, `mls.nok`). Udalosti `invoice.failed`, `invoice.undeliverable` a `tdd.*`
majú vlastný, užší payload.

`invoiceTypeCode` je kód **UBL BT-3 tak, ako prišiel v dokumente** — `380` faktúra,
`381` dobropis, `389` self-billing faktúra, `261` self-billing dobropis, ale aj iné
hodnoty (napr. `384` opravná faktúra). Keď kód nepoznáme, pole **chýba**; nedopĺňame
zaň `380`. Pole `document_type` je oproti tomu naša zjednodušená kategória, ktorá
neznáme kódy zaraďuje pod `INVOICE` — na rozlíšenie typu dokladu použite
`invoiceTypeCode`.

`document_url` odkazuje na stiahnutie originálneho UBL XML. Vyžaduje autentifikáciu
rovnako ako ostatné volania `/api/v1/*`.

### Overenie podpisu

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.

```ts

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);
}
```

```csharp
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));
}
```

```php
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);
}
```

**Podpisujeme presne to, čo pošleme.** Podpis počítajte zo *surového* tela
požiadavky. Ak si telo najprv rozparsujete do objektu a potom znova
serializujete, poradie kľúčov alebo medzery sa môžu zmeniť a podpis nebude
sedieť.

### 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.delivered` | Prístupový bod odberateľa prijal odchádzajúcu faktúru a potvrdil to kladným AS4 potvrdením o prevzatí (receipt): `status: "DELIVERED"`, `mls_c3_status: null`. Faktúra je odoslaná, doručenie ešte nie je potvrdené a kredit je stále rezervovaný. Ak MLS správa dorazí skôr, než stihneme túto udalosť odoslať, spravidla sa neodošle a príde rovno `invoice.accepted` alebo `invoice.rejected`. Pri príjemcovi mimo siete Peppol: finančná správa prijala daňové hlásenie, `status: "DELIVERED_NON_PEPPOL"` — tu je to konečný stav a kredit sa odpočíta. |
| `invoice.accepted` | Prístupový bod odberateľa poslal kladnú MLS správu — faktúra je doručená: `status: "ACCEPTED"`, `mls_c3_status: "OK"`. Vtedy sa odpočíta rezervovaný kredit. Neznamená to, že odberateľ faktúru obchodne schválil. |
| `invoice.unconfirmed` | MLS správa nedorazila do 25 minút od odoslania; faktúra prešla do `UNCONFIRMED` a kredit sa odpočítal. Ak MLS správa dorazí neskôr, príde ešte `invoice.accepted` alebo `invoice.rejected`. Payload nesie len `invoice_id` a `reason`. |
| `invoice.rejected` | Prístupový bod odberateľa faktúru odmietol 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í. |

**Poradie udalostí nie je zaručené.** `invoice.delivered` môže výnimočne prísť až po
`invoice.accepted` alebo `invoice.rejected` k tej istej faktúre. O stave faktúry
rozhodujte podľa poľa `status` v `data`, nie podľa názvu ani poradia udalostí: `DELIVERED`
nikdy neprepisuje konečný stav, ktorý ste už prijali, a ten istý názov `invoice.delivered`
nesie pri Peppole `DELIVERED`, mimo Peppolu `DELIVERED_NON_PEPPOL`.
