Skip to main content

Verify webhook requests with HMAC signatures

Add a signing secret to your webhook so your endpoint can confirm each request came from Haystack and wasn't changed on the way.

Written by Jordie

Anyone who knows your webhook's URL can send requests to it. Without verification, an attacker could send fake requests that create, change or delete records in your systems. A signing secret lets your endpoint tell Haystack's requests apart from everyone else's.

With a signing secret, Haystack signs every request it sends to your webhook, and your endpoint checks the signature before acting on the request. A valid signature tells you that:

  • the request came from Haystack, because only Haystack and your server know the secret.

  • it wasn't changed on the way, because the signature covers the request body.

  • it was sent recently, because the signature covers the time it was sent, so an old request can't be captured and sent to you again.

Haystack signs requests the way the open Standard Webhooks specification describes, using HMAC with SHA-256. Free libraries are available to verify requests in most languages.

Verifying signatures needs code on your endpoint. If your webhook goes to a tool that can't run code, such as Zapier or Make, you can check a shared value in a filter step instead using a custom header. See Add custom headers to your webhooks.

Before you start

How signing works

  1. You create a signing secret, save it on your webhook in Haystack, and keep a copy on your server.

  2. For each request, Haystack computes a signature from the request's id, its timestamp and its body, keyed with your secret, and sends the signature with the request.

  3. Your endpoint computes the signature again with its own copy of the secret. If the two match, the request is genuine.

The signature reveals nothing about your secret, so it's safe for it to travel with the request.

Every request carries three headers. Header names aren't case-sensitive, so your framework may show them in a different case.

Header

What it holds

webhook-id

A unique id for the request. Treat it as opaque; its format may change. A request that's sent again keeps the same id.

webhook-timestamp

When the request was sent, in seconds since the Unix epoch.

webhook-signature

The signature: v1, followed by base64 text. Only sent once the webhook has a signing secret.

Create a signing secret

Haystack doesn't generate secrets. You create your own, so it only ever exists in Haystack and on your own systems.

A signing secret is whsec_ followed by 24 to 64 random bytes, base64-encoded. 32 bytes is a good choice. Any of these commands will create one:

# Terminal (macOS, Linux)
echo "whsec_$(openssl rand -base64 32)"

# Node.js
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"

# Python
python3 -c "import base64, secrets; print('whsec_' + base64.b64encode(secrets.token_bytes(32)).decode())"

Store the secret where your server keeps its other credentials, such as an environment variable or a secrets manager. Don't put it in your source code.

Add the secret to your webhook

  1. In the Admin Dashboard, go to Integrations → Webhook.

  2. On the webhook you want to sign, find Signing secret and select Add signing secret.

  3. Paste your secret, including the whsec_ at the start, and select Save. If the secret isn't in the right format, Haystack says what's wrong before you save.

From then on, every request is signed, including test sends. The secret is shown as •••••••• and is never displayed again.

Tip: add the secret first. Until your endpoint checks signatures, the extra header is simply ignored. Then deploy your verification code, use Send test to confirm signatures pass, and start rejecting requests that fail.

Verify requests

Use the raw request body. Verify the body exactly as it arrived, before your framework parses it as JSON. Parsing and re-encoding can change spacing or key order, and then the signature won't match.

Verify with a library (recommended)

Standard Webhooks libraries do every check in this article for you, and take your whsec_… secret exactly as it is. They're available for JavaScript/TypeScript, Python, PHP, Go, Ruby, Java, Kotlin, C#, Rust and Elixir; see Standard Webhooks on GitHub. Pass the library the raw body, the request's headers and your secret. It returns the parsed body, or throws an error if the request isn't genuine.

Node.js (Express), with npm install standardwebhooks:

import express from "express";
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.HAYSTACK_SIGNING_SECRET);
const app = express();

// express.raw keeps the body exactly as it arrived.
app.post("/haystack", express.raw({ type: "application/json" }), (req, res) => {
let payload;
try {
payload = webhook.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.sendStatus(401);
}
// payload is the verified, parsed JSON body.
res.sendStatus(200);
});

Python (Flask), with pip install standardwebhooks:

import os
from flask import Flask, request, abort
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["HAYSTACK_SIGNING_SECRET"])
app = Flask(__name__)

@app.post("/haystack")
def haystack():
try:
# get_data reads the raw body, before any JSON parsing.
payload = webhook.verify(request.get_data(as_text=True), dict(request.headers))
except Exception:
abort(401)
# payload is the verified, parsed JSON body.
return "", 200

Verify manually

If you can't use a library, follow these steps. Each step shows example values, so you can check your code produces the same results.

You'll need:

  • the request: its raw body and its headers

  • your signing secret

  • HMAC-SHA256 and base64 support, which almost every language has built in

1. Get your signing secret and the raw request body.

In this example, the secret is:

whsec_CzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYY=

and the body is:

{"firstName":"Grace","lastName":"Hopper","email":"grace@example.com"}

This secret is published here, so never use it for a real webhook.

2. Read the three headers. If any is missing, reject the request.

webhook-id: k7Qm2pXr9LwTv4sNb8cYdA
webhook-timestamp: 1767225600
webhook-signature: v1,qpZ5yHo3KOlOQZSi+0VkxasDgbKkLdg0vD2pRt5Yc18=

3. Check the timestamp. Reject the request if webhook-timestamp is too far from your server's current time, in either direction. Allow at least 5 minutes; see Replays and duplicates below for why.

The example timestamp is in the past, so a real check rejects it. Skip this step while testing with these values.

4. Build the signed content. Join the id, the timestamp and the raw body with full stops (.):

k7Qm2pXr9LwTv4sNb8cYdA.1767225600.{"firstName":"Grace","lastName":"Hopper","email":"grace@example.com"}

5. Turn your secret into the key. Remove whsec_ and base64-decode the rest. The bytes you get are the key. Don't use the secret's text as the key, because that gives a different signature. As hex, the example key is:

0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186

6. Compute the signature. Calculate the HMAC-SHA256 of the signed content using the key, then base64-encode the result:

qpZ5yHo3KOlOQZSi+0VkxasDgbKkLdg0vD2pRt5Yc18=

To check this from a terminal:

printf '%s' 'k7Qm2pXr9LwTv4sNb8cYdA.1767225600.{"firstName":"Grace","lastName":"Hopper","email":"grace@example.com"}' \
| openssl dgst -sha256 -mac HMAC -macopt hexkey:0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186 -binary \
| base64

7. Compare it with the header. webhook-signature is a space-separated list of entries, each a version and a signature separated by a comma, such as v1,qpZ5…. Accept the request if any v1 entry matches the signature you computed.

  • Ignore entries with any version other than v1. Otherwise an attacker could add a weaker version to the list for you to check against.

  • Compare with a constant-time function, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python, never ==. A plain comparison can leak how much of a guess was right through how long it takes.

Haystack sends one entry today, but handle the list anyway so your code keeps working if that changes.

Here are the same steps in Node.js:

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

const TOLERANCE_SECONDS = 5 * 60; // at least 5 minutes

export function isFromHaystack(rawBody, headers, signingSecret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) return false;

// Reject requests sent too long ago (or too far in the future).
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) return false;

// The key is the decoded bytes after "whsec_", not the secret's text.
const key = Buffer.from(signingSecret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();

// The header is a space-separated list; any one matching v1 entry is enough.
return signatureHeader.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

Replays and duplicates

Replays. In a replay attack, someone captures a genuine request and sends it to you again later. The timestamp is part of what's signed, so it can't be changed without breaking the signature, and checking it means an old request is rejected even though its signature is valid.

  • Allow at least 5 minutes, the default in Standard Webhooks libraries. If Haystack can't deliver a request, it retries for a few minutes with the original timestamp and signature, so a shorter window can reject those retries. A longer window is fine; it just gives a captured request longer to be replayed.

  • Keep your server's clock accurate, for example with NTP. If it drifts, genuine requests start failing the check.

Duplicates. A request can reach you more than once, for example when an earlier attempt timed out after your server had already processed it. A retry keeps the request's webhook-id. A request Haystack re-sends later also keeps its id, but gets a new timestamp and signature. Record the ids you've processed and skip any you've already handled.

Test sends have ids starting with test-, so you can recognise them.

Rotate or remove your secret

Rotate your secret now and then, and straight away if you think it's been exposed or someone with access to it leaves your team.

A webhook has one signing secret at a time, and saving a new one replaces the old one immediately. To rotate without rejecting genuine requests:

  1. Create a new secret.

  2. Update your endpoint to accept a signature made with either the old or the new secret.

  3. In Haystack, click the masked secret, paste the new one and select Save.

  4. Once requests verify with the new secret, remove the old one from your endpoint.

To stop signing, select the bin icon next to the secret and confirm. Requests are sent without a webhook-signature header from the next one on, so first make sure your endpoint no longer requires one.

Troubleshooting

If signatures don't match, it's almost always one of these:

  • The body changed before verification. Your framework parsed the JSON, or middleware re-encoded it. Verify the raw body.

  • The secret was used as text. The key is the base64-decoded bytes after whsec_. Libraries handle this for you.

  • The signature was compared as hex. It's base64.

  • The secrets don't match. The secret on your server isn't the one saved in Haystack. You can't view the saved one, so if in doubt, save your server's copy to Haystack again.

  • The secret picked up a stray character. A space or line break was copied along with it. Haystack trims what you paste, so check your server's copy.

  • Every request fails the timestamp check. Your server's clock is probably wrong. Sync it, for example with NTP.

Related articles

Did this answer your question?