Webhooks push every change in your workspace to your systems as it happens: a record created in the app, a candidate moved to a new stage, an employee offboarded, a bulk job row written. Each change becomes an event; Jadiroon sends it as a signed POST to every endpoint subscribed to its type.
Events come from the audit trail, so they cover changes made in the app, through the API and by automation alike. The list of event types is in the event catalog.
Setting up an endpoint
A Workspace Admin adds endpoints to an integration in Settings → Integrations → (integration) → Webhooks (see Integrations):
- URL:
httpsonly, on a public host. Private, loopback and link-local addresses are refused when you save and again on every send. A dedicated deployment may only allow a list of approved hosts (422 forbidden_host), or no webhooks at all (422 webhook_egress_not_configured). - Event types:
*(every resource event), or a list such asemployee.created,employee.updated,application.hired. The opt-inaudit.recordedis never part of*; pick it explicitly (needsaudit.view). - Signing secret:
whsec_followed by 64 hex characters, shown when the endpoint is created and revealable later by a Workspace Admin (each reveal is recorded in the audit log). - Send test event sends a
pingevent to check the URL and your signature code.
An endpoint only receives events for resources its integration can read, and candidate contact fields are masked in the payload unless the integration holds recruitment.candidate.contact. Pay is hidden unless it holds structure.functional.salary: position and job description pay fields are null, pay fields are left out of changedFields, an update that changed only pay is not sent, and audit.recorded leaves out pay changes.
The request
POST /jadiroon-webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Jadiroon-Webhooks/1
Jadiroon-Event-Id: evt_8fb352d7c1a04e2b9f3a6c10
Jadiroon-Event-Type: employee.updated
Jadiroon-Signature: t=1761300374,v1=5c1f0e…
{
"id": "evt_8fb352d7c1a04e2b9f3a6c10",
"type": "employee.updated",
"apiVersion": "v1",
"occurredAt": "2026-10-24T10:06:14.331Z",
"workspaceId": "cmvikf7rn0001knoq5veyug12",
"actor": { "type": "user", "id": "cmvikf7xk0003knoqj7x054vw", "name": "Sara Alharbi" },
"data": {
"object": { "id": "cmv…", "status": "EMPLOYEE", "firstName": "Omar", "lastName": "Alqahtani", "externalRefs": [{ "system": "oracle-hcm", "externalId": "EMP-1002" }], "…": "…" },
"changedFields": ["currentPositionId"]
}
}
data.objectis the same object the API returns for that resource, as it was when the event was created.changedFieldslists the public fields that changed, when known.- Delete events (
<resource>.deleted) carry only{ id, externalRefs }. <resource>.bulk_changedmeans many rows changed at once (an import, a structure replace):data.objectis{ resource }. Re-read that resource withupdatedSince= the event'soccurredAt, and the deletion feed.actor.typeisuser,integration(an API key;nameis the integration) orsystem(automation).- Key order in the JSON may differ from the example; parse it, don't compare it as text.
Verifying the signature
Jadiroon-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>, computed with your endpoint's secret over t + "." + raw body. Always verify it before trusting the payload:
- Take the raw request body exactly as received (before any JSON parsing).
- Read
tand everyv1=value from the header. - Reject when
tis more than 5 minutes from your clock (replay protection). - Compute HMAC-SHA256 of
t + "." + bodywith the secret, as lowercase hex, and compare it in constant time with eachv1value. Any match passes.
Node.js
const crypto = require("crypto");
function verifyJadiroonSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = String(header || "").split(",").map((p) => p.trim());
const t = Number((parts.find((p) => p.startsWith("t=")) || "").slice(2));
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return signatures.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}
With Express, read the body raw for this route (express.raw({ type: "application/json" })) and pass req.body.toString("utf8").
Python
import hashlib
import hmac
import time
def verify_jadiroon_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = [p.strip() for p in (header or "").split(",")]
t_part = next((p for p in parts if p.startswith("t=")), "")
try:
t = int(t_part[2:])
except ValueError:
return False
if abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))
With Flask, pass request.get_data(); with Django, request.body.
Every attempt is signed again with a fresh t, so a retry of the same event has a new signature.
Responding
- Answer with any 2xx within 10 seconds. Do the real work after answering (queue it).
- Anything else counts as a failure: another status, a timeout, a connection error, or a redirect (redirects are not followed).
Retries
A failed delivery is retried on this schedule, counted from the previous attempt (the timing when the retry dispatcher runs every minute; see When retries run for the hosted service):
| Attempt | Delay |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8, 9, 10 | 24 hours each |
After the 10th failed attempt (on that schedule, about 3 days and 20 hours after the event) the delivery is marked dead and not retried. A Workspace Admin can redeliver any event from the endpoint's delivery log while it is kept (30 days); a redelivery has the same event id. A redelivery is rendered again with the integration's current permissions (and the record's current state), and it needs the endpoint and the integration to be active. It is refused with 409 conflict when the endpoint no longer subscribes to the event type (details.reason not_subscribed) or the record is gone (resource_gone), and with 403 forbidden (out_of_scope) when the integration can no longer see it.
When retries run
The first attempt is sent right after the change. Retries are sent by a background dispatcher, and the table above is their timing when it runs every minute (as on a self-hosted deployment that calls it from a system cron with its CRON_SECRET). The hosted service currently runs the retry dispatcher once a day, at 03:00 UTC. A retry then goes out at the first daily run after its delay has passed, so retries come at most once a day and the 10 attempts span 8 days or more instead of 3 days and 20 hours. Deliveries held by a pause are re-checked, endpoints are auto-disabled and old events are deleted on the same daily run. If you can't wait for the next run, fix the receiver and redeliver from the delivery log, or catch up with updatedSince.
Auto-disable
When an endpoint has failed continuously for 3 days (no successful delivery since the first failure), it is disabled at its next failed attempt (on the hosted service, usually the next daily run) and the Workspace Admins get a notification (for a deployment-level endpoint in the Platform Console, every console Owner gets an email instead). Its pending deliveries then fail. Fix the receiver, re-enable the endpoint in Settings (a deployment-level endpoint in Platform Console → Integrations), and redeliver what you missed (or catch up with updatedSince).
Pausing an endpoint or its integration is different: it gets no new events while paused (they are not queued for later), and deliveries already pending wait and are retried once it is active again.
Narrowing the scope
A delivery is prepared when the event happens, with the permissions the integration had then. So when an integration loses a permission, pending deliveries on its endpoints are dropped: each is marked failed with the response scope changed and is not sent. An event fanned out at the same moment as the change may still be delivered once. Likewise, when an endpoint stops subscribing to some event types, its pending deliveries of those types are dropped the same way. Privacy wins over completeness: after narrowing a scope, catch up with updatedSince on the lists you still read.
Ordering and duplicates
- Order is not guaranteed. Retries and parallel sends mean an older event can arrive after a newer one. Order by
occurredAt, and when you applydata.object, skip it if you already hold a newerupdatedAtfor that record. - Delivery is at least once. The same event can arrive more than once (a retry after a timeout, a redelivery). Deduplicate by the event
id(also inJadiroon-Event-Id); keep the ids of the last few days. - Events are kept for 30 days. For anything older, or after a long outage, re-sync with
updatedSinceand the deletion feed.
Rotating the secret
Rotate secret creates a new secret and keeps the old one valid for 24 hours. During that window each request carries two v1= values (new secret first), so a receiver with either secret keeps verifying. Deploy the new secret within the 24 hours.
Retention
Events and their deliveries are kept 30 days, because payloads contain personal data. Tombstones in the deletion feed are kept 180 days.