Posted by

How to Verify HubSpot Webhook Requests with Signature v3 (Node.js Example)

If your application receives requests from HubSpot, such as webhook notifications or app callbacks, your endpoint is a public URL. Anyone can send it a POST request. Signature validation is how you confirm that a request really came from HubSpot and was not altered in transit. This article walks through HubSpot's v3 request signature and shows a Node.js implementation.

Why validate at all?

Without validation, an attacker who discovers your endpoint can forge events, for example fake "contact created" notifications, and trigger whatever your code does next. HubSpot signs each request with your app's secret, so only a party that knows the secret can produce a valid signature.

The two headers you need

According to HubSpot's documentation, v3 validation uses two headers:

  • X-HubSpot-Signature-v3: the base64-encoded HMAC SHA-256 signature.
  • X-HubSpot-Request-Timestamp: the request timestamp in milliseconds.

The validation steps

  1. Check the timestamp. Reject the request if the timestamp is older than 5 minutes (300,000 ms). This limits replay attacks.
  2. Decode the URI. Decode certain URL-encoded characters in the request URI (for example %3A, %2F, %3F, %40), but not the ? that begins the query string. HubSpot's page lists the full set of characters.
  3. Build the string. Concatenate, as UTF-8: request method + request URI + request body + timestamp.
  4. Compute the HMAC. Create an HMAC SHA-256 of that string, using your app's secret as the key.
  5. Base64-encode the result.
  6. Compare it to the signature header. If they match, the request is verified. Use a constant-time comparison to avoid timing attacks.

A Node.js implementation

The example below uses Express. The important detail is that you need the raw request body, exactly as HubSpot sent it, because re-serializing parsed JSON can change whitespace or key order and break the signature.

const crypto = require('crypto');
const express = require('express');

const app = express();
const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET;
const MAX_AGE_MS = 300000; // 5 minutes

// Keep the raw body for signature checks
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));

// Decode only the characters HubSpot says to decode
function decodeUri(uri) {
  return uri.replace(
    /%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi,
    (m) => decodeURIComponent(m)
  );
}

function isValidHubSpotRequest(req) {
  const signature = req.get('X-HubSpot-Signature-v3');
  const timestamp = req.get('X-HubSpot-Request-Timestamp');
  if (!signature || !timestamp) return false;

  if (Math.abs(Date.now() - Number(timestamp)) > MAX_AGE_MS) return false;

  // Use the public URL HubSpot called, not an internal one behind a proxy
  const uri = decodeUri('https://' + req.get('host') + req.originalUrl);
  const source = req.method + uri + (req.rawBody || '') + timestamp;

  const expected = crypto
    .createHmac('sha256', CLIENT_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);
}

app.post('/hubspot/webhook', (req, res) => {
  if (!isValidHubSpotRequest(req)) return res.sendStatus(401);
  // ...process events...
  res.sendStatus(200);
});

app.listen(3000);

Common pitfalls

  • Using the parsed body. Sign against the raw body string, not JSON.stringify(req.body).
  • Wrong URL behind a proxy. The URI in the signed string must match the URL HubSpot actually requested. If a load balancer rewrites the host or scheme, rebuild the public URL deliberately.
  • Plain string comparison. Use crypto.timingSafeEqual, and check lengths first because it throws on buffers of different sizes.
  • Skipping the timestamp check. A valid signature alone does not stop an attacker from replaying a captured request.
  • Legacy versions. HubSpot's documentation notes that v1 hashes the client secret plus the body, and v2 also includes the HTTP method and URI. Prefer v3 where available.

A note on webhook versions

HubSpot's newer webhooks journal (v4) works differently: instead of pushing to a targetUrl, events are written to a journal that your application polls using OAuth, and events can be retrieved for the past 3 days. The signature scheme above applies to requests HubSpot pushes to your URL; it is not part of the v4 polling model. Check which model your integration uses before implementing validation.

Wrap-up

Validating signatures takes about thirty lines of code and closes off a whole class of spoofing and replay problems. Verify the timestamp, sign the exact method, URI, raw body and timestamp, and compare in constant time. Always test against a real HubSpot request in a developer account, since URL encoding details are the most common source of mismatches.

Sources: HubSpot Developer Docs, "Validating requests" and "Webhooks journal (v4)".