---
title: "Webhook signature verifier · Workwae"
description: "Paste a Workwae webhook's body, signing secret and signature headers to see whether the signature is valid and, if not, why. It runs in your browser."
url: https://workwae.com/tools/webhook-verifier
---

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](https://www.standardwebhooks.com/) 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](https://docs.workwae.com/guides/webhook-signatures) covers rotation and `parseWebhook`, which also parses the event.

webhook.ts · with the SDK

```ts
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

```ts
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.

[Request early access](https://workwae.com/early-access/) [Read the signature guide](https://docs.workwae.com/guides/webhook-signatures)
