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
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
POST /hooks/nodedata HTTP/1.1content-type: application/jsonnd-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
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 JSON6 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 accepted21 return new Response(err.code, { status: 400 });22 }23 throw err;24 }25}Without the SDK
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 window16 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 byte24 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.
const event = await verifyRequest(req, secret); // Idempotency: at-least-once delivery is a promise, not a bugif (await seen(event.id)) return new Response(null, { status: 204 }); await enqueue(event); // slow work off the request pathawait markSeen(event.id); return new Response(null, { status: 204 });Event catalog
| Type | Fires when |
|---|---|
listing.purchased | A buyer completed checkout for one of your listings. |
listing.published | A listing became publicly visible. |
listing.unpublished | A listing was removed from the marketplace. |
asset.downloaded | An entitled buyer downloaded an artifact. |
factory.job.completed | An AI Factory job finished generating and is ready for review. |
factory.job.published | An AI Factory job was turned into a listing. |
payout.paid | A 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
nd.webhookEndpoints.deliveries(id)— recent attempts, with response status and attempt count.- Check
enabled. A paused endpoint accepts no deliveries. - Confirm the subscription actually includes the event you expect.
- 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.