Guide
Webhooks
Kelomo pushes events — approvals, absences, published rosters, payroll batches and more — to your HTTPS endpoint as signed JSON. Payloads carry identifiers, states and timestamps only; fetch the details you need through the API with the ids in the payload.
Subscribing
Create endpoints in Settings → API access or over the API (requires the
integrations.manage scope). Pick the events to receive; subscribing to an
event also requires the capability that guards it — a key that cannot see payroll data
cannot subscribe to payroll events either. Every event, its capability and its payload
shape are in the event catalogue, and the same
catalogue is machine-readable at GET /v1/webhook-events.
curl -X POST https://api.kelomo.fi/v1/webhooks \
-H "Authorization: Bearer kelomo_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/kelomo",
"events": ["workday.approved", "absence.decided"]
}'
# → { "id": "…", "secret": "whsec_…" } (secret shown once)
The returned secret signs every delivery to that endpoint and is shown once.
Endpoints must be HTTPS on a public host, and redirects are not followed.
What a delivery looks like
POST /hooks/kelomo HTTP/1.1
Content-Type: application/json
x-kelomo-event: workday.approved
x-kelomo-delivery: 71d1a1e0-5b7c-4f37-9d68-1d2f3a4b5c6d
x-kelomo-signature: t=1770300000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "71d1a1e0-5b7c-4f37-9d68-1d2f3a4b5c6d",
"event": "workday.approved",
"createdAt": "2026-08-05T09:30:00.000Z",
"data": {
"workDayId": "…",
"membershipId": "…",
"workDate": "2026-08-04",
"approvedAt": "2026-08-05T09:30:00.000Z"
}
} | Header | Meaning |
|---|---|
x-kelomo-event | The event name, for routing before you parse the body. |
x-kelomo-delivery | Unique delivery id — your deduplication key. Matches id in the
body. |
x-kelomo-signature | Versioned HMAC signature; see below. |
Respond with any 2xx within 10 seconds to acknowledge. Acknowledge first and
process asynchronously — a slow handler reads as a failed delivery and triggers a retry.
Verifying the signature
The signature header is
t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">: the HMAC is
computed with your endpoint secret over the timestamp, a literal dot, and the raw request
body. Verify all three properties:
- Reject if
|now − t|exceeds 5 minutes (replay window). -
Compute the HMAC over
t + '.' + rawBodyand compare in constant time. -
Accept if any
v1entry matches — during secret rotation the header carries two.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300; // 5-minute replay window
export function verifyKelomoSignature(header, rawBody, secret) {
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const [key, value] = part.split('=');
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value); // two entries during secret rotation
}
if (!timestamp || signatures.length === 0) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedBuffer = Buffer.from(expected, 'hex');
return signatures.some((signature) => {
const candidate = Buffer.from(signature, 'hex');
return candidate.length === expectedBuffer.length && timingSafeEqual(candidate, expectedBuffer);
});
}
// Express: verify against the RAW body — re-serialising parsed JSON breaks the HMAC.
app.post('/hooks/kelomo', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifyKelomoSignature(
req.get('x-kelomo-signature') ?? '',
req.body.toString('utf8'),
process.env.KELOMO_WEBHOOK_SECRET,
);
if (!valid) return res.status(400).send('invalid signature');
res.status(200).end(); // acknowledge fast, process asynchronously
const event = JSON.parse(req.body.toString('utf8'));
// …hand off to a queue keyed on the x-kelomo-delivery id
}); import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300 # 5-minute replay window
def verify_kelomo_signature(header: str, raw_body: bytes, secret: str) -> bool:
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1": # two entries during secret rotation
signatures.append(value)
if timestamp is None or not signatures:
return False
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask: request.get_data() is the raw body — verify before parsing JSON.
@app.post("/hooks/kelomo")
def kelomo_hook():
if not verify_kelomo_signature(
request.headers.get("x-kelomo-signature", ""),
request.get_data(),
KELOMO_WEBHOOK_SECRET,
):
return "invalid signature", 400
return "", 200 # acknowledge fast, process asynchronously Delivery semantics: at-least-once
- At-least-once. A delivery that fails — non-2xx, timeout, unreachable —
is retried up to 5 attempts with exponential backoff starting at 30
seconds. Your handler may therefore see the same delivery twice; deduplicate on
x-kelomo-delivery. - Dead-letter. After the final attempt the delivery is parked as
dead_letter, visible in Settings → API access with its HTTP status and error. Redelivering from there creates a fresh delivery (with a new id) rather than mutating history. - Ordering is not guaranteed. Retries and parallel deliveries can arrive
out of order — use the
createdAtin the body, or re-read the resource, when order matters.
Rotating a secret
Rotation is zero-downtime: after the call, deliveries are signed with both secrets
for 24 hours (two v1 entries in the header), so verify-against-any receivers
keep working while you swap the stored secret.
curl -X POST https://api.kelomo.fi/v1/webhooks/ENDPOINT_ID/rotate-secret \
-H "Authorization: Bearer kelomo_YOUR_KEY"
# → { "secret": "whsec_NEW…" } (shown once; the old secret co-signs for 24 h) Reconciliation: the integration-event log
Every event is recorded in your organisation's integration-event log before any delivery is attempted, and kept for 90 days. If your endpoint was down — or you simply want to verify you missed nothing — page through the log instead of asking for redeliveries one by one:
curl "https://api.kelomo.fi/v1/integration-events?event=workday.approved&limit=100" \
-H "Authorization: Bearer kelomo_YOUR_KEY" The log returns the same payloads as the deliveries, in the standard paginated envelope. Polling it is also the recommended backfill when you first connect an integration.