Webhook signature verifier
Paste the raw body, the signing secret and the headers of a webhook delivery. The verifier checks the signature the way the SDK does and, when it fails, tells you why.
Verify a delivery
Every delivery carries webhook-id, webhook-timestamp and
webhook-signature. Copy them with the body from your server's request log.
All processing happens in this browser tab. The body, secret and headers you paste are read by a script on this page and checked with the Web Crypto API. They are never sent to Workwae or anyone else: using the verifier makes no network request. Even so, prefer a test endpoint's secret to a production one.
Result
How a signature is computed
Workwae signs deliveries as Standard Webhooks does, so any library for that standard verifies them. This is what the SDK and the verifier above do.
-
Read
webhook-id,webhook-timestampandwebhook-signature. All three are required. - Refuse the delivery unless the timestamp is 1 to 15 digits, a Unix time in seconds, and within 300 seconds of your clock, either side. This stops a captured request being replayed later.
-
Take the secret after
whsec_and decode it from base64. The bytes are the HMAC key. -
Compute the HMAC-SHA256 of the UTF-8 text
{webhook-id}.{webhook-timestamp}.{body}, with the body exactly as received, and encode it as base64. -
webhook-signaturelists entries writtenv1,<base64>, separated by spaces: two while a secret is being rotated. Compare each of the first 8 with your result in constant time. One match is enough.
Verify in your receiver
Use the SDK's helper, or the few lines of node:crypto below in Node 20+ or
Bun. Read the body as text first: a parsed and re-serialized body does not verify. The
signature guide covers
rotation and parseWebhook, which also parses the event.
import { checkWebhookSignature } from "@workwae/sdk";
export async function POST(request: Request): Promise<Response> {
// The raw body, exactly as received. Parsed and
// re-serialized JSON does not verify.
const body = await request.text();
const failure = await checkWebhookSignature(
// whsec_...; a TypeError if it is not base64
process.env.WORKWAE_WEBHOOK_SECRET!,
request.headers,
body,
);
if (failure) {
// "missing_headers" | "timestamp" | "signature"
console.warn(`Webhook refused: ${failure}`);
return new Response("Invalid webhook", { status: 400 });
}
const event = JSON.parse(body);
// Handle event.type and event.data here.
return new Response(null, { status: 204 });
}
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyWorkwaeWebhook(
secret: string,
headers: Headers,
body: string,
): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
if (!/^[0-9]{1,15}$/.test(timestamp)) return false;
// Refuses a replay of an old, captured request.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
// The key is the base64 text after "whsec_".
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
if (key.length === 0) throw new TypeError("Secret is not base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest();
// Space-separated "v1,<base64>" entries (two while a secret
// rotates; the first 8 count). One match is enough.
return signatures.split(" ", 8).some((candidate) => {
const [version, value] = candidate.split(",", 2);
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return (
given.length === expected.length &&
timingSafeEqual(given, expected)
);
});
}
// In a fetch-style handler (Bun.serve, Hono, a Next.js route):
// const body = await request.text(); // raw, before parsing
// const secret = process.env.WORKWAE_WEBHOOK_SECRET!;
// if (!verifyWorkwaeWebhook(secret, request.headers, body))
// return new Response("Invalid webhook", { status: 400 });
What the verifier can tell you
It checks the signature, not the event: whether a signed body is a valid Workwae event
is a separate check that parseWebhook makes.
| Finding | What it means | SDK result |
|---|---|---|
| A header is missing | webhook-id, webhook-timestamp or webhook-signature is absent or empty. | missing_headers |
| The timestamp is malformed or outside the tolerance | It is not 1 to 15 digits, or it is more than 300 seconds from the clock of the machine checking it. A timestamp in milliseconds is recognized. | timestamp |
| The secret is not valid | What follows whsec_ is not base64: quotes, an NAME= prefix, URL-safe characters or a cut-off copy. The SDK throws a TypeError for this; it is a configuration error, not a forged delivery. | throws TypeError |
| The signature uses another scheme or is malformed | No entry has the form v1,<base64>: a Stripe, GitHub or hex format, another version (v0, v1a), a missing prefix or a truncated value. | signature |
| The body was altered after signing | The signature matches once whitespace, line breaks, a byte order mark, JSON escaping or pretty-printing is undone. A parser or formatter changed the bytes before the check. | signature |
| No signature matches | The format is right and nothing explains the difference: another endpoint's secret, headers from another delivery or a changed body. | signature |
Receive signed webhooks from every inbox.
Workwae is in early access. Request access to get an endpoint and a signing secret of your own, or read the signature guide first.