Webhooks
locco doručuje podpísané JSON notifikácie o udalostiach na HTTPS endpointy zaregistrované partnermi, keď sa v tenante vyskytnú doménové udalosti (záznam vytvorený, záznam schválený, záznam vyplatený, záznam aktualizovaný, zamestnanec vytvorený alebo aktualizovaný). Táto stránka slúži ako referencia pre partnerov: formát podpisu, kontrakt na ochranu proti replay útokom, očakávania ohľadom idempotencie, politika pre retry a kánonický katalóg typov udalostí.
Push rozhranie je náprotivkom k pull /api/v1/* API. Payloady obsahujú rovnaké v1 DTOs, aké vracia GET /api/v1/{resource}/{id} volajúcemu s API kľúčom, takže partneri pracujú s jedným kontraktom pre obe rozhrania.
V skratke
- Partneri zaregistrujú HTTPS endpoint cez
POST /api/v1/webhooksa dostanú jednorazový podpisový secret (whsec_...). - locco posiela JSON cez POST na tento endpoint, keď nastanú zodpovedajúce udalosti. Telá požiadaviek sú podpísané pomocou HMAC-SHA256.
- Zlyhané doručenia (5xx, sieťové chyby, timeout) sa opakujú s exponential back-off; 4xx ukončí retry. Po približne 40 hodinách neúspešných pokusov (8 opakovaní po počiatočnom pokuse) je doručenie označené ako vyčerpané.
- Po 10 po sebe idúcich vyčerpaných doručeniach sa odber automaticky deaktivuje. Pre opätovnú aktiváciu je potrebná nová registrácia.
- Vnorené logy doručení (
GET /api/v1/webhooks/{id}/deliveries) a stránka v aplikácii Settings → Integrations → Webhooks zobrazujú, čo bolo odoslané a čo sa vrátilo.
Formát podpisu
Každá požiadavka obsahuje header Locco-Signature:
Locco-Signature: t=<unix-timestamp>,v1=<hex-hmac-sha256>Vstup pre HMAC je kánonický reťazec {timestamp}.{payload}:
{timestamp}: celé číslo v parametrit=(sekundy Unix epoch, UTC)..: literálny znak bodky.{payload}: surové, neupravené bajty tela požiadavky. Pred výpočtom JSON NEPREVÁDZAJTE na novú serializáciu. Na bielych znakoch a poradí kľúčov záleží.
Formát presne kopíruje podpis pre webhook od Stripe (t=...,v1=...), takže partneri s existujúcim kódom na overovanie Stripe ho môžu prispôsobiť iba premenovaním hlavičky.
Overenie podpisu
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60; // 5 minutes, Stripe-recommended
export function verifyLoccoSignature(rawBody, header, secret) { // header looks like: "t=1714050000,v1=abc123..." const parts = Object.fromEntries( header.split(',').map((part) => part.split('=', 2)), ); const timestamp = Number(parts.t); const signature = parts.v1;
if (!timestamp || !signature) { throw new Error('Malformed Locco-Signature header'); }
// 1. Replay protection: reject anything older than the tolerance window. const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestamp); if (ageSeconds > TOLERANCE_SECONDS) { throw new Error(`Signature timestamp is ${ageSeconds}s old; rejecting`); }
// 2. Recompute HMAC over the canonical input. const signedPayload = `${timestamp}.${rawBody}`; const expected = createHmac('sha256', secret) .update(signedPayload, 'utf8') .digest('hex');
// 3. Constant-time comparison. const expectedBuf = Buffer.from(expected, 'hex'); const actualBuf = Buffer.from(signature, 'hex'); if ( expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf) ) { throw new Error('Invalid signature'); }
return true;}import hmac, hashlib, time
TOLERANCE_SECONDS = 5 * 60
def verify_locco_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) ts = int(parts["t"]) sig = parts["v1"] if abs(time.time() - ts) > TOLERANCE_SECONDS: raise ValueError("timestamp outside tolerance") signed = f"{ts}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig): raise ValueError("invalid signature") return Trueusing System.Collections.Generic;using System.Globalization;using System.Linq;using System.Security.Cryptography;using System.Text;
public static bool VerifyLoccoSignature(ReadOnlySpan<byte> rawBody, string header, string secret){ const int toleranceSeconds = 5 * 60; var parts = header.Split(',') .Select(p => p.Split('=', 2)) .ToDictionary(p => p[0], p => p[1]); var timestamp = long.Parse(parts["t"], CultureInfo.InvariantCulture); var signature = parts["v1"]; var ageSeconds = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp); if (ageSeconds > toleranceSeconds) throw new InvalidOperationException("timestamp outside tolerance");
var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(rawBody.ToArray()).ToArray(); using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));}Rovnaký princíp funguje v akomkoľvek jazyku: HMAC-SHA256 z {timestamp}.{payload} so secretom ako kľúčom, zakódované do hex formátu a porovnané v konštantnom čase s parametrom v1=.
Ochrana proti replay útokom (POVINNÉ)
Partneri MUSIA odmietnuť požiadavky, ktorých timestamp je starší ako malé časové okno. Odporúčaná tolerancia je 5 minút (zhoduje sa s predvolenou hodnotou Stripe). Bez tejto kontroly môže útočník, ktorý zachytí legitímne telo požiadavky a podpis, vykonať replay útok donekonečna, kým nedôjde k rotácii secretu.
Odmietnite:
- Chýbajúci alebo nesprávne naformátovaný header
Locco-Signature. - Timestamp starší ako okno tolerancie.
- Timestamp výrazne v budúcnosti (povoľte malú rezervu na odchýlku hodín, napr. 30 sekúnd).
- Vypočítaný HMAC, ktorý sa nezhoduje s
v1=.
Pre kontrolu HMAC použite porovnanie v konštantnom čase (timingSafeEqual v Node.js, hmac.compare_digest v Pythone, CryptographicOperations.FixedTimeEquals v .NET). Porovnanie reťazcov (===) prezrádza informácie o čase a v princípe môže odhaliť podpis bajt po bajte.
Idempotencia
Každé doručenie obsahuje header Locco-Event-Id, čo je stabilný Guid identifikujúci logickú udalosť. Rovnaké id udalosti sa posiela znova pri:
- Manuálnych opakovaniach: partner klikol na Retry v používateľskom rozhraní logu doručení, alebo poslal POST na
/api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. - At-least-once redelivery: proces na pozadí, ktorý spadne medzi odoslaním požiadavky a zaznamenaním úspechu, znova zaradí ten istý riadok do fronty pri ďalšom dopytovaní.
Endpointy partnerov MUSIA spracovávať duplicitné príchody Locco-Event-Id ako no-ops. Štandardný vzor: udržujte úložisko nedávnych doručení (Redis, malá databázová tabuľka, in-process cache) a odmietnite každú udalosť, ktorej id už bolo spracované približne za poslednú hodinu.
Bez tejto deduplikácie sa downstream vedľajší efekt partnera (synchronizácia do ERP, odoslanie e-mailovej notifikácie, zmena záznamu) spustí dvakrát pre tú istú logickú udalosť vždy, keď dôjde k retry.
Sémantika pre retry je zámerná:
- Retry posiela rovnaké
Locco-Event-Idako pôvodné doručenie. Partneri deduplikujú iba na základe tohto headera, nepotrebujú kontrolovať payload. - Retry posiela rovnaké bajty tela ako pôvodné doručenie. Podpis sa prepočíta s novým timestampom, ale vstup pre HMAC je okrem tohto timestampu identický.
Politika pre retry
locco opakuje pokusy pri prechodných zlyhaniach s exponential back-off. Plán zahŕňa celkovo 9 HTTP pokusov (počiatočné doručenie plus 8 opakovaní) rozložených na približne 40 hodín od začiatku do konca.
| Pokus | Oneskorenie od predchádzajúceho pokusu | Kumulatívny čas od pokusu 1 |
|---|---|---|
| 1 (počiatočný) | (žiadne) | 0 |
| 2 | 30 sekúnd | 30s |
| 3 | 1 minúta | 1m 30s |
| 4 | 5 minút | 6m 30s |
| 5 | 15 minút | 21m 30s |
| 6 | 1 hodina | ~1h 21m |
| 7 | 3 hodiny | ~4h 21m |
| 8 | 12 hodín | ~16h 21m |
| 9 (konečný) | 24 hodín | ~40h 21m |
Retry vs ukončenie
| Odpoveď partnera | Správanie |
|---|---|
2xx | Úspech. Riadok doručenia označený ako Succeeded. Žiadne retry. |
4xx (okrem 408, 429) | Chyba na strane partnera. Riadok doručenia označený ako Failed. Žiadne retry. |
408 (Request Timeout), 429 (Too Many) | Považované za prechodné, retry podľa plánu. |
5xx | Chyba servera. Retry podľa plánu. |
| Sieťová chyba (DNS, TCP reset, TLS handshake) | Retry podľa plánu. |
| Timeout (locco to vzdá po 10 sekundách) | Retry podľa plánu. |
Po vyčerpaní všetkých 9 pokusov bez odpovede 2xx je riadok doručenia označený ako Exhausted. Telo poslednej odpovede partnera (skrátené na 2 KB) sa zachová v logu doručení, aby partneri mohli debugovať, čo ich endpoint vracal.
Headery Retry-After od partnerov nie sú vo v1 rešpektované. Vždy platí vyššie uvedený plán.
Automatická deaktivácia
Ak odber nazbiera 10 po sebe idúcich doručení so stavom Exhausted, locco ho automaticky deaktivuje (IsActive = false, DisabledAt, DisabledReason). Toto chráni locco pred neustálym bombardovaním trvalo nefunkčného endpointu.
Pre opätovnú aktiváciu automaticky deaktivovaného odberu:
- Skontrolujte log doručení, aby ste zistili, čo endpoint partnera vracal počas zlyhaní.
- Opravte endpoint partnera.
- Vymažte odber a vytvorte nový (
POST /api/v1/webhooks).
Typy udalostí
Katalóg v1. Nové typy sa pridávajú zmenou kódu súbežne s novou doménovou udalosťou, takže partneri sa môžu spoľahnúť, že tento zoznam predstavuje kompletnú sadu pri akomkoľvek vydaní.
Rovnaká sada je vystavená v API referencii ako enum schéma WebhookEventType; generátory SDK emitujú typovaný enum z tohto zoznamu, takže partneri získajú compile-time bezpečnosť pre hodnoty, na ktoré sa prihlasujú.
| Typ udalosti | Spustí sa, keď | data payload |
|---|---|---|
entry.approved | Cestovný záznam prešiel do konečného stavu Approved prostredníctvom kroku vo workflow. Automaticky schválené záznamy (spoločnosti bez schvaľovacieho workflow) túto udalosť nespúšťajú. Pozrite si entry.updated. | Webhook-safe zhrnutie cestovného záznamu (id + číslo záznamu + id zamestnanca + dátumy + stav + sumy). |
entry.paidout | Cestovný záznam bol označený ako vyplatený v rámci dávky výplat. | Rovnaké webhook-safe zhrnutie cestovného záznamu. |
entry.created | Bol vytvorený nový cestovný záznam. | Rovnaké webhook-safe zhrnutie cestovného záznamu. |
entry.updated | Audit log cestovného záznamu prijal záznam, ktorý nie je vytvorením, konečným schválením ani vyplatením, t. j. záznam sa zmenil spôsobom, ktorý platforma považuje za hodný zaznamenania. Zahŕňa úpravy, odoslanie, priebežné schválenia vo workflow, zrušenie, žiadosti o zmeny a zmeny podriadených entít (výdavky, diéty, nákladové strediská, prílohy). | Rovnaké webhook-safe zhrnutie cestovného záznamu. |
employee.created | Bol vytvorený nový riadok zamestnanca. | Webhook-safe zhrnutie zamestnanca (id + meno + pozícia + stav + audit timestamps). |
employee.updated | Existujúci riadok zamestnanca bol aktualizovaný. | Rovnaké webhook-safe zhrnutie zamestnanca. |
Kontrakt pre webhook payload: udalosti prenášajú identifikátory, nie PII
Telá udalostí webhooku prenášajú identifikátory zdrojov a minimálny snapshot pre zobrazenie (nikdy nie PII). Konkrétne:
- Udalosti zamestnancov prenášajú
id,firstName,lastName,position,departmentId,status,isAccountOwner,isArchived, pluscreatedAt/editedAt. Neprenášajú e-mail, telefón, adresu, daňové identifikačné číslo (OIB), bankový účet (IBAN) ani BIC. - Udalosti cestovných záznamov prenášajú id záznamu, číslo záznamu, id zamestnanca, dátumy, stav vo workflow, stav vyplatenia, sumu zálohy v mene žiadosti a základnej mene a audit timestamps. Neprenášajú vnorené detaily o výdavkoch / diétach / nákladových strediskách, text destinácie ani voľnotextový popis / správu / poznámky k predbežnému schváleniu.
Keď integrácia partnera potrebuje úplné PII alebo úplné vnorené detaily, stiahne si zdroj cez autentifikované API:
GET /api/v1/employees/{id}pre úplný záznam zamestnanca (e-mail, telefón, adresa, daňové identifikačné číslo, bankový účet).GET /api/v1/travel-entries/{id}pre úplný cestovný záznam (výdavky, diéty, alokácie na nákladové strediská, prílohy, voľnotextové polia).
Toto je rovnaký vzor, aký používa Stripe (telo udalosti = identifikátor; úplný zdroj = autentifikovaný GET). Pull požiadavky sú auditovateľné pre každé volanie voči API kľúču, ktorý ich vydal; webhook pushes opúšťajú locco v momente ich spustenia a nie sú auditovateľne priraditeľné konkrétnemu čitateľovi. Udržiavanie PII mimo push rozhrania udržuje rozsah GDPR pre integráciu partnera úzky a vyhýba sa núteniu endpointov partnera do pozície “musí spracovávať PII pri vstupe do siete”, na ktorú nemusia byť pripravení.
Webhook DTOs sú vo v1 iba na pridávanie (append-only). Nové polia bez PII môžu byť pridané; existujúce polia nebudú odstránené ani premenované. PII polia nebudú pridané do webhook payloadu (toto rozhranie je kontrakt, nielen predvolené nastavenie).
Výber udalostí na odber
Dva bežné tvary integrácie partnerov:
- Synchronizácia s ERP / externým systémom: prihláste sa na odber
entry.created,entry.updated,entry.approvedaentry.paidout. Spolu vám poskytnú kompletný forward-only pohľad na životný cyklus každého cestovného záznamu. Pre tenantov bez schvaľovacieho workflow saentry.approvedjednoducho nikdy nespustí (prípad automatického schválenia prechádza namiesto toho cezentry.updated); pre tenantov s workflow jeentry.approvedvyžadované na zachytenie prechodu do konečného schválenia. V kombinácii s kontraktom na deduplikáciu pomocouLocco-Event-Idvám to umožňuje udržiavať autoritatívny externý záznam bez dopytovania (polling). - Workflow riadené schvaľovaním: prihláste sa na odber
entry.approved, ak sa vaša integrácia zaujíma iba o konečné schválenie (napr. spustenie nadväzujúceho platobného procesu v momente, keď je cestovný záznam plne schválený). Poznámka:entry.approvedsa spúšťa iba pre tenantov, ktorí majú nakonfigurované schvaľovacie workflow s aspoň jedným krokom, ktorý má oprávnených schvaľovateľov. Pre tenantov bez workflow je záznam automaticky schválený pri odoslaní a namiesto toho sa spustíentry.updated.
Tvar obálky
{ "id": "evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7", "type": "entry.approved", "createdAt": "2026-04-23T12:34:56Z", "data": { "id": "...", "entryNumber": "1/26", "employeeId": "...", "departureDate": "2026-04-21", "returnDate": "2026-04-23", "status": 4, "advancePayment": 500.00, "currencyCode": "EUR", "advancePaymentInBaseCurrency": 500.00, "createdAt": "2026-04-20T08:00:00Z", "editedAt": "2026-04-23T12:34:56Z" }}id:evt_nasledované 32 hex znakmi (bez pomlčiek), napr.evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7. Sémanticky ide o rovnakú hodnotu Guid ako v headereLocco-Event-Id, ale v inom formáte reťazca.type: jeden z kánonických reťazcov typov udalostí uvedených vyššie.createdAt: ISO 8601 UTC timestamp, kedy bola udalosť vyvolaná v locco (nie kedy bola doručená).data: webhook-safe zhrnutie pre zdroj udalosti (pozrite si “Kontrakt pre webhook payload” vyššie).
Tvar na sieti (wire shape) je zaregistrovaný v API referencii ako schéma WebhookEnvelope, pričom data sú rozlíšené na základe type: udalosti entry.* prenášajú TravelEntryWebhookDto, udalosti employee.* prenášajú EmployeeWebhookDto. Generátory SDK emitujú typovanú diskriminovanú úniu (discriminated union) z OpenAPI dokumentu, takže partneri používajúci generátor získajú silne typovaný deserializátor namiesto objektu vo voľnom formáte.
Poznámka k formátu: porovnávanie id voči Locco-Event-Id. Hodnota id v obálke je evt_{guid:N} (32 malých hex znakov bez pomlčiek), zatiaľ čo header Locco-Event-Id je kánonický 8-4-4-4-12 hex Guid formát s pomlčkami (napr. b2c3d4e5-f6a7-b8c9-d0e1-f2a3b4c5d6e7). Predstavujú rovnakú hodnotu Guid, ale naivná kontrola rovnosti reťazcov medzi nimi zlyhá. Ak ich chcete porovnať, buď (a) odstráňte predponu evt_ a odstráňte pomlčky z hodnoty headera pred porovnaním reťazcov, alebo (b) parsujte obe ako Guids a porovnajte parsované hodnoty. Formáty na sieti sú stabilné a zámerné. Obálka webhooku od Stripe používa rovnakú skratku evt_... a partneri s existujúcim kódom na overovanie Stripe môžu tento vzor parsovania prevziať doslovne.
Headery
Každý webhook POST obsahuje:
| Header | Popis |
|---|---|
Content-Type | Vždy application/json; charset=utf-8. |
User-Agent | Locco-Webhook/1.0. V prípade potreby na toto naviažte allowlist partnera. |
Locco-Event | Reťazec typu udalosti (napr. entry.approved). Pohodlné smerovanie bez parsovania tela. |
Locco-Event-Id | Stabilný Guid pre idempotenciu. Partneri MUSIA na základe tohto deduplikovať. |
Locco-Signature | t={timestamp},v1={hex-hmac-sha256}. Pozrite si formát podpisu. |
Locco-Delivery-Id | Interné id riadku doručenia, užitočné pri korelácii voči logu doručení cez podporu locco. |
Registrácia odberu
Telo POST /api/v1/webhooks:
{ "url": "https://partner.example.com/hooks/locco", "eventTypes": ["entry.created", "entry.updated", "entry.approved", "entry.paidout"]}Obmedzenia:
urlmusí byť HTTPS. Nešifrované HTTP je odmietnuté pri vytváraní a znova pri odosielaní ako hĺbková ochrana (defense in depth).urlsa nesmie prekladať na localhost, loopback alebo RFC1918 privátne IP adresy (ochrana proti SSRF).eventTypesnesmie byť prázdne a každý záznam musí byť v kánonickom katalógu.
Telo odpovede vracia podpisový secret presne raz ako signingSecret. Okamžite si ho uložte. Neexistuje žiadny spôsob, ako obnoviť stratený secret z následnej požiadavky. Secret sa nevracia pri GET /api/v1/webhooks ani GET /api/v1/webhooks/{id}.
Log doručení
Skontrolujte, čo bolo odoslané a čo sa vrátilo, prostredníctvom:
- V aplikácii: Settings → Integrations → Webhooks (rozbaľovací riadok pre každé doručenie s úplným payloadom a skráteným telom odpovede).
- API (vnorené pod nadradeným odberom):
GET /api/v1/webhooks/{id}/deliveries: zoznam doručení pre odber. Podporuje query parametrepage,pageSize,status,eventType.GET /api/v1/webhooks/{id}/deliveries/{deliveryId}: načítanie jedného doručení vrátane úplných podpísaných bajtov payloadu a skráteného tela odpovede partnera. Vráti 404, ak doručenie existuje, ale patrí inému odberu (skúmanie naprieč odbermi nie je povolené).
Manuálne retry: POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. Platné iba pre doručenia v stave Failed alebo Exhausted. Retry zachováva pôvodné Locco-Event-Id a bajty payloadu, takže úložiská partnerov pre deduplikáciu stále fungujú správne.
Uchovávanie. Riadky doručení (vrátane zaznamenaných bajtov payloadu) sú po 30 dňoch hard-deletované každodenným cleanup jobom. Partneri, ktorí potrebujú trvalý audit trail každej udalosti, si musia pri prijatí zaznamenať vlastnú kópiu; endpoint pre log doručení slúži na krátkodobú pozorovateľnosť a replay, nie na dlhodobý archív. V kombinácii s projekciou webhook payloadu zbavenou PII (bajty payloadu prenášajú id zdrojov a minimálny snapshot pre zobrazenie, nikdy nie PII), historická kontrola cez tento endpoint nemôže uniknúť informácie nad rámec identifikátorov zdrojov.