Skip to main content
Every webhook delivery from Outhire includes a cryptographic signature so you can verify the request is authentic and hasn’t been tampered with.

Signature headers

Each request includes three headers:
HeaderDescription
webhook-idUnique identifier for this delivery
webhook-timestampUnix timestamp (seconds) of when the request was sent
webhook-signatureHMAC-SHA256 signature of the request

Verification steps

1

Extract the headers

Read the webhook-id, webhook-timestamp, and webhook-signature headers from the incoming request.
2

Construct the signature base string

Concatenate the webhook ID, timestamp, and raw request body, separated by periods:
{webhook-id}.{webhook-timestamp}.{raw_request_body}
Use the raw request body as received — do not parse and re-serialize the JSON.
3

Compute the expected signature

Sign the base string using HMAC-SHA256 with your webhook signing secret (the whsec_ prefixed value from your webhook settings, with the prefix stripped before use as the key).Base64-encode the resulting hash.
4

Compare signatures

The webhook-signature header contains the signature in the format:
v1,<base64-encoded-signature>
Extract the signature after v1, and compare it to your computed value using a constant-time comparison to prevent timing attacks.
5

Check the timestamp

Verify that webhook-timestamp is within 5 minutes of the current time to prevent replay attacks. Reject requests with timestamps outside this window.

Example implementations

import crypto from "crypto";

function verifyWebhookSignature(request, secret) {
  const webhookId = request.headers["webhook-id"];
  const timestamp = request.headers["webhook-timestamp"];
  const signature = request.headers["webhook-signature"];
  const body = request.rawBody; // raw request body as string

  // Check timestamp is within 5 minutes
  const now = Math.floor(Date.now() / 1000);
  const diff = Math.abs(now - parseInt(timestamp));
  if (diff > 300) {
    throw new Error("Webhook timestamp too old");
  }

  // Strip the whsec_ prefix from your secret
  const signingKey = Buffer.from(secret.replace("whsec_", ""), "base64");

  // Construct base string and compute HMAC
  const baseString = `${webhookId}.${timestamp}.${body}`;
  const expected = crypto
    .createHmac("sha256", signingKey)
    .update(baseString)
    .digest("base64");

  // Extract signature value (after "v1,")
  const received = signature.split(",")[1];

  // Constant-time comparison
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
    throw new Error("Invalid webhook signature");
  }

  return JSON.parse(body);
}
Always use the raw request body for signature verification. Parsing the JSON and re-serializing it may change formatting (whitespace, key order) and cause verification to fail.

Troubleshooting

  • Confirm you’re using the correct signing secret from your webhook settings
  • Make sure you’re stripping the whsec_ prefix before using the secret as a key
  • Verify you’re using the raw request body, not a parsed/re-serialized version
  • Check that your base string format is exactly {webhook-id}.{timestamp}.{body} with period separators
  • Ensure your server clock is synchronized (use NTP)
  • The tolerance window is 5 minutes — requests outside this window are considered invalid
  • Confirm your endpoint returns a 2xx status within 10 seconds
  • Check that your endpoint handles the larger payloads of production events
  • Review delivery logs in the Outhire admin UI for specific error details