Webhooks

Webhooks

Polling asks “anything new?” a thousand times for one yes. A webhook is the platform calling you — which means your endpoint is public, so every delivery must be verified before you act on it.

1. Register an endpoint

register.ts
1const endpoint = await nd.webhookEndpoints.create({
2 url: "https://example.com/hooks/nodedata",
3 events: ["listing.purchased", "payout.paid"],
4 description: "Production ledger sync",
5});
6
7console.log(endpoint.secret); // whsec_… — returned ONCE. Store it now.

The secret is returned exactly once

GET /webhook_endpoints never returns secrets. If you lose one, delete the endpoint and create a new one — there is no recovery path, by design.

2. Understand the signature

delivery headers
POST /hooks/nodedata HTTP/1.1
content-type: application/json
nd-signature: t=1785931200,v1=3f1c9a7e5b2d...c2
{"id":"evt_8Lp...","type":"listing.purchased","created":1785931200,"data":{...}}

v1 is HMAC-SHA256(secret, "{t}.{rawBody}") in lowercase hex. The timestamp is inside the signed message, which is what makes replay protection possible: an attacker can't pair a captured body with a fresh timestamp.

3. Verify it

app/api/hooks/nodedata/route.ts
1import { verifyRequest, WebhookVerificationError } from "@node-data/sdk";
2
3export async function POST(req: Request) {
4 try {
5 // Reads the raw body itself, so you can't verify re-serialized JSON
6 const event = await verifyRequest(req, process.env.ND_WEBHOOK_SECRET!);
7
8 switch (event.type) {
9 case "listing.purchased":
10 await recordSale(event.data);
11 break;
12 case "payout.paid":
13 await reconcile(event.data);
14 break;
15 }
16
17 return new Response(null, { status: 204 });
18 } catch (err) {
19 if (err instanceof WebhookVerificationError) {
20 // 400, not 200 — a 200 tells the sender an unverified request was accepted
21 return new Response(err.code, { status: 400 });
22 }
23 throw err;
24 }
25}

Without the SDK

verify.ts
1import { createHmac, timingSafeEqual } from "node:crypto";
2
3export function verify(raw: string, header: string, secret: string): void {
4 const parts = new Map(
5 header.split(",").map((p) => {
6 const i = p.indexOf("=");
7 return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const;
8 }),
9 );
10
11 const timestamp = Number(parts.get("t"));
12 const signature = parts.get("v1");
13 if (!Number.isFinite(timestamp) || !signature) throw new Error("malformed");
14
15 // Replay window
16 const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
17 if (age > 300) throw new Error("timestamp out of tolerance");
18
19 const expected = createHmac("sha256", secret)
20 .update(`${timestamp}.${raw}`)
21 .digest("hex");
22
23 // Constant time: a short-circuiting compare leaks the signature byte by byte
24 const a = Buffer.from(signature, "hex");
25 const b = Buffer.from(expected, "hex");
26 if (a.length !== b.length || !timingSafeEqual(a, b)) {
27 throw new Error("signature mismatch");
28 }
29}

Use the raw bytes, never re-serialized JSON

JSON.stringify(await req.json()) reorders keys, drops whitespace, and rewrites 1.0 as 1. Every one of those breaks the digest. Read req.text() first and parse only after verifying.

4. Handle events safely

Delivery is at-least-once. Retries after a timeout mean the same event can arrive twice, so dedupe on event.id and do slow work outside the request.

handler.ts
const event = await verifyRequest(req, secret);
// Idempotency: at-least-once delivery is a promise, not a bug
if (await seen(event.id)) return new Response(null, { status: 204 });
await enqueue(event); // slow work off the request path
await markSeen(event.id);
return new Response(null, { status: 204 });

Event catalog

TypeFires when
listing.purchasedA buyer completed checkout for one of your listings.
listing.publishedA listing became publicly visible.
listing.unpublishedA listing was removed from the marketplace.
asset.downloadedAn entitled buyer downloaded an artifact.
factory.job.completedAn AI Factory job finished generating and is ready for review.
factory.job.publishedAn AI Factory job was turned into a listing.
payout.paidA creator payout transfer settled.

Unknown event types in a create call are silently dropped, so a typo in a subscription list is quiet. Read the endpoint back after creating it to confirm what actually got registered.

Debugging a silent endpoint

  1. nd.webhookEndpoints.deliveries(id) — recent attempts, with response status and attempt count.
  2. Check enabled. A paused endpoint accepts no deliveries.
  3. Confirm the subscription actually includes the event you expect.
  4. Reproduce the signature locally with the signature tool — it signs and verifies with the same code the SDK ships.

Local development

Webhooks need a public URL. Tunnel your dev server (ngrok http 3000 or similar), register the tunnel URL as a test endpoint, and delete it when you're done — a dead tunnel URL just accumulates failed deliveries.