Odin Payroll sends a signed POST to your endpoints when something changes. Add endpoints in Settings, Webhooks or with POST /v1/partners/{partnerId}/webhooks. Endpoints must be public https URLs. Each endpoint gets a signing secret starting with whsec_, shown once.
Payload
{
"id": "evt_01a1d6...",
"type": "payroll_entity.kyb_status_changed",
"occurredAt": "2026-11-13T12:00:00.000Z",
"partnerId": "01a1d6...",
"data": { "payrollEntityId": "01a1d6...", "organizationId": "01a1d6...", "kybStatus": "passed", "status": "active" }
}
Payloads carry identifiers and statuses only, never personal details. Fetch details through the API with your own key.
| Event | When |
|---|---|
payroll_entity.created |
A payroll entity was created |
payroll_entity.kyb_status_changed |
Verification status changed |
payroll_engine.event |
Another payroll engine event concerning one of your entities |
webhook.test |
You sent a test from the app or API |
Verify every delivery
Each request has Odin-Signature: t=<unix seconds>,v1=<signature>. The signature is HMAC-SHA256 of <t>.<raw body> with your endpoint secret, encoded as base64url without padding. Use the raw body exactly as received, reject timestamps more than 5 minutes old, and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOdinSignature(rawBody, header, secret, now = Date.now()) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(now / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("base64url");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
import base64, hashlib, hmac, time
def verify_odin_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if not t or abs(time.time() - t) > 300:
return False
mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).digest()
expected = base64.urlsafe_b64encode(mac).rstrip(b"=").decode()
return hmac.compare_digest(expected, parts.get("v1", ""))
Delivery and retries
Respond with any 2xx within 10 seconds. Delivery is at least once: failed deliveries are retried with backoff, up to 8 attempts, so use the event id to ignore duplicates. Redirects are not followed. The app shows each endpoint's recent deliveries.
Event log
GET /v1/partners/{partnerId}/events returns the last 30 days of events, newest first, so you can replay anything your endpoint missed.