Handling Shopee and TikTok Shop webhook pushes safely
Shopee signs every webhook with HMAC-SHA256 over the string callback_url + request_body. You recompute exactly that formula with exactly the right partnerKey, and the signature still does not match. The key is not wrong and the algorithm is not wrong — the body you handed to the HMAC function is no longer the body Shopee signed, even though the JSON still “looks” identical.
1. Reproducing it in five lines
HMAC is a hash over an exact byte string. Parsing JSON and serializing it back is the kind of transformation that feels like it changes nothing — and it does not preserve the original bytes:
const raw = '{"code":1,"amount":10.50,"shop_id":123}'; // the body Shopee actually signed
const reparsed = JSON.stringify(JSON.parse(raw)); // what you get after express.json()
console.log(raw === reparsed); // false
console.log(raw); // {"code":1,"amount":10.50,"shop_id":123}
console.log(reparsed); // {"code":1,"amount":10.5,"shop_id":123} <- 10.50 → 10.510.50 and 10.5are the same number and two different byte strings. HMAC knows nothing about “JSON values” — it hashes bytes. Two different strings mean two different signatures. Run the snippet above through any crypto.createHmac(“sha256”, key) you like and the results never line up.
2. Why this slips through review
Nobody deliberately writes “parse it, serialize it back, then verify”. It happens implicitly: an express.json() middleware mounted at the app level — usually put there for the other routes — runs beforethe webhook route in Express's middleware chain. By the time the verification code touches req.body, the body has already been parsed into an object, and the original bytes are gone for good, no matter how carefully you JSON.stringify it back.
There is no exception and no warning in the logs — signature verification simply returnsfalse. It looks exactly like a misconfigured key, which is why most of the debugging time goes into re-checkingpartnerKey, the one thing that was never wrong.
3. The fix: verify before anything is allowed to parse
The webhook route has to receive the raw Buffer, verify the signature against that buffer, and only then JSON.parse it to do the actual work — and express.raw() has to sit ahead of every global express.json() on this path:
app.post(
"/shopee/webhook",
express.raw({ type: "application/json" }), // keep the bytes, do NOT parse
(req, res) => {
const signature = req.header("authorization") ?? "";
const isValid = shopee.verifyPushSignature(
callbackUrl,
req.body, // raw Buffer - exactly the bytes Shopee signed
signature,
);
if (!isValid) return res.status(401).end();
const payload = shopee.parsePushPayload(req.body); // parse AFTER the signature checks out
return res.status(204).end();
},
);The rule in one line: verify against bytes, and parse only once the signature is known to be good. Swap those two steps — even by accident, because a global middleware happens to sit in front — and the signature always fails, with no log telling you why.
4. Not just Express
This is not an Express problem. Any framework that parses the JSON body beforeyour route handler gets a chance to read the raw bytes has the same exposure — the root of it is “who touches the original bytes first”, not the syntax of one particular framework. NestJS (which runs on Express by default) hits it in exactly the same way if you do not handle it, because it also registers a global body parser at bootstrap.
The difference is that Nest ships an official answer, cleaner than carving the route out by hand as above:
const app = await NestFactory.create(AppModule, { rawBody: true });
// In the controller:
@Post("shopee/webhook")
handleWebhook(@Req() req: RawBodyRequest<Request>) {
const isValid = shopee.verifyPushSignature(
callbackUrl,
req.rawBody, // the original buffer - kept alongside the already-parsed req.body
signature,
);
if (!isValid) throw new UnauthorizedException();
const payload = req.body; // already parsed, safe to use AFTER verifying
}With rawBody: true, Nest keeps both req.body (parsed, for your logic) and req.rawBody (the original buffer, for verifying the signature) — so there is no need to pull the webhook route out of the shared parsing path the way express.raw() does above. Frameworks that do not parse JSON by default (Next.js Route Handlers, for instance, where you read request.text() yourself) avoid this one for free, without ever having to learn about it.