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/webhooks in 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 parametra t= (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

javascript
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;
}
python
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 True
csharp
using 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-Id kot 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.

PoskusZakasnitev od prejšnjega poskusaKumulativni čas od poskusa 1
1 (začetni)(ni)0
230 sekund30s
31 minuta1m 30s
45 minut6m 30s
515 minut21m 30s
61 ura~1h 21m
73 ure~4h 21m
812 ur~16h 21m
9 (zadnji)24 ur~40h 21m

Ponovi ali prekini

Partnerjev odgovorVedenje
2xxUspeh. 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.
5xxNapaka 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:

  1. Preglejte dnevnik dostav, da ugotovite, kaj je partnerjev endpoint vračal med okvarami.
  2. Popravite partnerjev endpoint.
  3. 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 dogodkaSproži se, kodata payload
entry.approvedPotni 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.paidoutPotni nalog je označen kot izplačan v okviru izplačila.Isti webhook-safe povzetek potnega naloga.
entry.createdUstvarjen je nov potni nalog.Isti webhook-safe povzetek potnega naloga.
entry.updatedV 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.createdUstvarjena je nova vrstica zaposlenega.Webhook-safe povzetek zaposlenega (id + ime + pozicija + status + audit timestampi).
employee.updatedObstoječ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, plus createdAt / 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.approved in entry.paidout. Skupaj dajo popolno kronološko evidenco življenjskega cikla vsakega potnega naloga. Za najemnike brez workflowa odobritev se entry.approved enostavno nikoli ne sproži (primer samodejne odobritve gre skozi entry.updated); za najemnike z workflowom je entry.approved potreben za zajetje terminalnega prehoda v odobreno. V kombinaciji s pogodbo o deduplikaciji na Locco-Event-Id to 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.approved se 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ži entry.updated.

Oblika ovojnice

json
{
"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 glava Locco-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:

GlavaOpis
Content-TypeVedno application/json; charset=utf-8.
User-AgentLocco-Webhook/1.0. Po potrebi pripnite partnerske allow-liste na to.
Locco-EventNiz tipa dogodka (npr. entry.approved). Praktično usmerjanje brez razčlenjevanja vsebine.
Locco-Event-IdStabilen Guid za idempotenco. Partnerji MORAJO deduplicirati po tem.
Locco-Signaturet={timestamp},v1={hex-hmac-sha256}. Glej format podpisa.
Locco-Delivery-IdNotranji ID vrstice dostave, uporaben pri usklajevanju z dnevnikom dostav prek locco podpore.

Registracija naročnine

Telo POST /api/v1/webhooks:

json
{
"url": "https://partner.example.com/hooks/locco",
"eventTypes": ["entry.created", "entry.updated", "entry.approved", "entry.paidout"]
}

Omejitve:

  • url mora biti HTTPS. Plaintext HTTP se zavrne pri ustvarjanju in znova ob pošiljanju kot dvojna zaščita.
  • url ne sme razrešiti na localhost, loopback ali RFC1918 zasebne IP naslove (zaščita pred SSRF-om).
  • eventTypes mora 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 poizvedbe page, 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.