Webhooks
Do not poll us. Add an address in Settings and we post to it when something happens. You get a signing secret, shown once.
The events
| quote.received | A carrier has quoted on your load |
| load.booked | A carrier has it |
| load.collected | It has left. Until this the job is a plan, after it the job is happening. |
| load.delivered | Delivered, proof of delivery in |
| load.confirmed | The poster has confirmed it, or the day passed and it confirmed itself. This is when the job becomes billable, so it is usually the one to raise an invoice from. |
| load.cancelled | Called off |
| document.added | The other firm put paperwork on the job |
document.added is how a proof of delivery or a waste transfer note reaches your system without anybody going to look for it. It carries the kind and a link. The link needs a signed in session to open, so fetch it as a person rather than from a server.
What we send
POST https://your-system.example.com/haulageboard
hb-event: quote.received
hb-signature: t=1788000000,v1=5f2a...
Content-Type: application/json
{
"event": "quote.received",
"sentAt": "2026-09-14T09:31:02.000Z",
"data": {
"loadId": "clx...",
"ref": "HB-1042",
"externalRef": "JOB-88213",
"amountPence": 24500
}
}Every event carries externalRef, the reference you sent when you posted the load, so your system recognises its own work without keeping a table mapping our references to yours.
Both firms on a job are told. If you posted it you hear when it is booked, collected and delivered. If you won it you hear the same, so a system that quoted through us gets the booking back the same way rather than by somebody noticing an email. quote.received is the exception and goes only to the poster, because the mover already knows they sent it.
Checking the signature
A URL is not a secret. Anybody who learns yours could post you a made up booking, so check every call before you act on it.
Use the raw body
Take the body exactly as received, before any parsing. Re-serialising JSON changes whitespace and key order, and the signature will not match. This is the mistake that costs people an afternoon.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
)
const t = Number(parts.t)
// Reject anything old, or a call captured today can be replayed next week.
if (!Number.isFinite(t)) return false
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1 ?? '', 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}The timestamp is signed along with the body, so it cannot be edited to make an old call look new. Compare in constant time, not with a string equality, or the comparison itself leaks the answer a character at a time.
Answering us
Any 2xx. Answer quickly and do the work afterwards, because we give up on a call after five seconds and count it as a failure.
After ten failures in a row we switch the address off and show that in your settings, so a server that was down for a week does not silently stay disconnected. Turn it back on there once it is fixed.
Expect the same event more than once
Treat handlers as repeatable. Key off the load reference and the event name, and make sure handling the same one twice does nothing the second time.