Skip to main content

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.

Direction

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:

ValueShapeNotes
Signing secretsk_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:

FieldTypeDescription
idstringUnique event id (evt_…). Use it to deduplicate — see Idempotency.
typestringThe event type (below).
createdAtstringISO-8601 timestamp of when the event occurred.
dataobjectIdentifiers for the affected resource. Fetch full details from the Server API.
Payloads carry identifiers, not full objects

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​

TypeWhendata
tour.createdA tour is createdtourId, organisationId
scan.processedA scan finishes processingscanId, roomId, floorId, tourId, organisationId
scan.failedA scan fails processingscanId, 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);
}
Verify before you trust

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:

AttemptDelay after previous
1immediate
25 minutes
330 minutes
42 hours
56 hours
624 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.