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 → Integrations → Webhooks 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-integration-event: 3c9e2b4a-8f1d-4e6a-b7c5-0d2e4f6a8b1c
x-kelomo-signature: t=1770300000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "71d1a1e0-5b7c-4f37-9d68-1d2f3a4b5c6d",
"event": "workday.approved",
"integrationEventId": "3c9e2b4a-8f1d-4e6a-b7c5-0d2e4f6a8b1c",
"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 | This delivery's id, the same as id in the body. |
x-kelomo-integration-event | The event's id, the same as integrationEventId in the body. |
x-kelomo-signature | Versioned HMAC signature; see below. |
| Body field | Meaning |
|---|---|
id | The delivery. Automatic retries and a manual redelivery keep it. |
event | The event name, as in the catalogue. |
integrationEventId |
The event itself: the id of its row in
GET /v1/integration-events, the same in every delivery of the event.
Your deduplication key. null only for the test send from the settings
page.
|
createdAt | When the event happened (UTC). |
data | The event payload: identifiers, states and timestamps. |
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 event.integrationEventId
}); 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 event twice; deduplicate on
integrationEventId, which a manual redelivery keeps as well. - Dead-letter. After the final attempt the delivery is parked as
dead_letter, visible under Settings → Integrations → Webhooks with its HTTP status and error. Redelivering from there sends the delivery again under the same id and closes the earlier attempt (it readssupersededin the delivery log, and no retry of it follows); asking twice, or from two tabs at once, still sends once. ItsintegrationEventIdandcreatedAtare the event's own, so a redelivered event still sorts where it happened. - 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. A log row's
id is the integrationEventId of every delivery of that event, so
the two reconcile row for row. Polling it is also the
recommended backfill when you first connect an integration.