Webhooks
A webhook tells you the moment something changes, instead of you asking every minute whether it has. Polling to catch a change that happens twice a day spends your entire rate-limit budget learning nothing.
Setting one up
In the app, Settings → Webhooks → Add endpoint. Give it an HTTPS URL and choose the events you want.
The signing secret is shown when the endpoint is created and remains viewable afterwards — unlike an API key. That is deliberate: your receiver needs it to verify every request, so it is stored encrypted rather than hashed.
New endpoints start unverified. Use Send test to deliver a test event;
a 2xx moves the endpoint to active. Do that before you rely on it — a typo in
the URL should fail at setup, not at three in the morning.
What arrives
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Operentra-Webhooks/1.0
Operentra-Event: employee.created
Operentra-Delivery: 4f3c2b1a-...
Operentra-Timestamp: 1788533995
Operentra-Signature: t=1788533995,v1=5f8d1e...
{
"id": "4f3c2b1a-...",
"type": "employee.created",
"data": {
"object": "employee",
"id": "e83f17a0-...",
"first_name": "Ayesha"
}
}
data is the full object, exactly as the API would return it — including
fields that need employees.sensitive:read. The subscriber is the endpoint,
not whichever key happened to cause the change; an event whose shape depended
on that would be one nobody could build against.
Verify the signature
Do this before you trust anything in the body. Your endpoint is a public URL and anyone can POST to it.
import crypto from 'node:crypto';
// rawBody must be the exact bytes received — parse AFTER verifying.
// JSON.parse followed by JSON.stringify reorders keys and changes whitespace,
// and the signature will not match.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Refuse anything too old: without this, a captured delivery can be
// replayed for ever.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// A header may carry MORE THAN ONE v1= value during a secret rotation.
// Accept the request if any of them matches.
return header
.split(',')
.filter((p) => p.trim().startsWith('v1='))
.some((p) => {
const given = p.trim().slice(3);
return (
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
});
}
Three things that catch people out:
- Sign the raw body. Most frameworks parse JSON before your handler runs;
you need the bytes. In Express,
express.raw({ type: 'application/json' }). - Compare in constant time.
===on an HMAC leaks it a byte at a time. - Expect multiple
v1=values. During a rotation both secrets are valid, and a receiver that reads only the first one breaks halfway through.
Responding
Answer 2xx as soon as you have stored the event. Do the work afterwards.
A delivery times out after 10 seconds, and a slow handler turns into a
retry — which means you will get the event again while still processing the
first copy. Deduplicate on Operentra-Delivery, which is stable across
retries of the same delivery.
Any non-2xx, or no answer at all, counts as a failure.
Retries
Failed deliveries are retried with a growing backoff:
| Attempt | Delay after the previous |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 20 hours |
After the eighth attempt the delivery is exhausted and stops. It stays in the delivery log and can be replayed by hand from the console once you have fixed whatever was wrong.
After 20 consecutive failures the endpoint is disabled and stops receiving anything. You are told when that happens. Re-enable it in the console once the receiver is healthy — nothing is deleted, so you can replay what was missed.
Rotating the secret
Rotate from the console. For 24 hours both the new and the previous secret
are accepted, and every delivery in that window is signed with both — which is
why the header can carry two v1= values.
Deploy the new secret to your receiver inside that window. After it closes, the old secret stops working.
What we will not deliver to
Your URL must be public HTTPS. We refuse private, loopback, link-local and cloud-metadata addresses, and we re-check on every delivery rather than only at registration — a hostname that resolves publicly today can resolve to an internal address tomorrow. Redirects are not followed.
If your receiver is inside a private network, put a small public relay in front of it rather than asking us to reach in.
Events
| Event | Fires when |
|---|---|
employee.created | An employee record was created. |
employee.updated | Any field on an employee changed. |
employee.deleted | An employee was soft-deleted. The payload carries deleted: true. |
attendance.recorded | An attendance record was created for a day. |
attendance.updated | An existing attendance record changed. |
leave.requested | An employee applied for leave. |
leave.approved | A leave application was approved. |
leave.rejected | A leave application was rejected. |
leave.cancelled | A leave application was cancelled. |
payroll.run.approved | A payroll run was approved. |
payroll.run.locked | A payroll run was locked and can no longer change. |
salary_slip.published | A salary slip was published to an employee. |
contract.signed | An employment contract was signed. |
Events not on this list are not emitted. An event type that fires sometimes is worse than one that does not exist, because you would build on it.