Webhooks
Get order and job events pushed to your server as they happen, signed so you can trust them.
Webhooks tell your systems when something happens in an organisation, so you do not have to poll the API. An owner or admin adds an endpoint in the office under Settings → Webhooks and chooses which events it receives, or all of them.
Events
type |
Sent when | data is |
|---|---|---|
order.created |
An order is placed, from the office, the booking page or the API | An order |
order.cancelled |
An order is cancelled | An order |
job.started |
A driver starts a job and heads to the site | A job |
job.completed |
A driver completes a job | A job |
job.failed |
A driver records a failed attempt | A job |
An endpoint on All events also receives event types added later. Handle types you do not
recognise by ignoring them and answering 200.
What your endpoint receives
A POST with a JSON body:
{
"id": "5f0c1b9e-8d7a-4e6f-9a2b-3c4d5e6f7a8b",
"type": "job.completed",
"createdAt": "2026-09-24T21:31:11.000Z",
"organisationId": "org_a1b2c3d4e5f6a7b8",
"data": {
"object": "job",
"id": "2c9d7e1f-4a3b-4c5d-9e8f-7a6b5c4d3e2f",
"reference": "J-20931",
"status": "done"
}
}
data is the same object the API returns for that order or job (shortened above), as it stood when
the event happened. Fetch it again from the API if you need its current state.
Each request carries these headers:
| Header | Value |
|---|---|
OPS25-Event-Id |
The event's id. The same on every retry of one event. |
OPS25-Event-Type |
The event's type. |
OPS25-Signature |
t=<unix seconds>,v1=<signature>. See below. |
Verifying the signature
Every endpoint has a signing secret, starting whsec_, shown in its settings. Check every request
before trusting it:
- Split
OPS25-Signatureon,intotandv1. - Compute an HMAC-SHA256 of
<t>.<raw request body>, keyed with the signing secret, as hex. - Compare it with
v1in constant time, and reject the request if they differ. - Reject the request if
tis more than five minutes from your clock.
Use the raw body exactly as it arrived. Parsing and re-serialising the JSON changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''));
}
Rotating the secret in settings replaces it immediately. Update your receiver straight away.
Responding and retries
Answer with any 2xx status within 10 seconds. Do slow work after you respond, not before.
Anything else, including a redirect or a timeout, is a failed delivery. OPS25 retries it after 1, 5,
30, 120 and 360 minutes, then gives up. Because an event can arrive more than once, use
OPS25-Event-Id to ignore ones you have already handled. Events are not guaranteed to arrive in
order; compare createdAt if order matters.
Settings shows the latest deliveries with their response codes, and Send test event posts a
webhook.test event to check an endpoint straight away.
Endpoint requirements
Endpoints must use https:// and be reachable on the public internet. OPS25 refuses URLs that point
at private or local network addresses.