Hey @Milest, we ran into the exact same issue and spent a while debugging it. Here’s what we found — hopefully this saves someone else the trouble.
Root cause
The hs project dev proxy signs requests with the local URL (after local.json mapping), not the original public URL.
For example, if your local.json maps https://your-backend.com to http://localhost:3000, the CLI:
- Receives a `hubspot.fetch()` call targeting `https://your-backend.com/api/endpoint?portalId=123\`
- Maps the URL to `http://localhost:3000/api/endpoint?portalId=123\`
- Signs with the mapped URL (http://localhost:3000/…)
- Sends the request to http://localhost:3000/…
We confirmed this by reading the CLI source code at @hubspot/app-functions-dev-server/dist/signing.js and AppProxyService.js.
The fix
Reconstruct the URL from what Express actually receives — not from a hardcoded public URL:
// JSconst uri = ${req.protocol}://${req.get(‘host’)}${req.originalUrl};
This works in both local dev and production because in both cases it’s the URL the request was actually sent to.
Other gotchas we found
1. `CLIENT_SECRET` is required — without it, requests fail silently (500 from the proxy, no backend logs at all). The CLI gives no warning. Launch with:
// shellCLIENT_SECRET="your-app-secret" hs project dev
2. GET body mismatch — the CLI sends “” (2 bytes, content-length: 2) as the HTTP body for GET requests, but signs with an empty string ‘’. Use ‘’ for GET/DELETE in your signature calculation.
Working middleware (Node.js/Express)
// JSconst crypto = require('crypto');function verifyHubspotSignature(req, res, next) { const signature = req.header('X-HubSpot-Signature-v3'); const timestamp = req.header('X-HubSpot-Request-Timestamp'); if (!signature || !timestamp) { return res.status(401).json({ error: 'Missing signature headers' }); } if (Date.now() - Number(timestamp) > 300000) { return res.status(401).json({ error: 'Timestamp too old' }); } const body = ['GET', 'DELETE'].includes(req.method) ? '' : JSON.stringify(req.body ?? ''); const uri = ${req.protocol}://${req.get(‘host’)}${req.originalUrl}; const source = req.method + uri + body + timestamp; const expected = crypto .createHmac('sha256', process.env.CLIENT_SECRET) .update(source) .digest('base64'); const sigBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) { return res.status(401).json({ error: 'Invalid signature' }); } next();}
Note: this follows the same approach as the official Node.js example in the HubSpot docs. Signature.isValid() from @hubspot/api-client does the exact same HMAC calculation internally (hubspot-api-nodejs/src/utils/signature.ts at master · HubSpot/hubspot-api-nodejs · GitHub) but doesn’t solve the URL reconstruction issue — you still need to pass it the correct URL yourself.
Hope this helps!