Docs

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
// 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:

sender.ts
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,
  });
}
ResponseWhen
202Signed, recent, and parse accepted the body. The body is { "accepted": n }.
400The body is not JSON, or parse refused it.
401The signature is wrong, or the timestamp is missing or older than the tolerance (five minutes).
405Not a POST.
503The 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.