Skip to main content

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:

AttemptDelay after the previous
21 minute
35 minutes
430 minutes
52 hours
65 hours
710 hours
820 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

EventFires when
employee.createdAn employee record was created.
employee.updatedAny field on an employee changed.
employee.deletedAn employee was soft-deleted. The payload carries deleted: true.
attendance.recordedAn attendance record was created for a day.
attendance.updatedAn existing attendance record changed.
leave.requestedAn employee applied for leave.
leave.approvedA leave application was approved.
leave.rejectedA leave application was rejected.
leave.cancelledA leave application was cancelled.
payroll.run.approvedA payroll run was approved.
payroll.run.lockedA payroll run was locked and can no longer change.
salary_slip.publishedA salary slip was published to an employee.
contract.signedAn 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.