Webhooks

locco isporučuje potpisane JSON obavijesti o događajima na partnerski registrirane HTTPS endpointe kada se u tenantu dogode domenski događaji (putni unos kreiran, putni unos odobren, putni unos isplaćen, putni unos ažuriran, zaposlenik kreiran ili ažuriran). Ova stranica je referenca za partnere: format potpisa, ugovor o zaštiti od ponovnog slanja, očekivanja vezana uz idempotenciju, politika ponovnih pokušaja i kanonski katalog tipova događaja.

Push-površina je dvojnik pull-površine /api/v1/*. Sadržaj poruka uključuje iste v1 DTO-e koje GET /api/v1/{resource}/{id} vraća pozivatelju s API ključem, pa partneri rade s jednim ugovorom za obje površine.

Pregled

  • Partneri registriraju HTTPS endpoint preko POST /api/v1/webhooks i dobivaju jednokratnu tajnu za potpisivanje (whsec_...).
  • locco šalje POST s JSON-om na taj endpoint kada se okine odgovarajući događaj. Tijela su potpisana s HMAC-SHA256.
  • Neuspjele isporuke (5xx, mreža, timeout) ponavljaju se s eksponencijalnom odgodom; 4xx prekida ponovne pokušaje. Nakon ~40 sati neuspjelih pokušaja (8 ponavljanja iznad početne isporuke) isporuka se označava kao iscrpljena.
  • Nakon 10 uzastopnih iscrpljenih isporuka, pretplata se automatski deaktivira. Registrirajte ponovno za reaktivaciju.
  • Ugniježđeni dnevnik isporuka (GET /api/v1/webhooks/{id}/deliveries) i stranica Postavke → Integracije → Webhooks u aplikaciji pokazuju što je poslano i što je vraćeno.

Format potpisa

Svaki zahtjev nosi zaglavlje Locco-Signature:

Locco-Signature: t=<unix-timestamp>,v1=<hex-hmac-sha256>

HMAC ulaz je kanonski string {timestamp}.{payload}:

  • {timestamp}: cijeli broj iz parametra t= (Unix epoch sekunde, UTC).
  • .: doslovan znak točka.
  • {payload}: sirovi, neizmijenjeni bajtovi tijela zahtjeva. NEMOJTE ponovno serijalizirati JSON prije računanja. Razmaci i poredak ključeva su važni.

Format zrcali Stripeov potpis webhooka u potpunosti (t=...,v1=...), pa partneri s postojećim Stripe verifikatorom mogu prilagoditi kod samo preimenovanjem zaglavlja.

Provjera potpisa

javascript
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60; // 5 minuta, preporuka po Stripeu
export function verifyLoccoSignature(rawBody, header, secret) {
// header izgleda kao: "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štita od ponovnog slanja: odbij sve starije od dopuštenog prozora.
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 kanonskim ulazom.
const signedPayload = `${timestamp}.${rawBody}`;
const expected = createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Usporedba u konstantnom vremenu.
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 pristup radi u svakom jeziku: HMAC-SHA256 nad {timestamp}.{payload} s tajnom kao ključem, hex-kodirano, uspoređeno u konstantnom vremenu s parametrom v1=.

Zaštita od ponovnog slanja (OBAVEZNO)

Partneri MORAJU odbiti zahtjeve čiji je timestamp stariji od malog prozora. Preporučena tolerancija je 5 minuta (odgovara Stripeovoj zadanoj). Bez ove provjere, napadač koji uhvati legitimno tijelo zahtjeva i potpis može ga ponavljati neograničeno dok god se tajna ne rotira.

Odbijte:

  • Nedostajuće ili neispravno zaglavlje Locco-Signature.
  • Timestamp stariji od prozora tolerancije.
  • Timestamp značajno u budućnosti (dopustite malu marginu za skretanje sata, npr. 30 sekundi).
  • Izračunati HMAC koji se ne podudara s v1=.

Koristite usporedbu u konstantnom vremenu za HMAC provjeru (timingSafeEqual u Node.js-u, hmac.compare_digest u Pythonu, CryptographicOperations.FixedTimeEquals u .NET-u). Jednakost stringova (===) curi vremenske informacije i u principu može otkriti potpis bajt po bajt.

Idempotencija

Svaka isporuka nosi zaglavlje Locco-Event-Id, stabilan Guid koji identificira logički događaj. Isti event id šalje se ponovno kod:

  • Ručnih ponovnih pokušaja: partner je kliknuo Pokušaj ponovno u sučelju dnevnika isporuka, ili je poslao POST na /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry.
  • At-least-once redelivery: pozadinski radnik koji se sruši između slanja zahtjeva i bilježenja uspjeha ponovno ubacuje isti redak na sljedećem prolazu.

Partnerski endpointi MORAJU duplikate Locco-Event-Id tretirati kao no-op. Standardni obrazac: čuvajte spremnik nedavnih isporuka (Redis, mala DB tablica, in-process cache) i odbijte svaki događaj čiji je ID već obrađen u zadnjih sat vremena.

Bez ove deduplikacije, partnerov nizvodni efekt (sinkronizacija u ERP, slanje obavijesti emailom, mutiranje zapisa) izvršit će se dvaput za isti logički događaj kad god dođe do ponovnog pokušaja.

Semantika ponovnih pokušaja je namjerna:

  • Ponovni pokušaj šalje isti Locco-Event-Id kao i originalna isporuka. Partneri dedupiraju samo po zaglavlju, ne moraju pregledavati sadržaj.
  • Ponovni pokušaj šalje iste bajtove tijela kao i originalna isporuka. Potpis se izračunava iznova s novim timestampom, ali HMAC ulaz je identičan osim tog timestampa.

Politika ponovnih pokušaja

locco ponavlja kod prolaznih grešaka s eksponencijalnom odgodom. Raspored je 9 ukupnih HTTP pokušaja (početna isporuka plus 8 ponavljanja) raspoređenih kroz ~40 sati.

PokušajOdgoda od prethodnog pokušajaKumulativno vrijeme od pokušaja 1
1 (početni)(nema)0
230 sekundi30s
31 minuta1m 30s
45 minuta6m 30s
515 minuta21m 30s
61 sat~1h 21m
73 sata~4h 21m
812 sati~16h 21m
9 (zadnji)24 sata~40h 21m

Ponovni pokušaj ili prekid

Partnerov odgovorPonašanje
2xxUspjeh. Redak isporuke označen kao Succeeded. Bez ponavljanja.
4xx (osim 408, 429)Greška na partnerovoj strani. Redak označen Failed. Bez ponavljanja.
408 (Request Timeout), 429 (Too Many)Tretirano kao prolazno, ponavlja se prema rasporedu.
5xxGreška servera. Ponavlja se prema rasporedu.
Mrežna greška (DNS, TCP reset, TLS handshake)Ponavlja se prema rasporedu.
Timeout (locco odustaje nakon 10 sekundi)Ponavlja se prema rasporedu.

Nakon što je svih 9 pokušaja iscrpljeno bez 2xx, redak isporuke označava se kao Exhausted. Tijelo zadnjeg partnerovog odgovora (skraćeno na 2 KB) sačuvano je u dnevniku isporuka kako bi partneri mogli debugirati što je njihov endpoint vraćao.

Zaglavlja Retry-After od partnera ne uvažavaju se u v1. Gore navedeni raspored uvijek vrijedi.

Automatska deaktivacija

Ako pretplata akumulira 10 uzastopnih isporuka u stanju Exhausted, locco je automatski deaktivira (IsActive = false, DisabledAt, DisabledReason). Time se locco štiti od neprestanog gađanja trajno pokvarenog endpointa.

Za ponovno aktiviranje automatski deaktivirane pretplate:

  1. Pregledajte dnevnik isporuka kako biste utvrdili što je partnerov endpoint vraćao tijekom kvarova.
  2. Popravite partnerov endpoint.
  3. Obrišite pretplatu i kreirajte novu (POST /api/v1/webhooks).

Tipovi događaja

v1 katalog. Novi tipovi dodaju se promjenom koda u koraku s novim domenskim događajem, pa se partneri mogu osloniti da je ovaj popis potpun u bilo kojem trenutku između izdanja.

Isti skup vrijednosti izložen je u API referenci kao WebhookEventType enum schema; SDK generatori iz njega emitiraju tipiziran enum, pa partneri dobiju compile-time provjeru za vrijednosti na koje se pretplaćuju.

Tip događajaOkida se kadadata payload
entry.approvedPutni unos je prešao u terminalno stanje Approved kroz korak workflowa. Automatski odobreni unosi (tvrtke bez workflowa odobrenja) ne okidaju ovaj događaj. Vidi entry.updated.Webhook-safe sažetak putnog unosa (id + broj unosa + id zaposlenika + datumi + status + iznosi).
entry.paidoutPutni unos je označen kao isplaćen u sklopu serije isplata.Isti webhook-safe sažetak putnog unosa.
entry.createdKreiran je novi putni unos.Isti webhook-safe sažetak putnog unosa.
entry.updatedU dnevniku revizija putnog unosa zabilježena je promjena koja nije kreiranje, terminalno odobrenje ili isplata, odnosno unos se promijenio na način koji platforma smatra vrijednim bilježenja. Uključuje izmjene, predaju, posredne korake odobrenja workflowa, otkazivanje, traženje izmjena i promjene povezanih entiteta (troškovi, dnevnice, mjesta troška, privitci).Isti webhook-safe sažetak putnog unosa.
employee.createdKreiran je novi redak zaposlenika.Webhook-safe sažetak zaposlenika (id + ime + pozicija + status + audit timestampovi).
employee.updatedPostojeći redak zaposlenika je ažuriran.Isti webhook-safe sažetak zaposlenika.

Ugovor o webhook payloadu: događaji nose identifikatore, ne PII

Tijela webhook događaja nose identifikatore resursa i minimalan prikazni snimak — nikada PII. Konkretno:

  • Događaji zaposlenika nose id, firstName, lastName, position, departmentId, status, isAccountOwner, isArchived, plus createdAt / editedAt. Ne nose email, telefon, adresu, OIB, IBAN ili BIC.
  • Događaji putnih unosa nose id unosa, broj unosa, id zaposlenika, datume, status workflowa, status isplate, iznos predujma u valuti zahtjeva i baznoj valuti, te audit timestampove. Ne nose ugniježđeni detalj troškova / dnevnica / mjesta troška, tekst destinacije ili slobodne tekstualne opise / izvještaje / pred-odobravajuće bilješke.

Kada partnerska integracija treba puni PII ili puni ugniježđeni detalj, povlači resurs preko autentificiranog API-ja:

  • GET /api/v1/employees/{id} za potpuni zapis zaposlenika (email, telefon, adresa, OIB, IBAN).
  • GET /api/v1/travel-entries/{id} za potpuni putni unos (troškovi, dnevnice, alokacije mjesta troška, privitci, slobodna tekstualna polja).

Ovo je isti obrazac koji koristi Stripe (tijelo događaja = identifikator; potpuni resurs = autentificirani GET). Pull zahtjevi su revizijski sljedivi po pozivu prema API ključu koji ih je izdao; webhook pushovi napuštaju locco u trenutku okidanja i nisu revizijski pripisivi konkretnom čitaču. Držanje PII-ja izvan push površine drži GDPR opseg partnerske integracije uskim i izbjegava prisiljavanje partnerskih endpointa u poziciju “mora rukovati PII-jem na mrežnom ulazu” za koju možda nisu spremni.

Webhook DTO-i su append-only u v1 — nova polja bez PII-ja mogu se dodati; postojeća polja neće se ukloniti niti preimenovati. PII polja neće se dodavati u webhook payload — ta površina je ugovor, ne samo zadana vrijednost.

Odabir događaja za pretplatu

Dva uobičajena oblika partnerske integracije:

  • ERP / sinkronizacija s vanjskim sustavom: pretplatite se na entry.created, entry.updated, entry.approved i entry.paidout. Zajedno daju potpuni kronološki zapis životnog ciklusa svakog putnog unosa. Za tenante bez workflowa odobrenja, entry.approved se jednostavno nikada ne okida (slučaj automatskog odobrenja prolazi kroz entry.updated); za tenante s workflowom, entry.approved je potreban da bi se uhvatio terminalni prijelaz u odobreno. U kombinaciji s ugovorom o deduplikaciji na Locco-Event-Id, ovo omogućuje održavanje vjerodostojne vanjske evidencije bez pollanja.
  • Tijek vođen odobrenjem: pretplatite se na entry.approved ako vašu integraciju zanima samo terminalno odobrenje (npr. pokretanje nizvodnog procesa plaćanja u trenutku kada je putni unos potpuno odobren). Napomena: entry.approved se okida samo za tenante koji imaju konfiguriran workflow odobrenja s barem jednim korakom koji ima prikladne odobravatelje. Za tenante bez workflowa, unos se automatski odobrava pri predaji i umjesto toga se okida entry.updated.

Oblik omotnice

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_ praćen s 32 hex znaka (bez crtica), npr. evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7. Semantički ista Guid vrijednost kao zaglavlje Locco-Event-Id, ali u drugačijem string formatu.
  • type: jedan od kanonskih stringova tipa događaja iznad.
  • createdAt: ISO 8601 UTC timestamp kada je događaj nastao u loccu (ne kada je isporučen).
  • data: webhook-safe sažetak resursa događaja (vidi “Ugovor o webhook payloadu” iznad).

Oblik na žici registriran je u API referenci kao WebhookEnvelope schema, pri čemu je data diskriminiran prema type: entry.* događaji nose TravelEntryWebhookDto, a employee.* događaji nose EmployeeWebhookDto. SDK generatori iz OpenAPI dokumenta emitiraju tipiziranu diskriminiranu uniju, pa partneri koji koriste generator dobiju strogo tipiziran deserijalizator umjesto slobodnog object.

Napomena o formatu: usporedba id s Locco-Event-Id. id u omotnici je evt_{guid:N} (32 mala hex znaka bez crtica), dok je zaglavlje Locco-Event-Id u kanonskom obliku Guida 8-4-4-4-12 s crticama (npr. b2c3d4e5-f6a7-b8c9-d0e1-f2a3b4c5d6e7). Predstavljaju istu Guid vrijednost, ali naivna usporedba stringa neće uspjeti. Za usporedbu, ili (a) skinite prefiks evt_ i uklonite crtice iz vrijednosti zaglavlja prije usporedbe, ili (b) parsirajte oba kao Guidove i usporedite parsane vrijednosti. Formati na žici su stabilni i namjerni. Stripeova webhook omotnica koristi isti evt_... skraćeni oblik, pa partneri s postojećim Stripe verifikatorom mogu doslovno prenijeti obrazac parsiranja.

Zaglavlja

Svaki webhook POST nosi:

ZaglavljeOpis
Content-TypeUvijek application/json; charset=utf-8.
User-AgentLocco-Webhook/1.0. Po potrebi prikvačite partnerske allow-liste na ovo.
Locco-EventString tipa događaja (npr. entry.approved). Praktično usmjeravanje bez parsiranja sadržaja.
Locco-Event-IdStabilan Guid za idempotenciju. Partneri MORAJU dedupirati po ovome.
Locco-Signaturet={timestamp},v1={hex-hmac-sha256}. Vidi format potpisa.
Locco-Delivery-IdInterni ID retka isporuke, koristan kod usklađivanja s dnevnikom isporuka preko locco podrške.

Registracija pretplate

Tijelo POST /api/v1/webhooks:

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

Ograničenja:

  • url mora biti HTTPS. Plaintext HTTP odbija se prilikom kreiranja i ponovno prilikom slanja kao dvostruka zaštita.
  • url ne smije razrješavati na localhost, loopback ili RFC1918 privatne IP adrese (zaštita od SSRF-a).
  • eventTypes mora biti neprazno i svaka stavka mora biti u kanonskom katalogu.

Tijelo odgovora vraća tajnu za potpisivanje točno jednom kao signingSecret. Spremite je odmah. Ne postoji način da se izgubljena tajna povrati naknadnim zahtjevom. Tajna se ne vraća u GET /api/v1/webhooks ni u GET /api/v1/webhooks/{id}.

Dnevnik isporuka

Pregledajte što je poslano i što je vraćeno preko:

  • U aplikaciji: Postavke → Integracije → Webhooks (proširivi redak po isporuci, s potpunim sadržajem i skraćenim tijelom odgovora).
  • API (ugniježđeno pod nadređenom pretplatom):
    • GET /api/v1/webhooks/{id}/deliveries: popis isporuka za pretplatu. Podržava parametre upita page, pageSize, status, eventType.
    • GET /api/v1/webhooks/{id}/deliveries/{deliveryId}: dohvat pojedinačne isporuke, uključujući potpune potpisane bajtove sadržaja i skraćeno tijelo partnerskog odgovora. Vraća 404 ako isporuka postoji, ali pripada drugoj pretplati (probijanje između pretplata nije dopušteno).

Ručni ponovni pokušaj: POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. Vrijedi samo za isporuke u stanju Failed ili Exhausted. Ponovni pokušaj zadržava originalni Locco-Event-Id i bajtove sadržaja, pa partnerske dedupe pohrane i dalje rade ispravno.

Retencija. Retci isporuka — uključujući zabilježene bajtove sadržaja — hard-deletaju se nakon 30 dana dnevnim cleanup poslom. Partneri kojima treba trajni revizijski trag svakog događaja moraju zabilježiti vlastitu kopiju pri primitku; endpoint dnevnika isporuka služi za kratkoročnu observabilnost i replay, ne za dugoročnu arhivu. U kombinaciji s PII-stripped projekcijom webhook payloada (bajtovi sadržaja nose identifikatore resursa i minimalan prikazni snimak, nikada PII), povijesna inspekcija kroz ovaj endpoint ne može procuriti informacije izvan identifikatora resursa.