Receive webhooks
A verified endpoint that turns signed pushes from another system into readings.
When a platform can push, let it. webhookSource from @klorad/protocols is a receiver that
accepts a push only when it is signed with your shared secret and recent, validates the body,
and reports the readings to the scene it is connected to.
// app/api/hooks/bins/route.ts in a Next.js app: the endpoint a bin sensor platform pushes to.
import { z } from "zod";
import { connectSource } from "@klorad/api/integration";
import { createScene } from "@klorad/api/world";
import { webhookSource } from "@klorad/protocols";
const scene = createScene({ coordinateSystem: { origin: { lat: 40.62637, lon: 22.94838 } } });
scene.add({ id: "bin-n1", name: "Bin N1", position: { east: -30, north: 70 }, correspondence: "shadow", binding: { source: "bins", entity: "n1" } });
const Push = z.object({ device: z.string(), fill: z.number().min(0).max(100), measuredAt: z.string().datetime() });
const bins = webhookSource({
id: "bins",
secret: () => process.env.BINS_WEBHOOK_SECRET ?? "",
parse: (body) => {
const push = Push.parse(body);
return [{ entity: push.device, quantity: "fill", value: push.fill, unit: "%", observedAt: Date.parse(push.measuredAt) }];
},
});
connectSource(scene, bins);
// 202 when accepted; 401 for a bad signature or a stale timestamp; 400 when parse refuses the body.
export const POST = (request: Request) => bins.handle(request);The signature
The sender signs timestamp.body with HMAC-SHA256 and the shared secret, and sends two headers:
x-klorad-timestamp (epoch milliseconds) and x-klorad-signature (sha256= followed by the hex
digest). signWebhook produces the header value:
import { signWebhook } from "@klorad/protocols";
// What a sender does: sign `timestamp.body` with the shared secret and send both headers.
export async function sendFill(secret: string, fill: number) {
const body = JSON.stringify({ device: "n1", fill, measuredAt: new Date().toISOString() });
const timestamp = Date.now();
return fetch("https://twin.example.com/api/hooks/bins", {
method: "POST",
headers: {
"content-type": "application/json",
"x-klorad-timestamp": String(timestamp),
"x-klorad-signature": await signWebhook(secret, timestamp, body),
},
body,
});
}| Response | When |
|---|---|
202 | Signed, recent, and parse accepted the body. The body is { "accepted": n }. |
400 | The body is not JSON, or parse refused it. |
401 | The signature is wrong, or the timestamp is missing or older than the tolerance (five minutes). |
405 | Not a POST. |
503 | The source is not connected to a scene. |
A receiver cannot be created without a secret: unsigned webhooks are never accepted. Change
the header names with signatureHeader and timestampHeader, the window with toleranceMs.
Where the scene lives
A webhook source feeds the scene in the same server process, which is what a platform service or a worker does. The packages have no transport that carries that live state to browsers; a browser reads it from your server.
Something wrong or unclear? Tell us.