Developers

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
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

Delivery request
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"
  }
}
HeaderMeaning
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:

  1. Reject if |now − t| exceeds 5 minutes (replay window).
  2. Compute the HMAC over t + '.' + rawBody and compare in constant time.
  3. Accept if any v1 entry matches — during secret rotation the header carries two.
Node.js
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
});
Python
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 createdAt in 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
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
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.