HubSpot's v3 webhook check fits in one line: join the method, the full URL, the raw body and the X-HubSpot-Request-Timestamp value, HMAC that string with SHA-256 and your app's client secret, Base64 the digest, and compare it to X-HubSpot-Signature-v3 in constant time. Anything with a timestamp more than 5 minutes old gets rejected.
What makes it annoying is that every mistake looks identical. Hash a slightly different URL, or a body that went through JSON.parse and back, and you get a different digest and a bare 401. I put together a longer walkthrough on DevToolLab where a Node.js verifier was tested against signatures from a separate Python signer. Here is the short version.
Checking One by Hand First
Before writing code, concatenate POST, the URL, the body and the timestamp yourself and drop the result into the HMAC Generator, set to SHA-256 with Base64 output and your client secret as the key. If the output matches the X-HubSpot-Signature-v3 header, your understanding of the recipe is right. The Webhook Signature Verifier ships presets for Stripe, GitHub and Shopify but not HubSpot, so with that tool you use the generic HMAC mode and paste the concatenated string as the payload. Neither tool sends your secret anywhere; both compute in the browser.
Three Signature Versions, One Worth Using
HubSpot's request validation docs, checked October 9, 2026, describe three schemes. For the two older ones, X-HubSpot-Signature-Version says which you received. The developer platform version of that page repeats the same v3 steps.
| Version | Header | Input | Used for |
|---|---|---|---|
| v1 | X-HubSpot-Signature |
SHA-256 of client secret + body | CRM object event subscriptions from the webhooks API |
| v2 | X-HubSpot-Signature |
SHA-256 of client secret + method + URI + body | workflow webhook actions, custom CRM cards |
| v3 | X-HubSpot-Signature-v3 |
Base64 HMAC SHA-256 of method + URI + body + timestamp | current version, sent with X-HubSpot-Request-Timestamp
|
When the v3 header is on the request, verify that one. It is the only scheme that is an actual HMAC and the only one that carries a timestamp, so it is all this post covers.
A v3 Verifier in Node.js
One step in HubSpot's instructions is easy to skip. The URI has to have 12 specific percent-encoded sequences decoded before hashing: %3A, %2F, %3F, %40, %21, %24, %27, %28, %29, %2A, %2C and %3B. The literal ? that begins the query string is left alone. This verifier handles the decoding, the 5-minute window and a length check before the constant-time compare:
import crypto from "node:crypto"
const DECODE = {
"%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
"%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
}
export function decodeUri(uri) {
return uri.replace(/%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/g, (m) => DECODE[m])
}
export function verifyV3({ secret, method, uri, rawBody, timestamp, signature, now = Date.now() }) {
if (!signature || !timestamp) return false
if (Math.abs(now - Number(timestamp)) > 5 * 60 * 1000) return false
const source = method + decodeUri(uri) + rawBody + timestamp
const expected = crypto.createHmac("sha256", secret).update(source, "utf8").digest("base64")
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Wiring it into Express 5 comes down to one choice: take the body as raw bytes with express.raw, since those bytes are what HubSpot signed.
import express from "express"
import { verifyV3 } from "./verify.mjs"
const app = express()
app.post("/webhooks/hubspot", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyV3({
secret: process.env.HUBSPOT_CLIENT_SECRET,
method: req.method,
uri: `https://${req.hostname}${req.originalUrl}`,
rawBody: req.body.toString("utf8"),
timestamp: req.get("X-HubSpot-Request-Timestamp"),
signature: req.get("X-HubSpot-Signature-v3"),
})
if (!ok) return res.sendStatus(401)
const events = JSON.parse(req.body)
res.sendStatus(200)
})
app.listen(4040)
Testing Against a Signer Written in Another Language
If the same code signs and verifies, a shared bug passes every test. So the signatures here came from Python's hmac module rather than Node's crypto:
import base64, hashlib, hmac, sys
secret, method, uri, ts = sys.argv[1:5]
body = sys.stdin.read()
for enc, dec in {"%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
"%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";"}.items():
uri = uri.replace(enc, dec)
src = (method + uri + body + ts).encode("utf-8")
print(base64.b64encode(hmac.new(secret.encode(), src, hashlib.sha256).digest()).decode())
The test used test-client-secret as the secret and a single contact.creation event shaped like the sample in HubSpot's docs. The Node side reported:
1 valid (python-signed, node-verified): true
2 tampered body: false
3 timestamp 6 min old: false
3b timestamp 4 min old: true
4 wrong secret: false
5 decoded uri: https://api.example.com/webhooks/hubspot?email=ann@example.com&tag=a,b
Sending the same requests through curl to the running Express server gave 200 for the valid one and 401 for an edited body, a dropped header, and a request carrying the wrong Host.
Debugging a 401, in Order
Verification stops at the first check that fails, so walk the checks in the same order: headers present, timestamp fresh, source string built, HMAC equal.
-
The body was re-serialized. HubSpot's own Node.js sample runs
bodyParser.json()and then hashesJSON.stringify(body), which only works when the original bytes happen to matchJSON.stringifyoutput. A body with a space after each comma verified from its raw bytes and failed after a parse and re-stringify. -
The URI is not the one HubSpot called. Use the public
httpshostname, the same way the docs example does. Receiving the request aslocalhostinstead ofapi.example.combroke verification in testing, and a reverse proxy that rewritesHostdoes the same thing in production. -
The 12 sequences were left encoded.
ann%40example.commust turn intoann@example.combefore hashing. Other percent-encodings stay as they are. -
timingSafeEqualthrew instead of returning false. The docs sample passes the buffers straight tocrypto.timingSafeEqual, and Node throwsRangeError: Input buffers must have the same byte lengthif the header length differs from the digest. A truncated or junk header then takes down the handler rather than producing a401. Check lengths first. - The clock is off. Requests older than 5 minutes are supposed to be refused, so a server that has drifted past that refuses all of them. Look at NTP before you look at the HMAC.
The original post goes through the same list with the test result behind each item, plus browser tools for capturing a live request and decoding its URL.
What a Valid Signature Does Not Prove
Passing v3 means HubSpot sent the request and nobody changed it in transit. It does not mean you have not seen it before: a captured request still verifies for up to 5 minutes. Store the eventId values you have already handled and skip repeats. The client secret also has to stay on the server, since it is the only thing separating your endpoint from anyone who finds the URL.
Wrap-Up
Prefer the v3 header. Hash method + decoded URI + raw body + timestamp with HMAC SHA-256, Base64 the result, check lengths, then compare in constant time. Take the body as bytes and the hostname from your public URL. When a request still fails, paste the exact string you hashed into an HMAC tool and compare its output to the header; whatever differs in that string is your bug.


Top comments (0)