Six events, one payload shape, and how to point them at the till so that a paid job closes itself.

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

EventWhen it firesWhat is in it
`job.created`Intake, or a Shopfront submission acceptedThe job, its customer, its status
`job.status_changed`Any status change, in either directionThe job, plus `from` and `to`
`job.note_added`A note is written on a cardThe job, the note, the author's initials
`part.received`A part is booked inThe job, the part, the supplier
`deposit.paid`A deposit clearsThe job, the amount, nothing about the card
`job.collected`A job leaves the shopThe 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.