Webhooks
Six events, one payload shape, and an HMAC signature on every request. This is the whole developer surface — there is no REST API and we are not currently building one, because almost every shop that has asked wanted exactly this instead.
The six events
| Event | When it fires | What is in it |
|---|---|---|
| `job.created` | Intake, or a Shopfront submission accepted | The job, its customer, its status |
| `job.status_changed` | Any status change, in either direction | The job, plus `from` and `to` |
| `job.note_added` | A note is written on a card | The job, the note, the author's initials |
| `part.received` | A part is booked in | The job, the part, the supplier |
| `deposit.paid` | A deposit clears | The job, the amount, nothing about the card |
| `job.collected` | A job leaves the shop | The job and its final total |
The payload
Same envelope every time. The `job` object is identical across all six.
```
{
"event": "job.status_changed",
"sent_at": "2026-08-29T16:12:04Z",
"workspace": "wickenden-cycles",
"job": {
"id": "job_4417",
"number": 4417,
"summary": "Creaking at the bottom bracket",
"from": "on_the_bench",
"to": "done",
"total_cents": 12800,
"deposit_cents": 4000,
"public_url": "https://benchtop.example/j/4417"
}
}
```
Money is always integer cents. There are no floats anywhere in the payload, for the reason everybody who has shipped a float knows.
Signing
Every request carries `Benchtop-Signature: t=<unix>,v1=<hex>`, an HMAC-SHA256 over `<t>.<raw body>` using the signing secret shown when you create the endpoint.
```
const [t, v1] = header.split(',').map(p => p.split('=')[1]);
const expected = hmacSha256(secret, `${t}.${rawBody}`);
if (!timingSafeEqual(expected, v1) || now() - t > 300) reject();
```
Verify against the raw body, before any JSON parsing. Re-serialising and then hashing produces a different string and a signature that never matches, which is the single most common support email about this page.
Retries
A non-2xx or a timeout over 10 seconds is retried six times over about 18 hours, backing off 1m, 5m, 30m, 2h, 6h, 12h. After that the delivery is marked failed and stays in the log for 30 days. We do not retry a 410, which is how you tell us an endpoint is gone for good.
Deliveries are at-least-once. Use `sent_at` and the job id to make your handler idempotent.
Pointing this at a till
Match on `job.number`, not on `job.id` — the number is what your staff type and what is printed on the card, and it is the only field a human will ever be able to give you over the phone. The id is stable and opaque and belongs in your database, not on a receipt. Most shops listen for `job.collected` and close the corresponding sale.