Webhooks

locco delivers signed JSON event notifications to partner-registered HTTPS endpoints when domain events occur in a tenant (entry created, entry approved, entry paid out, entry updated, employee created or updated). This page is the partner-facing reference: signature format, replay-protection contract, idempotency expectations, retry policy, and the canonical event-type catalog.

The push surface is the counterpart to the pull /api/v1/* API. Payloads embed the same v1 DTOs that GET /api/v1/{resource}/{id} returns to an API-key caller, so partners work with one contract for both surfaces.

At a glance

  • Partners register an HTTPS endpoint via POST /api/v1/webhooks and receive a one-time signing secret (whsec_...).
  • locco POSTs JSON to that endpoint when matching events fire. Bodies are signed with HMAC-SHA256.
  • Failed deliveries (5xx, network, timeout) retry with exponential back-off; 4xx terminates retry. After ~40 hours of failed retries (8 retries on top of the initial attempt) the delivery is marked exhausted.
  • After 10 consecutive exhausted deliveries the subscription auto-disables. Re-register to reactivate.
  • The nested delivery log (GET /api/v1/webhooks/{id}/deliveries) and the in-app Settings → Integrations → Webhooks page expose what was sent and what came back.

Signature format

Every request carries a Locco-Signature header:

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

The HMAC input is the canonical string {timestamp}.{payload}:

  • {timestamp}: the integer in the t= parameter (Unix epoch seconds, UTC).
  • .: a literal period character.
  • {payload}: the raw, unmodified request body bytes. Do NOT re-serialize JSON before computing. Whitespace and key ordering matter.

The format mirrors Stripe’s webhook signature exactly (t=...,v1=...), so partners with existing Stripe verifier code can adapt with a header rename.

Verifying a signature

javascript
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;
}
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));
}

The same shape works in any language: HMAC-SHA256 of {timestamp}.{payload} with the secret as the key, hex-encoded, constant-time compared against the v1= parameter.

Replay protection (REQUIRED)

Partners MUST reject requests whose timestamp is older than a small window. The recommended tolerance is 5 minutes (matches Stripe’s default). Without this check, an attacker who captures a legitimate request body and signature can replay it indefinitely as long as the secret has not rotated.

Reject:

  • Missing or malformed Locco-Signature header.
  • Timestamp older than the tolerance window.
  • Timestamp meaningfully in the future (allow a small clock-skew margin, e.g. 30 seconds).
  • Computed HMAC that does not match v1=.

Use a constant-time comparison for the HMAC check (timingSafeEqual in Node.js, hmac.compare_digest in Python, CryptographicOperations.FixedTimeEquals in .NET). String equality (===) leaks timing information and can in principle reveal the signature byte by byte.

Idempotency

Every delivery carries a Locco-Event-Id header, a stable Guid identifying the logical event. The same event id is sent again on:

  • Manual retries: the partner clicked Retry in the delivery-log UI, or POSTed /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry.
  • At-least-once redelivery: a background worker that crashes between sending the request and recording success re-enqueues the same row on the next poll.

Partner endpoints MUST treat duplicate Locco-Event-Id arrivals as no-ops. The standard pattern: keep a recent-deliveries store (Redis, a small DB table, an in-process cache) and reject any event whose id was already processed in the last hour or so.

Without this dedupe, the partner’s downstream side effect (sync to ERP, email a notification, mutate a record) will run twice for the same logical event whenever a retry happens.

The retry semantics are deliberate:

  • A retry sends the same Locco-Event-Id as the original delivery. Partners dedupe on the header alone, they do not need to inspect the payload.
  • A retry sends the same body bytes as the original delivery. The signature is recomputed with a fresh timestamp, but the HMAC input is identical aside from that timestamp.

Retry policy

locco retries on transient failures with exponential back-off. The schedule is 9 total HTTP attempts (the initial delivery plus 8 retries) spread over ~40 hours end to end.

AttemptDelay since the previous attemptCumulative time since attempt 1
1 (initial)(none)0
230 seconds30s
31 minute1m 30s
45 minutes6m 30s
515 minutes21m 30s
61 hour~1h 21m
73 hours~4h 21m
812 hours~16h 21m
9 (final)24 hours~40h 21m

Retry vs terminate

Partner responseBehavior
2xxSuccess. Delivery row marked Succeeded. No retry.
4xx (except 408, 429)Partner-side error. Delivery row marked Failed. No retry.
408 (Request Timeout), 429 (Too Many)Treated as transient, retry per schedule.
5xxServer error. Retry per schedule.
Network error (DNS, TCP reset, TLS handshake)Retry per schedule.
Timeout (locco gives up after 10 seconds)Retry per schedule.

After all 9 attempts are exhausted without a 2xx, the delivery row is marked Exhausted. The body of the partner’s last response (truncated to 2 KB) is preserved in the delivery log so partners can debug what their endpoint was returning.

Retry-After headers from partners are not honored in v1. The schedule above always applies.

Auto-disable

If a subscription accumulates 10 consecutive Exhausted deliveries, locco automatically disables it (IsActive = false, DisabledAt, DisabledReason). This protects locco from hammering a permanently broken endpoint.

To re-enable an auto-disabled subscription:

  1. Inspect the delivery log to identify what the partner endpoint was returning during the failures.
  2. Fix the partner endpoint.
  3. Delete the subscription and create a new one (POST /api/v1/webhooks).

Event types

The v1 catalog. New types are added by code change in lockstep with a new domain event, so partners can rely on this list being the complete set at any given release.

The same set is exposed in the API reference as the WebhookEventType enum schema; SDK generators emit a typed enum from this list so partners get compile-time safety on the values they subscribe to.

Event typeFires whendata payload
entry.approvedA travel entry transitioned to terminal Approved status via a workflow step. Auto-approved entries (companies without an approval workflow) do not fire this event. See entry.updated.A webhook-safe travel-entry summary (id + entry number + employee id + dates + status + amounts).
entry.paidoutA travel entry was marked paid out as part of a payout batch.Same webhook-safe travel-entry summary.
entry.createdA new travel entry was created.Same webhook-safe travel-entry summary.
entry.updatedA travel entry’s audit log received a non-create, non-terminal-approve, non-paidout entry, i.e., the entry mutated in a way the platform considers worth recording. Includes edits, submission, intermediate workflow approvals, cancellation, request-changes, and child-entity changes (expenses, per diems, cost centers, attachments).Same webhook-safe travel-entry summary.
employee.createdA new employee row was created.A webhook-safe employee summary (id + name + position + status + audit timestamps).
employee.updatedAn existing employee row was updated.Same webhook-safe employee summary.

Webhook payload contract: events carry handles, not PII

Webhook event bodies carry resource identifiers and a minimal display snapshot — never PII. Specifically:

  • Employee events carry id, firstName, lastName, position, departmentId, status, isAccountOwner, isArchived, plus createdAt / editedAt. They do not carry email, phone, address, tax id (OIB), bank account (IBAN), or BIC.
  • Travel-entry events carry the entry id, entry number, employee id, dates, workflow status, paid-out status, advance payment amount in the request currency and base currency, and audit timestamps. They do not carry the nested expense / per-diem / cost-center detail, the destination text, or the free-text description / report / pre-approval notes.

When a partner integration needs full PII or full nested detail, it pulls the resource over the authenticated API:

  • GET /api/v1/employees/{id} for the full employee record (email, phone, address, tax id, bank account).
  • GET /api/v1/travel-entries/{id} for the full travel entry (expenses, per diems, cost-center allocations, attachments, free-text fields).

This is the same pattern Stripe uses (event body = identifier; full resource = authenticated GET). Pull requests are auditable per-call against the API key that issued them; webhook pushes leave Locco the moment they fire and aren’t audit-attributable to a specific reader. Keeping PII off the push surface keeps the partner integration’s GDPR scope tight and avoids forcing partner endpoints into a “must handle PII at network ingress” posture they may not be prepared for.

The webhook DTOs are append-only in v1 — new non-PII fields may be added; existing fields will not be removed or renamed. PII fields will not be added to the webhook payload — that surface is a contract, not just a default.

Picking events to subscribe to

Two common partner integration shapes:

  • ERP / external-system sync: subscribe to entry.created, entry.updated, entry.approved, and entry.paidout. Together these give you a complete forward-only view of every travel entry’s lifecycle. For tenants without an approval workflow, entry.approved simply never fires (the auto-approve case flows through entry.updated instead); for workflow tenants, entry.approved is required to catch the terminal approval transition. Combined with the dedupe contract on Locco-Event-Id, this lets you maintain an authoritative external record without polling.
  • Approval-driven workflow: subscribe to entry.approved if your integration only cares about terminal approval (e.g., kicking off a downstream payment process the moment a travel entry is fully approved). Note: entry.approved only fires for tenants that have configured an approval workflow with at least one step that has eligible approvers. For tenants without a workflow, the entry is auto-approved at submission and entry.updated fires instead.

Envelope shape

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_ followed by 32 hex characters (no dashes), e.g. evt_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7. Semantically the same Guid value as the Locco-Event-Id header, but in a different string format.
  • type: one of the canonical event-type strings above.
  • createdAt: ISO 8601 UTC timestamp of when the event was raised in locco (not when it was delivered).
  • data: the webhook-safe summary for the event’s resource (see “Webhook payload contract” above).

The wire shape is registered in the API reference as the WebhookEnvelope schema, with data discriminated on type: entry.* events carry a TravelEntryWebhookDto, employee.* events carry an EmployeeWebhookDto. SDK generators emit a typed discriminated union from the OpenAPI document, so partners using a generator get a strongly-typed deserializer instead of a free-form object.

Format note: matching id against Locco-Event-Id. The envelope’s id is evt_{guid:N} (32 lowercase hex characters with no dashes) while the Locco-Event-Id header is the canonical 8-4-4-4-12 hex Guid form with dashes (e.g. b2c3d4e5-f6a7-b8c9-d0e1-f2a3b4c5d6e7). They represent the same Guid value, but a naive string-equality check between the two will fail. To compare them, either (a) strip the evt_ prefix and remove the dashes from the header value before comparing strings, or (b) parse both as Guids and compare the parsed values. The wire formats are stable and intentional. Stripe’s webhook envelope uses the same evt_... shorthand and partners with existing Stripe verifier code can lift the parsing pattern verbatim.

Headers

Every webhook POST carries:

HeaderDescription
Content-TypeAlways application/json; charset=utf-8.
User-AgentLocco-Webhook/1.0. Pin partner allowlists on this if needed.
Locco-EventThe event-type string (e.g. entry.approved). Convenient routing without parsing the body.
Locco-Event-IdStable Guid for idempotency. Partners MUST dedupe on this.
Locco-Signaturet={timestamp},v1={hex-hmac-sha256}. See signature format.
Locco-Delivery-IdThe internal delivery-row id, useful when correlating against the delivery log via locco support.

Subscription registration

POST /api/v1/webhooks body:

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

Constraints:

  • url must be HTTPS. Plaintext HTTP is rejected at create time and again at dispatch time as defense in depth.
  • url cannot resolve to localhost, loopback, or RFC1918 private IPs (SSRF protection).
  • eventTypes must be non-empty and every entry must be in the canonical catalog.

The response body returns the signing secret exactly once as signingSecret. Save it immediately. There is no way to recover a lost secret from a subsequent request. The secret is not returned by GET /api/v1/webhooks or GET /api/v1/webhooks/{id}.

Delivery log

Inspect what was sent and what came back via:

  • In-app: Settings → Integrations → Webhooks (an expandable row per delivery, with the full payload and truncated response body).
  • API (nested under the parent subscription):
    • GET /api/v1/webhooks/{id}/deliveries: list deliveries for a subscription. Supports page, pageSize, status, eventType query parameters.
    • GET /api/v1/webhooks/{id}/deliveries/{deliveryId}: fetch a single delivery, including the full signed payload bytes and the partner’s truncated response body. Returns 404 if the delivery exists but belongs to a different subscription (cross-subscription probing is not allowed).

Manual retry: POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry. Only valid for deliveries in Failed or Exhausted status. The retry preserves the original Locco-Event-Id and payload bytes, so partners’ dedupe stores still work correctly.

Retention. Delivery rows — including the recorded payload bytes — are hard-deleted after 30 days by a daily cleanup job. Partners that need a permanent audit trail of every event must record their own copy on receipt; the delivery-log endpoint is for short-term observability and replay, not long-term archive. Combined with the webhook payload’s PII-stripped projection (the payload bytes carry resource ids and a minimal display snapshot, never PII), historical inspection through this endpoint cannot leak information beyond the resource identifiers.