DEV Community

EventDock
EventDock

Posted on • Originally published at eventdock.app

How to Verify HubSpot Webhook Signatures (v1, v2, and the v3 You Should Actually Use)

HubSpot has shipped three different webhook signature schemes over the years, and they all use similar header names. Most of the broken HubSpot webhook validation I have seen comes from implementing v1 when HubSpot is sending v3, or hashing the wrong string. This post shows the three versions, the exact v3 verification HubSpot recommends today, and the raw-body and URL mistakes that make every request look invalid.

If you just want the short version: use v3, hash the method plus the full URI plus the raw body plus the timestamp, and reject anything older than five minutes.

Three signature versions, and which one you are getting

HubSpot did not replace its old signatures when it added new ones. An app can receive any of them depending on how the webhook was configured and how old the app is. The three schemes are not interchangeable, so the first job is knowing which one HubSpot is sending you.

  • v1 (X-HubSpot-Signature, no version header): a SHA-256 hash of your app client secret concatenated with the raw request body. No timestamp, so no replay protection. This is the oldest and weakest scheme.
  • v2 (X-HubSpot-Signature with X-HubSpot-Signature-Version: v2): a SHA-256 hash of the client secret plus the HTTP method plus the full URI plus the raw body. Still no timestamp.
  • v3 (X-HubSpot-Signature-v3 plus X-HubSpot-Request-Timestamp): an HMAC-SHA256, keyed with the client secret, over the HTTP method plus the URI plus the raw body plus the timestamp, then Base64 encoded. The timestamp is signed, which is what gives you replay protection. This is the one HubSpot recommends now.

Look at the headers on an actual delivery. If you see X-HubSpot-Signature-v3, you are on v3 and everything below applies. If you only see X-HubSpot-Signature, check the version header to tell v1 from v2.

What v3 actually signs

The v3 signature is computed over one concatenated string, in this exact order:

HTTP_METHOD + FULL_URI + RAW_REQUEST_BODY + TIMESTAMP
Enter fullscreen mode Exit fullscreen mode

Four things people get wrong here, in order of how often I see them:

  1. The raw body. HMAC signs bytes. If your framework parsed the JSON and you re-serialized it with JSON.stringify, the bytes changed and the signature will never match. Verify against the untouched body.
  2. The full URI, normalized. This means the complete URL HubSpot called, including the https:// scheme, the host, the path, and any query string. Two extra rules for v3 that bite people: HubSpot URL-decodes a specific set of characters in the URI before signing, and it drops any fragment. So the string you sign is the normalized URI, not the raw one off the wire. Match HubSpot's normalization or the hash will differ.
  3. The timestamp. It comes from the X-HubSpot-Request-Timestamp header and it is part of the signed string. Leave it out and every signature is wrong.
  4. Base64, not hex. v3 is Base64 encoded. v1 and v2 are hex. If you compare a hex digest to the Base64 header, it fails even when everything else is right.

Verifying a v3 signature in Node.js

This reads the two headers, rebuilds the signed string, computes the HMAC, compares in constant time, and rejects stale timestamps to block replays.

import crypto from 'crypto';

function verifyHubSpotV3(
  method,           // 'POST'
  fullUri,          // 'https://your-app.com/webhooks/hubspot?x=1'  (exactly what HubSpot called)
  rawBody,          // the untouched request body as a string, NOT re-stringified JSON
  signatureHeader,  // X-HubSpot-Signature-v3
  timestamp,        // X-HubSpot-Request-Timestamp
  clientSecret      // your app's client secret
) {
  // Reject replays: the timestamp is signed, so it cannot be forged past this window.
  const FIVE_MIN = 5 * 60 * 1000;
  if (Date.now() - Number(timestamp) > FIVE_MIN) return false;

  const signedString = method + fullUri + rawBody + timestamp;
  const expected = crypto
    .createHmac('sha256', clientSecret)
    .update(signedString, 'utf8')
    .digest('base64');

  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

Notice there is no JSON.parse in that function. Parse the body only after the signature passes.

Getting the raw body (the part frameworks fight you on)

The verification is easy. Handing it the exact bytes HubSpot sent is where most setups break, because web frameworks parse the body for you by default.

// Express: use the raw parser on the webhook route so req.body stays a Buffer
app.post('/webhooks/hubspot',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.get('X-HubSpot-Signature-v3');
    const ts = req.get('X-HubSpot-Request-Timestamp');
    const fullUri = 'https://your-app.com' + req.originalUrl; // include the query string
    if (!verifyHubSpotV3('POST', fullUri, req.body.toString('utf8'), sig, ts, process.env.HUBSPOT_CLIENT_SECRET)) {
      return res.status(401).send('bad signature');
    }
    res.status(200).send('ok');          // ack fast
    handleHubSpotEvent(JSON.parse(req.body.toString('utf8'))).catch(console.error);
  }
);
Enter fullscreen mode Exit fullscreen mode

In Next.js route handlers, read the body once with await req.text() and do not call req.json() first, because you cannot read the stream twice. In Cloudflare Workers, await request.text() gives you the raw bytes and the Web Crypto API does the HMAC.

Signatures prove authenticity. They do nothing for lost events.

HubSpot batches webhook events and retries failed deliveries for up to a few days, then gives up. That window feels safe until a deploy, a database timeout, or a cold start eats a burst of contact.propertyChange or deal.creation events during the minutes your handler is down. A missed deal.creation is a lead your CRM automation never fired on. A missed contact.deletion is a record you keep syncing after the customer asked to be forgotten.

This is where EventDock fits. You point HubSpot at EventDock instead of directly at your app. EventDock verifies the signature, stores the event, acknowledges HubSpot right away so the delivery is safely captured, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.

You can wire it up on the free tier and watch every HubSpot event get captured and delivered. Start with EventDock free, or read how the exactly-once processing pattern handles the duplicate deliveries that HubSpot, like every retrying provider, will eventually send you.

Top comments (0)