Webhooks
Webhooks let your backend react to things that happen in Noughty Reality without
polling. When an event occurs, we send an HTTP POST to a URL you register, with
a signed JSON body. You verify the signature, do your work, and return 2xx.
This is the reverse of the rest of the Server API: here we call you. You provide an HTTPS endpoint; we deliver events to it.
Setup
Give the Noughty Reality team the HTTPS URL that should receive events. In return you get a signing secret:
| Value | Shape | Notes |
|---|---|---|
| Signing secret | sk_whk_… | Shown once. Store it in a secrets manager — it's how you verify every delivery. |
One endpoint per client. The same secret signs every event sent to that endpoint.
The request
Each delivery is an HTTP POST with a JSON body and one signature header:
POST https://your-backend.com/webhooks
Content-Type: application/json
X-Noughty-Signature: t=1699900000,v1=3f8a9c2b…
{
"id": "evt_01JABCXYZ…",
"type": "scan.processed",
"createdAt": "2026-09-11T12:00:00.000Z",
"data": {
"scanId": "0191…",
"roomId": "0191…",
"floorId": "0191…",
"tourId": "0191…",
"organisationId": "org_123"
}
}
Every event shares the same envelope:
| Field | Type | Description |
|---|---|---|
id | string | Unique event id (evt_…). Use it to deduplicate — see Idempotency. |
type | string | The event type (below). |
createdAt | string | ISO-8601 timestamp of when the event occurred. |
data | object | Identifiers for the affected resource. Fetch full details from the Server API. |
data contains the ids needed to locate the resource, not its full content (which
can change between the event and delivery). Use them to GET the current state,
e.g. /server/tours/{tourId}.
Event types
| Type | When | data |
|---|---|---|
tour.created | A tour is created | tourId, organisationId |
scan.processed | A scan finishes processing | scanId, roomId, floorId, tourId, organisationId |
scan.failed | A scan fails processing | scanId, roomId, floorId, tourId, organisationId |
Verifying the signature
The X-Noughty-Signature header proves the request came from us and wasn't
altered. It has two parts:
X-Noughty-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
To verify, recompute the signature over "{t}.{rawBody}" with your signing
secret and compare it to v1 in constant time. Use the raw request body —
re-serialising parsed JSON can change the bytes and break the check.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
// Reject old timestamps to prevent replay.
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}
Your endpoint is public — anyone can POST to it. Reject any request whose
signature doesn't verify, and reject timestamps older than a few minutes.
Responding
Return any 2xx status to acknowledge receipt. Anything else — or no response
within 10 seconds — is treated as a failure and retried.
Acknowledge fast: verify, enqueue the work on your side, and return 2xx. Don't
do slow processing before responding, or you risk the timeout and a needless retry.
Retries
We attempt delivery immediately. If your endpoint doesn't return 2xx (or times
out), we retry with backoff:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 24 hours |
After the 6th failed attempt the delivery is marked exhausted and we stop. Because of retries, delivery is at-least-once — the same event may arrive more than once.
Idempotency
Since an event can be delivered more than once (a retry after your endpoint
already processed it, a network blip), deduplicate on id. Record the event
ids you've handled and ignore repeats. Handlers should be safe to run twice.