Webhooks
locco dostavlja podpisane JSON obvestila o dogodkih na partnerjeve registrirane HTTPS endpointe, ko se v najemniku zgodijo domenski dogodki (potni nalog ustvarjen, potni nalog odobren, potni nalog izplačan, potni nalog posodobljen, zaposleni ustvarjen ali posodobljen). Ta stran je referenca za partnerje: format podpisa, pogodba o zaščiti pred ponovnim pošiljanjem, pričakovanja glede idempotence, politika ponovnih poskusov in kanonični katalog vrst dogodkov.
Push-površina je dvojnik pull-površine /api/v1/*. Vsebina sporočil vključuje iste v1 DTO-je, ki jih GET /api/v1/{resource}/{id} vrne klicatelju z API ključem, zato partnerji za obe površini delajo z eno pogodbo.
Pregled
- Partnerji registrirajo HTTPS endpoint prek
POST /api/v1/webhooksin dobijo enkratno skrivnost za podpisovanje (whsec_...). - locco pošlje POST z JSON-om na ta endpoint, ko se sproži ustrezen dogodek. Telesa so podpisana s HMAC-SHA256.
- Neuspele dostave (5xx, omrežje, timeout) se ponavljajo z eksponentno zakasnitvijo; 4xx prekine ponovne poskuse. Po približno 40 urah neuspelih poskusov (8 ponovitev nad začetno dostavo) se dostava označi kot izčrpana.
- Po 10 zaporednih izčrpanih dostavah se naročnina samodejno deaktivira. Za reaktivacijo se ponovno registrirajte.
- Vgnezden dnevnik dostav (
GET /api/v1/webhooks/{id}/deliveries) in stran Nastavitve → Integracije → Webhooks v aplikaciji prikazujeta, kaj je bilo poslano in kaj je bilo vrnjeno.
Format podpisa
Vsak zahtevek nosi glavo Locco-Signature:
Locco-Signature: t=<unix-timestamp>,v1=<hex-hmac-sha256>HMAC vhod je kanonični niz {timestamp}.{payload}:
{timestamp}: celo število iz parametrat=(Unix epoch sekunde, UTC)..: dobesedni znak pika.{payload}: surovi, nespremenjeni bajti telesa zahtevka. NE serializirajte JSON-a znova pred izračunom. Presledki in vrstni red ključev so pomembni.
Format v celoti zrcali Stripe-ov podpis webhooka (t=...,v1=...), zato lahko partnerji z obstoječim Stripe verifikatorjem prilagodijo kodo le s preimenovanjem glave.
Preverjanje podpisa
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60; // 5 minut, priporočilo po Stripeu
export function verifyLoccoSignature(rawBody, header, secret) { // header izgleda kot: "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. Zaščita pred ponovnim pošiljanjem: zavrni vse, kar je starejše od dovoljenega okna. 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. Ponovno izračunaj HMAC nad kanoničnim vhodom. const signedPayload = `${timestamp}.${rawBody}`; const expected = createHmac('sha256', secret) .update(signedPayload, 'utf8') .digest('hex');
// 3. Primerjava v konstantnem času. 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));}Isti pristop deluje v vsakem jeziku: HMAC-SHA256 nad {timestamp}.{payload} s skrivnostjo kot ključem, hex-kodirano, primerjano v konstantnem času s parametrom v1=.
Zaščita pred ponovnim pošiljanjem (OBVEZNO)
Partnerji MORAJO zavrniti zahtevke, katerih timestamp je starejši od majhnega okna. Priporočena toleranca je 5 minut (ustreza Stripeovi privzeti). Brez te zaščite lahko napadalec, ki prestreže legitimno telo zahtevka in podpis, le-tega neomejeno ponavlja, dokler se skrivnost ne rotira.
Zavrnite:
- Manjkajočo ali nepravilno glavo
Locco-Signature. - Timestamp, starejši od okna tolerance.
- Timestamp znatno v prihodnosti (dovolite majhno mejo za nihanje ure, npr. 30 sekund).
- Izračunan HMAC, ki se ne ujema z
v1=.
Uporabite primerjavo v konstantnem času za preverjanje HMAC (timingSafeEqual v Node.js, hmac.compare_digest v Pythonu, CryptographicOperations.FixedTimeEquals v .NET-u). Enakost nizov (===) pušča časovne informacije in lahko načeloma razkrije podpis bajt za bajtom.
Idempotenca
Vsaka dostava nosi glavo Locco-Event-Id, stabilen Guid, ki identificira logični dogodek. Isti event id se znova pošlje pri:
- Ročnih ponovnih poskusih: partner je kliknil Poskusi znova v vmesniku dnevnika dostav ali poslal POST na
/api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. - At-least-once redelivery: ozadenjski delavec, ki se zruši med pošiljanjem zahtevka in beleženjem uspeha, znova vstavi isto vrstico ob naslednjem prehodu.
Partnerski endpointi MORAJO duplikate Locco-Event-Id obravnavati kot no-op. Standardni vzorec: hranite zbiralnik nedavnih dostav (Redis, mala DB tabela, in-process cache) in zavrnite vsak dogodek, katerega ID je že bil obdelan v zadnji uri.
Brez te deduplikacije se partnerjev nizvodni učinek (sinhronizacija v ERP, pošiljanje obvestil po e-pošti, mutiranje zapisa) izvede dvakrat za isti logični dogodek vsakič, ko pride do ponovnega poskusa.
Semantika ponovnih poskusov je namerna:
- Ponovni poskus pošlje isti
Locco-Event-Idkot izvirna dostava. Partnerji deduplicirajo le po glavi, ne potrebujejo pregledovati vsebine. - Ponovni poskus pošlje iste bajte telesa kot izvirna dostava. Podpis se izračuna znova z novim timestampom, vendar je HMAC vhod identičen, razen tega timestampa.
Politika ponovnih poskusov
locco ponavlja pri prehodnih napakah z eksponentno zakasnitvijo. Urnik je 9 skupnih HTTP poskusov (začetna dostava plus 8 ponovitev), razporejenih čez približno 40 ur.
| Poskus | Zakasnitev od prejšnjega poskusa | Kumulativni čas od poskusa 1 |
|---|---|---|
| 1 (začetni) | (ni) | 0 |
| 2 | 30 sekund | 30s |
| 3 | 1 minuta | 1m 30s |
| 4 | 5 minut | 6m 30s |
| 5 | 15 minut | 21m 30s |
| 6 | 1 ura | ~1h 21m |
| 7 | 3 ure | ~4h 21m |
| 8 | 12 ur | ~16h 21m |
| 9 (zadnji) | 24 ur | ~40h 21m |
Ponovi ali prekini
| Partnerjev odgovor | Vedenje |
|---|---|
2xx | Uspeh. Vrstica dostave označena kot Succeeded. Brez ponavljanja. |
4xx (razen 408, 429) | Napaka na partnerjevi strani. Vrstica označena Failed. Brez ponavljanja. |
408 (Request Timeout), 429 (Too Many) | Obravnavano kot prehodno, ponavlja se po urniku. |
5xx | Napaka strežnika. Ponavlja se po urniku. |
| Omrežna napaka (DNS, TCP reset, TLS handshake) | Ponavlja se po urniku. |
| Timeout (locco odneha po 10 sekundah) | Ponavlja se po urniku. |
Ko je vseh 9 poskusov izčrpanih brez 2xx, se vrstica dostave označi kot Exhausted. Telo zadnjega partnerjevega odgovora (skrajšano na 2 KB) je shranjeno v dnevniku dostav, da lahko partnerji razhroščujejo, kaj je njihov endpoint vračal.
Glave Retry-After od partnerjev se v v1 ne upoštevajo. Vedno velja zgornji urnik.
Samodejna deaktivacija
Če naročnina nakopiči 10 zaporednih dostav v stanju Exhausted, jo locco samodejno deaktivira (IsActive = false, DisabledAt, DisabledReason). S tem se locco ščiti pred neprestanim pošiljanjem na trajno pokvarjen endpoint.
Za ponovno aktivacijo samodejno deaktivirane naročnine:
- Preglejte dnevnik dostav, da ugotovite, kaj je partnerjev endpoint vračal med okvarami.
- Popravite partnerjev endpoint.
- Izbrišite naročnino in ustvarite novo (
POST /api/v1/webhooks).
Tipi dogodkov
v1 katalog. Novi tipi se dodajo s spremembo kode v koraku z novim domenskim dogodkom, zato se partnerji lahko zanesejo, da je ta seznam popoln v katerem koli trenutku med izdajami.
Isti nabor vrednosti je izpostavljen v API referenci kot enum shema WebhookEventType; SDK generatorji iz njega izdajajo tipiziran enum, zato partnerji dobijo compile-time preverjanje za vrednosti, na katere se naročajo.
| Tip dogodka | Sproži se, ko | data payload |
|---|---|---|
entry.approved | Potni nalog je prešel v terminalno stanje Approved skozi korak workflowa. Samodejno odobreni nalogi (podjetja brez workflowa odobritev) ne sprožijo tega dogodka. Glej entry.updated. | Webhook-safe povzetek potnega naloga (id + številka naloga + id zaposlenega + datumi + status + zneski). |
entry.paidout | Potni nalog je označen kot izplačan v okviru izplačila. | Isti webhook-safe povzetek potnega naloga. |
entry.created | Ustvarjen je nov potni nalog. | Isti webhook-safe povzetek potnega naloga. |
entry.updated | V dnevniku revizij potnega naloga je zabeležena sprememba, ki ni ustvarjanje, terminalna odobritev ali izplačilo, oziroma se je nalog spremenil tako, kot platforma šteje za vredno beleženja. Vključuje urejanja, oddajo, vmesne korake odobritev workflowa, preklic, zahtevo po spremembah in spremembe povezanih entitet (stroški, dnevnice, stroškovna mesta, priloge). | Isti webhook-safe povzetek potnega naloga. |
employee.created | Ustvarjena je nova vrstica zaposlenega. | Webhook-safe povzetek zaposlenega (id + ime + pozicija + status + audit timestampi). |
employee.updated | Obstoječa vrstica zaposlenega je posodobljena. | Isti webhook-safe povzetek zaposlenega. |
Pogodba o webhook payloadu: dogodki nosijo identifikatorje, ne PII
Telesa webhook dogodkov nosijo identifikatorje virov in minimalen prikazni snimek — nikoli PII. Konkretno:
- Dogodki zaposlenih nosijo
id,firstName,lastName,position,departmentId,status,isAccountOwner,isArchived, pluscreatedAt/editedAt. Ne nosijo e-pošte, telefona, naslova, OIB-a, IBAN-a ali BIC-a. - Dogodki potnih nalogov nosijo id naloga, številko naloga, id zaposlenega, datume, status workflowa, status izplačila, znesek akontacije v valuti zahtevka in osnovni valuti ter audit timestampe. Ne nosijo vgnezdenih podrobnosti stroškov / dnevnic / stroškovnih mest, besedila cilja ali prosto besedilnih opisov / poročil / opomb pred odobritvijo.
Ko partnerska integracija potrebuje polni PII ali polno vgnezdeno podrobnost, vir potegne prek avtenticiranega API-ja:
GET /api/v1/employees/{id}za polni zapis zaposlenega (e-pošta, telefon, naslov, OIB, IBAN).GET /api/v1/travel-entries/{id}za polni potni nalog (stroški, dnevnice, dodelitve stroškovnih mest, priloge, prosto besedilna polja).
To je isti vzorec, ki ga uporablja Stripe (telo dogodka = identifikator; polni vir = avtenticiran GET). Pull zahtevki so revizijsko sledljivi po klicu k API ključu, ki jih je izdal; webhook pushi zapustijo locco v trenutku sproženja in niso revizijsko pripisljivi konkretnemu bralcu. Držanje PII zunaj push površine drži GDPR obseg partnerske integracije ozek in izogibi prisiljanju partnerskih endpointov v pozicijo “mora ravnati s PII na omrežnem vhodu”, za katero morda niso pripravljeni.
Webhook DTO-ji so append-only v v1 — nova polja brez PII se lahko dodajo; obstoječa polja se ne bodo odstranila niti preimenovala. PII polja se ne bodo dodajala v webhook payload — ta površina je pogodba, ne le privzeta vrednost.
Izbira dogodkov za naročnino
Dve običajni obliki partnerske integracije:
- ERP / sinhronizacija z zunanjim sistemom: naročite se na
entry.created,entry.updated,entry.approvedinentry.paidout. Skupaj dajo popolno kronološko evidenco življenjskega cikla vsakega potnega naloga. Za najemnike brez workflowa odobritev seentry.approvedenostavno nikoli ne sproži (primer samodejne odobritve gre skozientry.updated); za najemnike z workflowom jeentry.approvedpotreben za zajetje terminalnega prehoda v odobreno. V kombinaciji s pogodbo o deduplikaciji naLocco-Event-Idto omogoča vzdrževanje verodostojne zunanje evidence brez polljanja. - Tok, voden z odobritvijo: naročite se na
entry.approved, če vašo integracijo zanima samo terminalna odobritev (npr. sproženje nizvodnega procesa plačila v trenutku, ko je potni nalog v celoti odobren). Opomba:entry.approvedse sproži samo za najemnike, ki imajo konfiguriran workflow odobritev z vsaj enim korakom, ki ima ustrezne odobritelje. Za najemnike brez workflowa se nalog samodejno odobri ob oddaji in se namesto tega sprožientry.updated.
Oblika ovojnice
{ "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_z 32 hex znaki (brez pomišljajev), npr.evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7. Semantično ista Guid vrednost kot glavaLocco-Event-Id, vendar v drugačnem string formatu.type: ena od kanoničnih nizov tipa dogodka zgoraj.createdAt: ISO 8601 UTC timestamp, ko je dogodek nastal v loccu (ne ko je bil dostavljen).data: webhook-safe povzetek vira dogodka (glej “Pogodba o webhook payloadu” zgoraj).
Oblika na žici je registrirana v API referenci kot shema WebhookEnvelope, pri čemer je data diskriminiran glede na type: entry.* dogodki nosijo TravelEntryWebhookDto, employee.* dogodki pa EmployeeWebhookDto. SDK generatorji iz OpenAPI dokumenta izdajajo tipizirano diskriminirano unijo, zato partnerji, ki uporabljajo generator, dobijo strogo tipiziran deserializator namesto prostega object.
Opomba o formatu: primerjava id z Locco-Event-Id. id v ovojnici je evt_{guid:N} (32 malih hex znakov brez pomišljajev), medtem ko je glava Locco-Event-Id v kanonični obliki Guid 8-4-4-4-12 s pomišljaji (npr. b2c3d4e5-f6a7-b8c9-d0e1-f2a3b4c5d6e7). Predstavljata isto Guid vrednost, vendar naivna primerjava niza ne bo uspela. Za primerjavo bodisi (a) odstranite predpono evt_ in odstranite pomišljaje iz vrednosti glave pred primerjavo, ali (b) razčlenite oba kot Guida in primerjajte razčlenjeni vrednosti. Formati na žici so stabilni in namerni. Stripeova webhook ovojnica uporablja isto skrajšano obliko evt_..., zato lahko partnerji z obstoječim Stripe verifikatorjem dobesedno prenesejo vzorec razčlenjevanja.
Glave
Vsak webhook POST nosi:
| Glava | Opis |
|---|---|
Content-Type | Vedno application/json; charset=utf-8. |
User-Agent | Locco-Webhook/1.0. Po potrebi pripnite partnerske allow-liste na to. |
Locco-Event | Niz tipa dogodka (npr. entry.approved). Praktično usmerjanje brez razčlenjevanja vsebine. |
Locco-Event-Id | Stabilen Guid za idempotenco. Partnerji MORAJO deduplicirati po tem. |
Locco-Signature | t={timestamp},v1={hex-hmac-sha256}. Glej format podpisa. |
Locco-Delivery-Id | Notranji ID vrstice dostave, uporaben pri usklajevanju z dnevnikom dostav prek locco podpore. |
Registracija naročnine
Telo POST /api/v1/webhooks:
{ "url": "https://partner.example.com/hooks/locco", "eventTypes": ["entry.created", "entry.updated", "entry.approved", "entry.paidout"]}Omejitve:
urlmora biti HTTPS. Plaintext HTTP se zavrne pri ustvarjanju in znova ob pošiljanju kot dvojna zaščita.urlne sme razrešiti na localhost, loopback ali RFC1918 zasebne IP naslove (zaščita pred SSRF-om).eventTypesmora biti neprazno in vsak element mora biti v kanoničnem katalogu.
Telo odgovora vrne skrivnost za podpisovanje natanko enkrat kot signingSecret. Takoj jo shranite. Ne obstaja način, da bi se izgubljena skrivnost obnovila z naknadnim zahtevkom. Skrivnost se ne vrne v GET /api/v1/webhooks niti v GET /api/v1/webhooks/{id}.
Dnevnik dostav
Preglejte, kaj je bilo poslano in kaj je bilo vrnjeno, prek:
- V aplikaciji: Nastavitve → Integracije → Webhooks (razširljiva vrstica po dostavi, s polno vsebino in skrajšanim telesom odgovora).
- API (vgnezdeno pod nadrejeno naročnino):
GET /api/v1/webhooks/{id}/deliveries: seznam dostav za naročnino. Podpira parametre poizvedbepage,pageSize,status,eventType.GET /api/v1/webhooks/{id}/deliveries/{deliveryId}: pridobi posamezno dostavo, vključno s polnimi podpisanimi bajti vsebine in skrajšanim telesom partnerjevega odgovora. Vrne 404, če dostava obstaja, vendar pripada drugi naročnini (preboj med naročninami ni dovoljen).
Ročni ponovni poskus: POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. Velja samo za dostave v stanju Failed ali Exhausted. Ponovni poskus ohrani izvirni Locco-Event-Id in bajte vsebine, zato partnerske dedupe shrambe še naprej delujejo pravilno.
Retencija. Vrstice dostav — vključno z zabeleženimi bajti vsebine — se hard-deletajo po 30 dneh z dnevnim cleanup poslom. Partnerji, ki potrebujejo trajno revizijsko sled vsakega dogodka, morajo ob prejemu zabeležiti svojo kopijo; endpoint dnevnika dostav služi kratkoročni opazljivosti in replayu, ne dolgoročnemu arhivu. V kombinaciji s PII-stripped projekcijo webhook payloada (bajti vsebine nosijo identifikatorje virov in minimalen prikazni snimek, nikoli PII) zgodovinski pregled prek tega endpointa ne more puščati informacij zunaj identifikatorjev virov.