Square's webhook signature catches people in a specific way: the signature is computed over the notification URL joined to the raw request body, not the body on its own. Miss the URL, or reconstruct a URL that differs by even a trailing slash, and every notification fails validation while your HMAC code looks perfectly correct. This post shows the exact scheme Square uses, the two traps that break it, and the older header you should stop trusting.
What Square actually signs
Every webhook from Square carries an x-square-hmacsha256-signature header. Per Square's own validation docs, the value is an HMAC-SHA-256 built from three things:
- The signature key for your webhook subscription (found next to the subscription in the Square Dashboard, one per subscription).
- The notification URL you configured for that subscription.
- The raw body of the request, exactly as sent.
The message you run through HMAC is the notification URL followed by the raw body, concatenated in that order, and the result is Base64-encoded. To validate, you compute the same thing and compare it against the header.
const crypto = require('crypto');
// From your webhook subscription in the Square Dashboard
const SIGNATURE_KEY = process.env.SQUARE_SIGNATURE_KEY;
// The EXACT notification URL you configured for the subscription
const NOTIFICATION_URL = 'https://api.yourapp.com/webhooks/square';
function isValidSquareSignature(rawBody, signatureHeader) {
const payload = NOTIFICATION_URL + rawBody; // URL first, then the raw body
const expected = crypto
.createHmac('sha256', SIGNATURE_KEY)
.update(payload, 'utf8')
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the RAW body, verify, then parse
app.post('/webhooks/square',
express.raw({ type: '*/*' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.get('x-square-hmacsha256-signature');
if (!isValidSquareSignature(rawBody, signature)) {
return res.status(403).send('bad signature');
}
const event = JSON.parse(rawBody); // only parse after the check passes
// ... handle event
res.sendStatus(200);
});
Notice the comparison uses crypto.timingSafeEqual rather than ===. A plain string compare returns early on the first differing byte, and that timing difference leaks how much of the signature you got right. Use a constant-time compare, and guard the length first because timingSafeEqual throws on a length mismatch.
Trap one: the raw body
HMAC signs bytes, not objects. If your framework parses the JSON and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Square sent. Key order, whitespace, and number formatting all shift, and the signature never matches. Capture the raw, unparsed body, verify against it, and parse only after the check passes. In Express that means express.raw() on the webhook route, not express.json(). This is the same trap Stripe, Paddle, HubSpot, and Slack share, laid out side by side in the webhook signature verification comparison.
Trap two: the notification URL has to match exactly
Because the URL is part of the signed message, the string you concatenate has to be the exact notification URL Square has on file for the subscription, character for character. This is where most Square-specific failures come from:
-
Reconstructing the URL from the request. If you build the URL from
req.protocolandreq.headers.host, a load balancer that terminates TLS can hand your apphttpinstead ofhttps, or a different host, and your rebuilt URL stops matching. Do not rebuild it. Use the exact URL you registered, as a constant. -
Trailing slash and path differences.
https://api.yourapp.com/webhooks/squareand the same URL with a trailing slash produce different signatures. Match what is stored in the subscription exactly. - Sandbox versus production. The two environments have separate subscriptions, separate signature keys, and usually separate notification URLs. Verifying a sandbox event against your production key and URL fails every time.
The clean fix is to treat the notification URL as a known constant per environment rather than something you derive at request time. You configured it, so you already know it. This is the same class of problem Twilio has, where the signature is tied to the request URL and a proxy can break it, covered in the Twilio signature validation guide.
Stop using the legacy x-square-signature header
Older Square integrations validated an x-square-signature header that used HMAC-SHA1. That scheme is legacy and being retired in favor of the SHA-256 header above. If your code still reads x-square-signature, move to x-square-hmacsha256-signature. SHA1 is not a hash you want at the door of your payment events, and Square's current guidance is to use the SHA-256 signature.
The mistakes that account for most failures
- Signing the body only and forgetting to prepend the notification URL.
- Verifying against a re-serialized body instead of the raw bytes.
- Rebuilding the URL from request headers behind a proxy instead of using the configured constant.
- Using the wrong subscription's signature key, or crossing sandbox and production keys.
- Comparing with
===instead of a constant-time function.
Signatures are only half of it
Validating the signature proves a notification really came from Square. It does nothing for the notification that never arrives, the one that fired while your server was mid-deploy, or the one dropped because your handler threw before it acknowledged. Square retries failed deliveries for a while and then disables the subscription if it keeps failing, so a bad hour on your side can turn into a subscription Square has stopped sending to. The events behind that are the ones tied to money, a payment completed, a refund, a dispute, exactly the ones you cannot afford to lose.
That is the layer EventDock adds. You point Square at EventDock instead of your app. EventDock verifies the signature, stores the event, and acknowledges Square right away so no timeout or downtime window costs you a delivery, 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 and arrive when it recovers instead of being dropped and eventually disabled. For the duplicate deliveries any retrying pipeline produces, the exactly-once processing pattern covers how to stay idempotent.
You can point a Square sandbox subscription at EventDock on the free tier and watch every event get verified, stored, and delivered. Start with EventDock free.
Top comments (0)