Node webhook inspector

A webhook inspector accepts test events, labels familiar payloads, verifies optional signatures, and returns a compact receipt to the sender. Tokay runs it as a Function and keeps each request beside its response, duration, and logs.

Last updated

Use it when you are connecting a provider and need to understand the actual bytes reaching your code before you build the full workflow. You can begin with unsigned samples, then require HMAC signatures when the sender is ready.

Runtime
Node.js
Framework
Express
Service types
Function
Resources
None
Required secrets
None

What it does

Press Send Test Event and the request appears under Requests with the Function's JSON response and matching log line.

Send a payload containing an email and message and the handler labels it form lead. An order.created event receives new order, while anything else remains general.

Once WEBHOOK_SECRET is set, unsigned or invalid requests receive 401. Generic SHA256 headers and Stripe style timestamped signatures are both accepted.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Try it

  1. Copy the Function URL from the top of the Service page. Replace the placeholder below with that URL, then send the form event.
export FUNCTION_URL="https://your-function-url"
curl -X POST "$FUNCTION_URL/hooks/forms" \
  -H "content-type: application/json" \
  -d '{"email":"mira@example.com","message":"Could you quote the autumn workshop?"}'
  1. Open Requests and confirm the response tag is form lead.
  2. Choose Copy as cURL and replay the request. The new history entry gives you the same response from the same body.

Turn on signature checks

When you are ready to reject unsigned requests, open the Function's Environment page and find WEBHOOK_SECRET under From your code. Choose Set shared value, save the secret, and deploy again.

Generic senders can put the lowercase HMAC SHA256 digest in x-webhook-signature, with or without the sha256= prefix. Stripe style signatures with t and v1 fields work too.

Unsigned or invalid requests now return 401. Send Test Event is unsigned, so use that button before adding the secret. Afterward, replay a signed request from your provider.

How it works

You can inspect signatures correctly because Express keeps the raw body instead of parsing and rebuilding it first. The handler checks the optional secret, applies one small tagging rule, and returns its receipt.

Tokay puts the public Function URL and request history around that code. You get a debugging surface without adding an event dashboard to the project.

Secrets

Name Required Purpose
WEBHOOK_SECRET No Requires a valid generic or Stripe style signature.

Grow from here

Try Replace a Zap when each event should enter a database and feed a scheduled digest.

src/index.ts Raw

import { createHmac, timingSafeEqual } from "node:crypto";
import express, { Request } from "express";

const app = express();
app.use(express.raw({ type: "*/*", limit: "1mb" }));

function sameHex(left: string, right: string) {
  const a = Buffer.from(left, "hex");
  const b = Buffer.from(right, "hex");
  return a.length > 0 && a.length === b.length && timingSafeEqual(a, b);
}

function signatureIsValid(request: Request, body: Buffer, secret: string) {
  const generic = request.get("x-webhook-signature");
  if (generic) {
    const supplied = generic.replace(/^sha256=/, "");
    const expected = createHmac("sha256", secret).update(body).digest("hex");
    return sameHex(supplied, expected);
  }

  const stripe = request.get("stripe-signature");
  if (stripe) {
    const fields = stripe.split(",").map(part => part.split("=", 2));
    const timestamp = fields.find(([key]) => key === "t")?.[1];
    const signatures = fields.filter(([key]) => key === "v1").map(([, value]) => value);
    if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
    const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex");
    return signatures.some(value => sameHex(value, expected));
  }

  return false;
}

function readJson(body: Buffer) {
  try {
    return JSON.parse(body.toString("utf8"));
  } catch {
    return null;
  }
}

function tagPayload(payload: unknown) {
  if (!payload || typeof payload !== "object") return "unstructured";
  const record = payload as Record<string, unknown>;
  if (typeof record.email === "string" && typeof record.message === "string") return "form lead";
  if (record.type === "order.created" || record.event === "order.created") return "new order";
  return "general";
}

function receive(request: Request, response: express.Response) {
  const body = Buffer.isBuffer(request.body) ? request.body : Buffer.from("");
  const secret = process.env.WEBHOOK_SECRET?.trim() || "";
  if (secret && !signatureIsValid(request, body, secret)) {
    console.log(JSON.stringify({ event: "webhook.rejected", hook: request.params.name || "test", reason: "bad signature" }));
    return response.status(401).json({ ok: false, error: "A valid webhook signature is required." });
  }

  const payload = readJson(body);
  const hook = request.params.name || "test";
  const tag = tagPayload(payload);
  const summary = {
    event: "webhook.received",
    hook,
    tag,
    providerEvent: payload?.type || payload?.event || null,
    bytes: body.length,
  };
  console.log(JSON.stringify(summary));
  return response.status(200).json({ ok: true, hook, tag, receivedAt: new Date().toISOString() });
}

app.post("/", receive);
app.post("/hooks/:name", receive);
app.get("/health", (_request, response) => response.json({ ok: true }));

const port = Number(process.env.PORT || 3000);
app.listen(port, "0.0.0.0", () => console.log(`Webhook handler ready on port ${port}`));

package.json Raw

{
  "name": "node-webhook-inspector",
  "version": "1.0.0",
  "private": true,
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "5.2.1"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^24.0.0",
    "typescript": "5.9.3"
  }
}

tsconfig.json Raw

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Built from revision 52c7b7f

Guide: Deploy a webhook