CH. 04 A field guide · trust, but verify

How to verify webhook signatures

Your webhook URL is public. Anyone can POST to it. The signature header is how you tell a real event from a forged one: the sender mixes a shared secret with the exact bytes it sent, and you do the same math on the bytes you got. Here's how that works for Stripe, GitHub, Slack, Shopify and Standard Webhooks, with code that's been tested, and a playground to break it in.

Algorithm, all five
HMAC-SHA256
Usual replay window
5 min
Changes allowed
0 bytes
Fig. 01 — the same math on both endsLive
Sender and receiver compute the same HMAC over the same bytes Stripe computes HMAC-SHA256 with the endpoint secret over the timestamp, a dot and the body, and sends the result in the Stripe-Signature header. The receiver builds the same string from the raw bytes it received and computes the HMAC with the same secret. The two values match, so it answers 200. Below, a relay added one space to the body. The receiver's result is completely different, and it answers 400. SENDER · STRIPE key whsec_example msg t + "." + body HMAC-SHA256 → v1=c100576e… THE REQUEST · HTTPS POST /webhook Stripe-Signature: t=1492774577, v1=c100576e… {"id":"evt_1",…} YOUR RECEIVER same key, same msg, built from the raw bytes HMAC-SHA256 → ? THE CHECK, ON YOUR RECEIVER HEADER SAYS v1=c100576ed1091bd0a7ace3cb… YOU COMPUTE v1=c100576ed1091bd0a7ace3cb… ✓ MATCH → 200 ✕ A RELAY ADDED ONE SPACE ON THE WAY BYTES NOW {"id":␣"evt_1",…} YOU COMPUTE v1=8122a06034e54fbc2212b7… ✕ NO MATCH → 400 SAME SECRET · ONE BYTE CHANGED · A NEW SIGNATURE
Real values: secret whsec_example, timestamp 1492774577, body {"id":"evt_1","type":"payment_intent.succeeded"}. One extra space after the first colon changes the signature completely, which is exactly what you want: there's no "close enough" in HMAC.

§ 01 The short answer

Verifying a webhook, in one paragraph

Quick answer

Take the request body as raw bytes, before any parsing. Build the string the provider signed (often a timestamp, a separator, then the body), compute HMAC-SHA256 over it with your signing secret, and encode the result as the provider does: hex for Stripe, GitHub and Slack, base64 for Shopify and Standard Webhooks.

Compare it with the signature header using a constant-time function (crypto.timingSafeEqual, hmac.compare_digest), and reject timestamps more than about five minutes old. Only then parse the JSON. If it keeps failing, capture the request and check it in the tester's Signature tab.

Spec sheet Key facts

Algorithm
HMAC-SHA256 for Stripe, GitHub, Slack, Shopify and Standard Webhooks (v1)
Signed over
The raw body, plus a timestamp for Stripe, Slack and Standard Webhooks
Encoding
Hex: Stripe, GitHub, Slack · Base64: Shopify, Standard Webhooks
Replay window
5 min: Stripe's libraries, Slack, Standard Webhooks' libraries
Compare with
crypto.timingSafeEqual (Node) · hmac.compare_digest (Python)
Rotation
Stripe: old secret kept up to 24 h, one v1 per secret · Standard Webhooks: space-separated list

§ 02 Try it

The HMAC playground

Pick a scheme. The sender signs the body below, and its header is frozen. Then change anything, the way a proxy, a framework or a wrong config would, and see what the receiver computes. It runs in your browser with the Web Crypto API; nothing you type is sent anywhere.

Change it the way tools do

1Signed content ·

Blue: what the scheme adds before the body. ↵ marks a line break.

2HMAC key

3Header the sender sent

4What the receiver computes from what it got

Check it yourself in a terminal

$

§ 03 How it works

Six ideas behind every webhook signature

The providers differ in header names and string formats. Under that, they all do the same thing.

01 · The math

HMAC-SHA256, in plain words

HMAC takes a secret key and a message and returns a fixed-size fingerprint: 32 bytes, so 64 hex characters or 44 in base64. The same key and the same message always give the same result. Change one byte of either and the result looks unrelated. Without the key, nobody can produce a matching fingerprint for a message of their choice.

So when your result matches the header, two things are true: the sender knew the secret, and the bytes weren't changed on the way.

02 · The input

Why it has to be the raw bytes

The sender signed the exact bytes it sent, spaces and key order included. If your framework parses the JSON and you turn it back into text, you get similar bytes, not the same ones, and the HMAC changes. See the example below. Verify first, parse after.

Text encoding counts too: GitHub's docs say to handle the payload as UTF-8, because payloads can contain Unicode characters.

03 · The clock

Timestamps stop replays

A captured request with a valid signature stays valid forever, unless time is part of what's signed. Stripe, Slack and Standard Webhooks sign a timestamp with the body, so an attacker can't change it. You reject anything older than a few minutes: Stripe's libraries, Slack and the Standard Webhooks libraries all use 5 minutes.

04 · The comparison

Compare in constant time

A plain == stops at the first character that differs, so a wrong guess that starts right takes a little longer to reject. Measured over many requests, that can leak the signature piece by piece. crypto.timingSafeEqual and hmac.compare_digest take the same time either way. GitHub, Stripe, Slack and the Standard Webhooks spec all say to use one.

05 · The key

Rotating a secret without downtime

When you roll a Stripe endpoint secret, you can keep the old one active for up to 24 hours. Meanwhile Stripe signs with both and sends two v1= values in one header, so check every one. Standard Webhooks puts a space-separated list in webhook-signature for the same reason. After a Shopify client secret rotation, it can take up to an hour before deliveries use the new one.

06 · The transport

Why HTTPS still matters

A signature proves who sent the body. It doesn't hide it. Over plain HTTP, anyone on the path can read the payload and replay the request: within the window if it's timestamped, at any time if it isn't (GitHub and Shopify sign the body only). Stripe requires HTTPS for live-mode endpoints, and GitHub recommends HTTPS with SSL verification left on.

Parse, then re-serialize: same data, different bytes

One body, three byte strings. Each line is what that code actually printed, so each would get its own signature.

Where the bytes came fromBytesWhat changed
The raw request body{"b":1, "2":true, "url":"https:\/\/x.io", "n":1.0, "name":"Zoë"}The original. This is what was signed.
Node: JSON.stringify(JSON.parse(raw)){"2":true,"b":1,"url":"https://x.io","n":1,"name":"Zoë"}Spaces gone, key "2" moved first, \/ unescaped, 1.0 became 1
Python: json.dumps(json.loads(raw)){"b": 1, "2": true, "url": "https://x.io", "n": 1.0, "name": "Zo\u00eb"}Spaces changed, \/ unescaped, ë escaped as \u00eb

§ 04 Side by side

Five signature schemes compared

What each provider puts in the header, what exactly it signs, and with which key. Checked against each provider's documentation in October 2026.

ProviderHeader · valueSigned contentEncodingKeyTimestamp · window
StripeStripe-Signature
t=…,v1=…
{t}.{body}HexThe endpoint's whsec_… secret, used as ist= in the same header
5 min (library default)
GitHubX-Hub-Signature-256
sha256=…
{body}HexThe webhook secret you choseNone
Use X-GitHub-Delivery to spot replays
SlackX-Slack-Signature
v0=…
v0:{timestamp}:{body}HexThe app's signing secretX-Slack-Request-Timestamp
5 min
ShopifyX-Shopify-Hmac-Sha256
the signature only
{body}Base64The app's client secretNot signed
Skip a repeated X-Shopify-Webhook-Id
Standard Webhookswebhook-signature
v1,… (space-separated list)
{id}.{timestamp}.{body}Base64The part after whsec_, base64-decodedwebhook-timestamp
5 min (reference libraries)

Stripe also sends a fake v0= signature on test events; ignore every scheme except v1. GitHub's X-Hub-Signature (no -256) is HMAC-SHA1 and only there for legacy integrations. Shopify sends an X-Shopify-Triggered-At header, but it isn't covered by the HMAC, so it can't prove an event is fresh.

§ 05 The code

Verify it in Node and Python

Each example is a complete handler. We ran every one against correctly signed requests, a one-byte change, a wrong secret, a stale timestamp and missing headers: only the first was accepted. Node uses Express, Python uses Flask. Pick a language once and every tab follows.

Stripe, with the official library

The library parses the header, checks every v1 signature in constant time and rejects timestamps older than 5 minutes. Use it if you can.

Node · Express

server.js
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// Raw body on this route: the signature covers the exact bytes.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Bad signature: ${err.message}`);
  }
  res.sendStatus(200);
  // …then handle event.type, and skip event.id values you've already seen
});

app.use(express.json());  // JSON parsing for every other route, after the webhook
app.listen(3000);

Python · Flask

app.py
import os
import stripe
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhook")
def webhook():
    payload = request.get_data()  # the raw bytes; request.json would be too late
    try:
        event = stripe.Webhook.construct_event(
            payload, request.headers.get("Stripe-Signature"),
            os.environ["STRIPE_WEBHOOK_SECRET"])
    except (ValueError, stripe.error.SignatureVerificationError):
        return "Bad signature", 400
    # …handle event["type"], and skip event["id"] values you've already seen
    return "", 200

Stripe, by hand

What the library does, following Stripe's manual steps: split the header on , and =, keep t and every v1, sign {t}.{body}, compare each in constant time, check the age.

Node · Express

server.js
import crypto from "node:crypto";
import express from "express";

const app = express();

// timingSafeEqual throws if the lengths differ, so check that first.
function safeEqual(a, b) {
  const x = Buffer.from(a), y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyStripe(rawBody, header = "", secret, tolerance = 300) {
  const items = header.split(",").map((part) => part.split("="));
  const t = items.find(([k]) => k === "t")?.[1];
  const sigs = items.filter(([k]) => k === "v1").map(([, v]) => v);  // two during a secret roll
  if (!t || sigs.length === 0) return false;
  const age = Math.abs(Date.now() / 1000 - Number(t));
  if (!(age <= tolerance)) return false;  // also rejects a non-numeric t
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.`).update(rawBody).digest("hex");
  return sigs.some((sig) => safeEqual(expected, sig));
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) ||
      !verifyStripe(req.body, req.get("Stripe-Signature"), process.env.STRIPE_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString("utf8"));  // parse only after the check
  res.sendStatus(200);
});

app.listen(3000);

Python · Flask

app.py
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request

app = Flask(__name__)

def verify_stripe(payload: bytes, header: str, secret: str, tolerance=300) -> bool:
    items = [part.split("=", 1) for part in (header or "").split(",") if "=" in part]
    t = next((v for k, v in items if k == "t"), "")
    sigs = [v for k, v in items if k == "v1"]  # two during a secret roll
    if not (t.isascii() and t.isdigit()) or not sigs or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), sig.encode()) for sig in sigs)

@app.post("/webhook")
def webhook():
    payload = request.get_data()
    if not verify_stripe(payload, request.headers.get("Stripe-Signature"),
                         os.environ["STRIPE_WEBHOOK_SECRET"]):
        return "Bad signature", 400
    event = json.loads(payload)  # parse only after the check
    return "", 200

GitHub · X-Hub-Signature-256

GitHub signs the body only, as sha256= plus hex. The header is missing entirely if the webhook has no secret. To test your code, GitHub's docs give the secret It's a Secret to Everybody and the body Hello, World!, which must give sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.

Node · Express

server.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;

// timingSafeEqual throws if the lengths differ, so check that first.
function safeEqual(a, b) {
  const x = Buffer.from(a), y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

// GitHub payloads can be up to 25 MB; express.raw stops at 100 kB by default.
app.post("/webhook", express.raw({ type: "*/*", limit: "25mb" }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
  const expected = "sha256=" +
    crypto.createHmac("sha256", secret).update(req.body).digest("hex");
  if (!safeEqual(expected, req.get("X-Hub-Signature-256") ?? "")) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));  // with content type application/json
  res.sendStatus(204);
});

app.listen(3000);

Python · Flask

app.py
import hashlib
import hmac
import os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()

@app.post("/webhook")
def github_webhook():
    body = request.get_data()  # raw bytes: read them before request.json or request.form
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)
    event = request.get_json()  # with content type application/json
    return "", 204

Slack · signing secret

Slack signs v0:{timestamp}:{body}. Slash commands arrive form-encoded, so in Flask read the bytes with request.get_data() before anything touches request.form: after the form is parsed, Flask can't give them back.

Node · Express

server.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.SLACK_SIGNING_SECRET;

// timingSafeEqual throws if the lengths differ, so check that first.
function safeEqual(a, b) {
  const x = Buffer.from(a), y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

app.post("/slack/events", express.raw({ type: "*/*" }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
  const ts = req.get("X-Slack-Request-Timestamp") ?? "";
  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (!(age <= 300)) return res.sendStatus(401);  // older than 5 minutes, or not a number
  const expected = "v0=" + crypto.createHmac("sha256", secret)
    .update(`v0:${ts}:`).update(req.body).digest("hex");
  if (!safeEqual(expected, req.get("X-Slack-Signature") ?? "")) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  // …then parse: JSON for the Events API, a form for slash commands
});

app.listen(3000);

Python · Flask

app.py
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SLACK_SIGNING_SECRET"].encode()

@app.post("/slack/events")
def slack_events():
    body = request.get_data()  # first: once request.form has run, the bytes are gone
    ts = request.headers.get("X-Slack-Request-Timestamp", "")
    if not (ts.isascii() and ts.isdigit()) or abs(time.time() - int(ts)) > 300:
        abort(401)  # older than 5 minutes, or not a number
    base = b"v0:" + ts.encode() + b":" + body
    expected = "v0=" + hmac.new(SECRET, base, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Slack-Signature", "")
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)
    return "", 200

Shopify · X-Shopify-Hmac-Sha256

The body only, keyed with the app's client secret, and base64 instead of hex.

Node · Express

server.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.SHOPIFY_CLIENT_SECRET;

// timingSafeEqual throws if the lengths differ, so check that first.
function safeEqual(a, b) {
  const x = Buffer.from(a), y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

app.post("/webhooks", express.raw({ type: "*/*" }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
  const expected = crypto.createHmac("sha256", secret).update(req.body).digest("base64");
  if (!safeEqual(expected, req.get("X-Shopify-Hmac-Sha256") ?? "")) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  // …then skip X-Shopify-Webhook-Id values you've already handled
});

app.listen(3000);

Python · Flask

app.py
import base64
import hashlib
import hmac
import os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SHOPIFY_CLIENT_SECRET"].encode()

@app.post("/webhooks")
def shopify_webhook():
    body = request.get_data()
    digest = hmac.new(SECRET, body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()  # base64, not hex
    received = request.headers.get("X-Shopify-Hmac-Sha256", "")
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)
    return "", 200

Standard Webhooks

The spec's reference libraries (standardwebhooks on npm and PyPI) take the whsec_… string, decode the key, check every v1 signature and reject timestamps more than 5 minutes off in either direction.

Node · Express

server.js
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
// Takes the "whsec_…" string: strips the prefix and base64-decodes the rest.
const wh = new Webhook(process.env.WEBHOOK_SECRET);

app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers);  // checks signature and 5-minute window
  } catch (err) {
    return res.sendStatus(401);
  }
  res.sendStatus(204);
});

app.listen(3000);

Python · Flask

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

app = Flask(__name__)
# Takes the "whsec_…" string: strips the prefix and base64-decodes the rest.
wh = Webhook(os.environ["WEBHOOK_SECRET"])

@app.post("/webhook")
def webhook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        abort(401)
    return "", 204

§ 06 When it fails

Why signature verification fails

Nearly every failure is one of these. The first two account for most of them.

01

The secret is the wrong one

No signatures found matching the expected signature for payload · every request fails, even fresh ones

Stripe's docs call this the most common cause. Each Stripe endpoint has its own whsec_ secret, and an endpoint used in both test and live mode has a different secret for each. Events forwarded by stripe listen are signed with the secret the CLI prints, not the Dashboard's. For Slack, use the Signing Secret, not the Client Secret. For Shopify, the app's client secret; after you rotate it, Shopify can take up to an hour to switch.

Then check for invisible extras: a trailing newline or space copied into an .env file is part of the key. Stripe's Node library adds a note to its error when the secret contains whitespace.

02

The body was parsed before you checked it

Webhook payload must be provided as a string or a Buffer … Payload was provided as a parsed JavaScript object instead

A JSON middleware ran first, so your code got an object, or a re-serialized string, instead of the bytes that were signed. In Express, put express.raw() on the webhook route and mount app.use(express.json()) after it: Express runs middleware in order. In Flask, call request.get_data() before request.json or request.form. Stripe's troubleshooting guide lists the fixes for Next.js and AWS API Gateway too.

03

Hex where the provider uses base64, or the reverse

Your value is 64 characters, the header's is 44 (or the other way round)

The bytes are the same; the text isn't. SHA-256 gives 32 bytes: 64 characters in hex, 44 in base64 (ending in =). Stripe, GitHub and Slack use hex; Shopify and Standard Webhooks use base64. Also keep or drop the prefix consistently: GitHub's header starts with sha256=, Slack's with v0=, Standard Webhooks' with v1,.

04

A tool added or removed whitespace

A replayed or hand-made request fails, while the real delivery passed

Editors often save a fixture file with a final newline, and echo adds one too. curl -d @file.json strips carriage returns and newlines from the file. When you replay a captured body, send it byte for byte: printf '%s' or --data-binary @file.json. Then remember the timestamp: a replay of an old capture is rejected as too old, by design.

05

Your server's clock is off

Timestamp outside the tolerance zone · valid signatures rejected

The signature matches, but the age check fails because your clock says it's later (or earlier) than it is. Stripe recommends NTP to keep your server's clock in sync. Don't "fix" it by setting the tolerance to 0: in Stripe's libraries, that turns the check off entirely.

06

A proxy or relay changed the body

It passes in the webhook tester, fails in your app

Something between the provider and your code rewrote the bytes. The smee.io client parses the JSON and writes it out again, so whitespace and escapes can change (smee-client#325). An AWS API Gateway mapping template needs extra setup to pass the raw body through. GitHub's troubleshooting list says the same about proxies and load balancers: they must not modify the payload or headers. Stripe's library error says it too: a forwarding tool has to keep "the exact request body, including JSON formatting and new line style".

07

Comparing with ==, or crashing on length

It works, but leaks timing · or a garbage header gives a 500 instead of a 401

A plain == or === leaks timing; use a constant-time comparison. But note: Node's crypto.timingSafeEqual throws if the two buffers have different lengths, so check the length first, as the safeEqual helper above does. Python's hmac.compare_digest accepts str only if it's ASCII and raises TypeError otherwise; comparing encoded bytes, as above, avoids that on odd input.

08

Only the first signature was checked

Verification starts failing right after you roll a Stripe secret

During a roll, Stripe sends one v1= per active secret. Code that reads only the first one fails whenever the match is in another position. Check every v1 value and accept if any matches. The same goes for each entry in a Standard Webhooks webhook-signature list.

09

The Standard Webhooks secret wasn't decoded

No matching signature found · with a secret you just copied

A Standard Webhooks secret looks like Stripe's, whsec_ plus text, but it's used differently. Stripe uses the whole string as the key. Standard Webhooks strips whsec_ and base64-decodes the rest, and that decoded data is the key. Feed the string straight into createHmac and every signature fails. Switch the playground to Standard Webhooks to see the decoded key length.

Check a real requestPoint the provider at the webhook tester, trigger an event, open the request, and paste your secret into the Signature tab. It shows the signed content, the value in the header and the value it computed, in your browser. If it passes there and fails in your app, your app (or something in front of it) changed the bytes. If it fails there too, the secret is wrong.

§ 07 Ship it

Verification works on your laptop?
Put it where the provider can reach it.

One sentence · no dashboard
A Your handler checks signatures?

Ask your coding agent

Open a terminal in your project folder and paste one of these. The agent packages your code, deploys it, and hands you back a permanent HTTPS URL to register once.

$claude "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"
$codex "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"

Keep the signing secret out of your code: the agent can store it as an encrypted secret on the server.

B Starting from zero?

Have one built and deployed

Chat assistants deploy for you when they can run code with internet access; otherwise they'll give you the exact commands.

§ 08 FAQ

Frequently asked questions

How do I verify a webhook signature?

Read the raw request body as bytes and build the provider's signed content from it (for Stripe: the timestamp, a dot, then the body). Compute HMAC-SHA256 over it with your signing secret, encode the result the way the provider does (hex or base64), and compare it with the header using a constant-time function. If the scheme has a timestamp, also reject requests more than about five minutes old.

Why does Stripe say "No signatures found matching the expected signature for payload"?

One of the three inputs is wrong. Usually the body was parsed or changed before you passed it in, so pass the raw Buffer or bytes. Or the whsec_ secret is the wrong one: stripe listen, each Dashboard endpoint, and test versus live mode each have their own.

What is X-Hub-Signature-256?

The header GitHub adds to a webhook delivery when the webhook has a secret. Its value is sha256= followed by the HMAC-SHA256 hex digest of the request body, keyed with that secret. The older X-Hub-Signature header uses SHA-1 and is only included for legacy purposes.

How do I verify a Shopify webhook HMAC?

Compute HMAC-SHA256 of the raw request body with your app's client secret, base64-encode it, and compare it with the X-Shopify-Hmac-Sha256 header in constant time. After you rotate the client secret, it can take up to an hour before Shopify signs with the new one.

How do I verify Slack requests with the signing secret?

Join v0, the X-Slack-Request-Timestamp value and the raw body with colons. Compute HMAC-SHA256 over that with your app's signing secret (in the app admin panel under Basic Info), prefix the hex digest with v0=, and compare it with X-Slack-Signature using an HMAC compare function. Reject timestamps more than five minutes from your server's time.

If webhooks are signed, do I still need HTTPS?

Yes. A signature proves who sent the body and that it wasn't changed, but it doesn't hide it. Without TLS, anyone on the network path can read the payload and replay the captured request: within the time window for schemes with a timestamp, and at any time for schemes without one. Stripe requires HTTPS for live-mode endpoints.

Why compare signatures in constant time?

A normal string comparison stops at the first character that differs, so the time it takes leaks how much of a guess was right. Over many requests, an attacker could in principle build a valid signature one character at a time. Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python.