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": "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:

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 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​

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