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": "Jordan"
}
}
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 for the retention window below 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; everything that was missed is still in the log for thirty days, so you can replay it.
Retention
Events and their delivery attempts are kept for 30 days, then removed. That is measured from when the event was emitted, and it applies to every delivery regardless of outcome — a success and an exhausted failure age out together. The retry schedule finishes in under two days, so by the time a row is removed it has been final for four weeks.
Two things are never removed by the sweep: an event that was never fanned out to any endpoint, and an event with a delivery that is still pending or in flight. Either is something we want to see, not tidy away.
If you need delivery history beyond thirty days, record it on your side when
the event arrives. The id on every event is stable, and the log is not a
substitute for your own audit trail.
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 | Plan |
|---|---|---|
employee.created | An employee record was created. | Every plan |
employee.updated | Any field on an employee changed. | Every plan |
employee.deleted | An employee was soft-deleted. The payload carries deleted: true. | Every plan |
attendance.recorded | An attendance record was created for a day. | Every plan |
attendance.updated | An existing attendance record changed. | Every plan |
leave.requested | An employee applied for leave. | Every plan |
leave.approved | A leave application was approved. | Every plan |
leave.rejected | A leave application was rejected. | Every plan |
leave.cancelled | A leave application was cancelled. | Every plan |
payroll.run.approved | A payroll run was approved. | Payroll |
payroll.run.locked | A payroll run was locked and can no longer change. | Payroll |
salary_slip.published | A salary slip was published to an employee. | Payroll |
contract.signed | An employment contract was signed. | Every plan |
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.
An event with a plan in the last column is delivered only while the workspace's
plan includes that feature. You can subscribe to it on any plan, but until then
it is recorded and delivered to nobody — the same data the API would answer
402 for.