Webhooks
locco doručuje podepsaná JSON oznámení o událostech na HTTPS endpointy registrované partnery, když v tenantu dojde k doménovým událostem (záznam vytvořen, záznam schválen, záznam vyplacen, záznam aktualizován, zaměstnanec vytvořen nebo aktualizován). Tato stránka slouží jako reference pro partnery: formát podpisu, pravidla pro ochranu proti replay útokům, očekávání ohledně idempotence, zásady pro opakování (retry) a kanonický katalog typů událostí.
Push rozhraní je protějškem k pull /api/v1/* API. Payloady obsahují stejné v1 DTO objekty, které GET /api/v1/{resource}/{id} vrací volajícímu s API klíčem, takže partneři pracují s jedním kontraktem pro obě rozhraní.
Přehled
- Partneři zaregistrují HTTPS endpoint pomocí
POST /api/v1/webhooksa obdrží jednorázový podpisový klíč (whsec_...). - locco odesílá JSON pomocí POST na tento endpoint, když nastanou odpovídající události. Těla požadavků jsou podepsána pomocí HMAC-SHA256.
- Neúspěšná doručení (5xx, chyby sítě, timeout) se opakují pomocí exponential back-off, zatímco 4xx opakování ukončí. Po přibližně 40 hodinách neúspěšných pokusů (8 opakování nad rámec původního pokusu) je doručení označeno jako vyčerpané.
- Po 10 po sobě jdoucích vyčerpaných doručeních se odběr automaticky deaktivuje. Pro opětovnou aktivaci je nutná nová registrace.
- Vnořený log doručení (
GET /api/v1/webhooks/{id}/deliveries) a stránka Settings → Integrations → Webhooks v aplikaci ukazují, co bylo odesláno a co se vrátilo.
Formát podpisu
Každý požadavek obsahuje header Locco-Signature:
Locco-Signature: t=<unix-timestamp>,v1=<hex-hmac-sha256>Vstupem pro HMAC je kanonický řetězec {timestamp}.{payload}:
{timestamp}: celé číslo v parametrut=(sekundy unixového času, UTC)..: doslovný znak tečky.{payload}: surové, neupravené bajty těla požadavku. Před výpočtem JSON znovu neserializujte. Na bílých znacích a pořadí klíčů záleží.
Tento formát přesně kopíruje webhook podpis služby Stripe (t=...,v1=...), takže partneři s existujícím kódem pro ověřování Stripe jej mohou přizpůsobit pouhým přejmenováním headeru.
Ověření 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));}Stejný postup funguje v jakémkoli jazyce: HMAC-SHA256 z {timestamp}.{payload} s tajným klíčem, zakódovaný jako hex a porovnaný v konstantním čase s parametrem v1=.
Ochrana proti replay útokům (VYŽADOVÁNO)
Partneři MUSÍ odmítnout požadavky, jejichž timestamp je starší než malé časové okno. Doporučená tolerance je 5 minut (odpovídá výchozí hodnotě Stripe). Bez této kontroly může útočník, který zachytí legitimní tělo požadavku a podpis, provádět replay útoky donekonečna, dokud nedojde k rotaci tajného klíče.
Odmítněte:
- Chybějící nebo chybně formátovaný header
Locco-Signature. - Timestamp starší než okno tolerance.
- Timestamp výrazně v budoucnosti (povolte malou rezervu pro odchylku hodin, např. 30 sekund).
- Vypočítaný HMAC, který neodpovídá
v1=.
Pro kontrolu HMAC použijte porovnání v konstantním čase (timingSafeEqual v Node.js, hmac.compare_digest v Pythonu, CryptographicOperations.FixedTimeEquals v .NET). Běžná rovnost řetězců (===) prozrazuje informace o časování a může v principu odhalit podpis bajt po bajtu.
Idempotence
Každé doručení obsahuje header Locco-Event-Id, což je stabilní Guid identifikující logickou událost. Stejné id události je odesláno znovu při:
- Manuálních opakováních: partner klikl na Retry v uživatelském rozhraní logu doručení, nebo odeslal POST na
/api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. - At-least-once redelivery: proces na pozadí, který spadne mezi odesláním požadavku a zaznamenáním úspěchu, znovu zařadí stejný řádek do fronty při dalším dotazování.
Partnerské endpointy MUSÍ zpracovávat duplicitní příchozí Locco-Event-Id jako no-ops. Standardní vzor je udržovat úložiště nedávných doručení (Redis, malá databázová tabulka, in-process cache) a odmítnout jakoukoli událost, jejíž id již bylo zpracováno zhruba v poslední hodině.
Bez této deduplikace se partnerův navazující vedlejší efekt (synchronizace do ERP, odeslání e-mailového oznámení, změna záznamu) spustí dvakrát pro stejnou logickou událost, kdykoli dojde k opakování.
Sémantika opakování je záměrná:
- Opakování odesílá stejné
Locco-Event-Idjako původní doručení. Partneři provádějí deduplikaci pouze na základě tohoto headeru, nepotřebují zkoumat payload. - Opakování odesílá stejné bajty těla jako původní doručení. Podpis je přepočítán s novým timestamp, ale vstup pro HMAC je kromě tohoto timestamp identický.
Zásady pro opakování
locco opakuje pokusy při přechodných chybách pomocí exponential back-off. Plán zahrnuje celkem 9 HTTP pokusů (původní doručení plus 8 opakování) rozložených do přibližně 40 hodin od začátku do konce.
| Pokus | Zpoždění od předchozího pokusu | Kumulativní čas od pokusu 1 |
|---|---|---|
| 1 (počáteční) | (žádné) | 0 |
| 2 | 30 sekund | 30s |
| 3 | 1 minuta | 1m 30s |
| 4 | 5 minut | 6m 30s |
| 5 | 15 minut | 21m 30s |
| 6 | 1 hodina | ~1h 21m |
| 7 | 3 hodiny | ~4h 21m |
| 8 | 12 hodin | ~16h 21m |
| 9 (konečný) | 24 hodin | ~40h 21m |
Opakování vs ukončení
| Odpověď partnera | Chování |
|---|---|
2xx | Úspěch. Řádek doručení označen jako Succeeded. Bez opakování. |
4xx (kromě 408, 429) | Chyba na straně partnera. Řádek doručení označen jako Failed. Bez opakování. |
408 (Request Timeout), 429 (Too Many) | Považováno za přechodné, opakování podle plánu. |
5xx | Chyba serveru. Opakování podle plánu. |
| Chyba sítě (DNS, TCP reset, TLS handshake) | Opakování podle plánu. |
| Timeout (locco to vzdá po 10 sekundách) | Opakování podle plánu. |
Po vyčerpání všech 9 pokusů bez odpovědi 2xx je řádek doručení označen jako Exhausted. Tělo poslední odpovědi partnera (zkrácené na 2 KB) je zachováno v logu doručení, aby partneři mohli ladit, co jejich endpoint vracel.
Headery Retry-After od partnerů nejsou ve v1 respektovány. Vždy platí výše uvedený plán.
Automatická deaktivace
Pokud odběr nashromáždí 10 po sobě jdoucích doručení ve stavu Exhausted, locco jej automaticky deaktivuje (IsActive = false, DisabledAt, DisabledReason). To chrání locco před neustálým dotazováním trvale nefunkčního endpointu.
Pro opětovnou aktivaci automaticky deaktivovaného odběru:
- Zkontrolujte log doručení a zjistěte, co partnerský endpoint vracel během selhání.
- Opravte partnerský endpoint.
- Smažte odběr a vytvořte nový (
POST /api/v1/webhooks).
Typy událostí
Katalog v1. Nové typy jsou přidávány změnou kódu současně s novou doménovou událostí, takže se partneři mohou spolehnout, že tento seznam představuje kompletní sadu pro dané vydání.
Stejná sada je vystavena v API reference jako enum schéma WebhookEventType. Generátory SDK z tohoto seznamu vytvářejí typový enum, takže partneři získají compile-time bezpečnost pro hodnoty, k jejichž odběru se přihlásí.
| Typ události | Spouští se, když | data payload |
|---|---|---|
entry.approved | Cestovní záznam přešel do konečného stavu Approved prostřednictvím kroku workflow. Automaticky schválené záznamy (společnosti bez schvalovacího workflow) tuto událost nespouštějí. Viz entry.updated. | Webhook-safe shrnutí cestovního záznamu (id + číslo záznamu + id zaměstnance + data + stav + částky). |
entry.paidout | Cestovní záznam byl označen jako vyplacený v rámci dávky výplat. | Stejné webhook-safe shrnutí cestovního záznamu. |
entry.created | Byl vytvořen nový cestovní záznam. | Stejné webhook-safe shrnutí cestovního záznamu. |
entry.updated | Audit log cestovního záznamu obdržel záznam, který není vytvořením, konečným schválením ani vyplacením, tj. záznam se změnil způsobem, který platforma považuje za hodný zaznamenání. Zahrnuje úpravy, odeslání, průběžná schválení ve workflow, zrušení, žádosti o změny a změny podřízených entit (výdaje, diety, nákladová střediska, přílohy). | Stejné webhook-safe shrnutí cestovního záznamu. |
employee.created | Byl vytvořen nový řádek zaměstnance. | Webhook-safe shrnutí zaměstnance (id + jméno + pozice + stav + audit timestamps). |
employee.updated | Existující řádek zaměstnance byl aktualizován. | Stejné webhook-safe shrnutí zaměstnance. |
Kontrakt webhook payloadu: události nesou identifikátory, nikoli PII
Těla webhook událostí obsahují identifikátory zdrojů a minimální snímek pro zobrazení, nikdy PII. Konkrétně:
- Události zaměstnanců obsahují
id,firstName,lastName,position,departmentId,status,isAccountOwner,isArchiveda navíccreatedAt/editedAt. Neobsahují e-mail, telefon, adresu, daňové identifikační číslo (OIB), bankovní účet (IBAN) ani BIC. - Události cestovních záznamů obsahují id záznamu, číslo záznamu, id zaměstnance, data, stav workflow, stav vyplacení, částku zálohy v měně požadavku a základní měně a audit timestamps. Neobsahují vnořené detaily výdajů / diet / nákladových středisek, text destinace ani volnotextový popis / zprávu / poznámky k předběžnému schválení.
Když partnerská integrace potřebuje kompletní PII nebo úplné vnořené detaily, stáhne si zdroj přes autentizované API:
GET /api/v1/employees/{id}pro úplný záznam zaměstnance (e-mail, telefon, adresa, daňové identifikační číslo, bankovní účet).GET /api/v1/travel-entries/{id}pro úplný cestovní záznam (výdaje, diety, alokace na nákladová střediska, přílohy, volnotextová pole).
Jedná se o stejný vzor, jaký používá Stripe (tělo události = identifikátor; úplný zdroj = autentizovaný GET). Pull požadavky jsou auditovatelné pro každé volání vůči API klíči, který je vydal. Webhook push oznámení opouštějí locco v okamžiku spuštění a nelze je z hlediska auditu přiřadit konkrétnímu čtenáři. Udržování PII mimo push rozhraní udržuje rozsah GDPR partnerské integrace úzký a nenutí partnerské endpointy do pozice “musí zpracovávat PII na vstupu do sítě”, na kterou nemusí být připraveny.
Webhook DTO objekty jsou ve v1 pouze pro přidávání (append-only). Mohou být přidána nová pole bez PII, ale existující pole nebudou odstraněna ani přejmenována. PII pole nebudou do webhook payloadu přidávána, protože toto rozhraní je kontrakt, nikoli pouze výchozí nastavení.
Výběr událostí k odběru
Dva běžné modely partnerské integrace:
- Synchronizace s ERP / externím systémem: přihlaste se k odběru
entry.created,entry.updated,entry.approvedaentry.paidout. Společně vám poskytnou kompletní dopředný pohled na životní cyklus každého cestovního záznamu. U tenantů bez schvalovacího workflow seentry.approvedjednoduše nikdy nespustí (případ automatického schválení prochází místo toho přesentry.updated). U tenantů s workflow jeentry.approvedvyžadováno k zachycení konečného přechodu schválení. V kombinaci s deduplikačním kontraktem naLocco-Event-Idvám to umožní udržovat autoritativní externí záznam bez nutnosti dotazování (polling). - Workflow řízené schvalováním: přihlaste se k odběru
entry.approved, pokud vaši integraci zajímá pouze konečné schválení (např. spuštění navazujícího platebního procesu v okamžiku, kdy je cestovní záznam plně schválen). Poznámka:entry.approvedse spouští pouze u tenantů, kteří mají nakonfigurované schvalovací workflow s alespoň jedním krokem, který má oprávněné schvalovatele. U tenantů bez workflow je záznam automaticky schválen při odeslání a místo toho se 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_následované 32 hex znaky (bez pomlček), např.evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7. Sémanticky se jedná o stejnou Guid hodnotu jako v headeruLocco-Event-Id, ale v jiném formátu řetězce.type: jeden z výše uvedených kanonických řetězců typu události.createdAt: ISO 8601 UTC timestamp okamžiku, kdy byla událost vyvolána v locco (nikoli kdy byla doručena).data: webhook-safe shrnutí pro zdroj události (viz “Kontrakt webhook payloadu” výše).
Tvar pro přenos je registrován v API reference jako schéma WebhookEnvelope, kde jsou data rozlišována podle type: události entry.* nesou TravelEntryWebhookDto, události employee.* nesou EmployeeWebhookDto. Generátory SDK vytvářejí z OpenAPI dokumentu typovou diskriminovanou unii, takže partneři používající generátor získají silně typovaný deserializátor místo objektu volného formátu.
Poznámka k formátu: porovnání id s Locco-Event-Id. Hodnota id v obálce je evt_{guid:N} (32 malých hex znaků bez pomlček), zatímco header Locco-Event-Id má kanonický 8-4-4-4-12 hex Guid formát s pomlčkami (např. b2c3d4e5-f6a7-b8c9-d0e1-f2a3b4c5d6e7). Představují stejnou Guid hodnotu, ale naivní kontrola rovnosti řetězců mezi nimi selže. Chcete-li je porovnat, buď (a) odstraňte předponu evt_ a odstraňte pomlčky z hodnoty headeru před porovnáním řetězců, nebo (b) naparsujte obojí jako Guid a porovnejte naparsované hodnoty. Formáty pro přenos jsou stabilní a záměrné. Webhook obálka služby Stripe používá stejnou zkratku evt_... a partneři s existujícím kódem pro ověřování Stripe mohou tento vzor parsování převzít doslovně.
Headery
Každý webhook POST obsahuje:
| Header | Popis |
|---|---|
Content-Type | Vždy application/json; charset=utf-8. |
User-Agent | Locco-Webhook/1.0. V případě potřeby na toto navažte partnerský allowlist. |
Locco-Event | Řetězec typu události (např. entry.approved). Pohodlné směrování bez nutnosti parsovat tělo. |
Locco-Event-Id | Stabilní Guid pro idempotenci. Partneři MUSÍ na jeho základě provádět deduplikaci. |
Locco-Signature | t={timestamp},v1={hex-hmac-sha256}. Viz formát podpisu. |
Locco-Delivery-Id | Interní id řádku doručení, užitečné při korelaci s logem doručení přes podporu locco. |
Registrace odběru
Tělo POST /api/v1/webhooks:
{ "url": "https://partner.example.com/hooks/locco", "eventTypes": ["entry.created", "entry.updated", "entry.approved", "entry.paidout"]}Omezení:
urlmusí být HTTPS. Nešifrované HTTP je odmítnuto při vytváření a znovu při odesílání jako hloubková obrana (defense in depth).urlse nesmí překládat na localhost, loopback nebo privátní IP adresy podle RFC1918 (ochrana proti SSRF).eventTypesnesmí být prázdné a každá položka musí být v kanonickém katalogu.
Tělo odpovědi vrací podpisový tajný klíč přesně jednou jako signingSecret. Okamžitě si jej uložte. Ztracený tajný klíč nelze z následného požadavku nijak obnovit. Tajný klíč není vrácen pomocí GET /api/v1/webhooks ani GET /api/v1/webhooks/{id}.
Log doručení
Zkontrolujte, co bylo odesláno a co se vrátilo, prostřednictvím:
- V aplikaci: Settings → Integrations → Webhooks (rozbalovací řádek pro každé doručení s úplným payloadem a zkráceným tělem odpovědi).
- API (vnořené pod nadřazeným odběrem):
GET /api/v1/webhooks/{id}/deliveries: vypíše doručení pro daný odběr. Podporuje query parametrypage,pageSize,status,eventType.GET /api/v1/webhooks/{id}/deliveries/{deliveryId}: načte jedno doručení, včetně úplných podepsaných bajtů payloadu a zkráceného těla odpovědi partnera. Vrací 404, pokud doručení existuje, ale patří k jinému odběru (zkoumání napříč odběry není povoleno).
Manuální opakování: POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. Platí pouze pro doručení ve stavu Failed nebo Exhausted. Opakování zachovává původní Locco-Event-Id a bajty payloadu, takže deduplikační úložiště partnerů stále fungují správně.
Uchovávání. Řádky doručení, včetně zaznamenaných bajtů payloadu, jsou po 30 dnech hard-deletovány každodenním cleanup procesem. Partneři, kteří potřebují trvalý auditní záznam každé události, si musí při přijetí uložit vlastní kopii. Endpoint logu doručení slouží ke krátkodobému sledování a opakování (replay), nikoli k dlouhodobé archivaci. V kombinaci s projekcí webhook payloadu zbavenou PII (bajty payloadu nesou id zdrojů a minimální snímek pro zobrazení, nikdy PII) nemůže historická kontrola prostřednictvím tohoto endpointu uniknout informace nad rámec identifikátorů zdrojů.