Tools

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.

Runs in your browser; nothing is sent

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.

The algorithm

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.

  1. Read webhook-id, webhook-timestamp and webhook-signature. All three are required.
  2. 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.
  3. Take the secret after whsec_ and decode it from base64. The bytes are the HMAC key.
  4. 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.
  5. webhook-signature lists entries written v1,<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.
In your code

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.

webhook.ts · with the SDK
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 });
}
verify.ts · node:crypto only
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 });
Diagnosis

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
Early access

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.