Webhooks
Get 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 → Developer → Webhooks and chooses which events it receives, or all of them.
Events
type |
Sent when |
|---|---|
person.added |
Someone is added to the team |
person.archived |
Someone is marked as having left the team |
job.created |
A job is created, in the office or from an online booking |
job.status_changed |
A job moves to another status, including when it is closed out, cancelled or booked |
quote.sent |
A quote is sent to the customer |
quote.accepted |
A quote is accepted, by the customer online or marked by the office |
quote.declined |
A quote is declined, by the customer online or marked by the office |
invoice.issued |
An invoice is issued |
invoice.paid |
An invoice is paid: by card online, marked paid, or paid in Xero |
An endpoint on All events also receives event types added later. Handle types you do not
recognise by ignoring them and answering 200.
The data of each event
data is a short summary of the record as it stood when the event happened, not the whole record.
id is CREW25's id for it. number is the number people see without the organisation's prefix or
padding, so job JB-00042 is 42. Amounts are whole cents.
| Object | Events | Fields |
|---|---|---|
person |
person.added, person.archived |
id, name; personType (admin, office, worker or subcontractor) on person.added only |
job |
job.created |
id, number, title, status (enquiry or scheduled) |
job |
job.status_changed |
id, number, from and status, each enquiry, scheduled, in_progress, on_hold, completed or cancelled |
quote |
quote.* |
id, number, totalCents: the quote's total before GST |
invoice |
invoice.* |
id, number, totalCents: the invoice's total including GST |
What your endpoint receives
A POST with a JSON body:
{
"id": "5f0c1b9e-8d7a-4e6f-9a2b-3c4d5e6f7a8b",
"type": "person.added",
"createdAt": "2026-09-24T21:31:11.000Z",
"organisationId": "org_a1b2c3d4e5f6a7b8",
"data": {
"object": "person",
"id": "2c9d7e1f-4a3b-4c5d-9e8f-7a6b5c4d3e2f",
"name": "Sam Carpenter",
"personType": "worker"
}
}
Each request carries these headers:
| Header | Value |
|---|---|
CREW25-Event-Id |
The event's id. The same on every retry of one event. |
CREW25-Event-Type |
The event's type. |
CREW25-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
CREW25-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. CREW25 retries it after 1, 5,
30, 120 and 360 minutes, then gives up. Because an event can arrive more than once, use
CREW25-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. CREW25 refuses URLs that point
at private or local network addresses.