Guides

Webhooks

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.