import crypto from 'node:crypto';
// 1. Build the body ONCE (a string, not an object)
const payload = JSON.stringify(requestData);
// 2. Sign that exact string -> lowercase hex
const signature = crypto
.createHmac('sha256', signKey)
.update(payload, 'utf8')
.digest('hex');
// 3. Send the SAME string you signed
const response = await fetch('https://api.napoleonpay.com/api/v1/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
Signature: signature,
},
body: payload,
});JSON.stringify already drops the .0 from whole numbers, but it emits raw UTF-8 where PHP escapes it, and keeps {} for empty objects — both are corrected below.
import crypto from 'node:crypto';
const canonicalise = (v) =>
Array.isArray(v)
? v.map(canonicalise)
: v && typeof v === 'object'
? (Object.keys(v).length
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, canonicalise(x)]))
: []) // empty object -> []
: v;
const escapeNonAscii = (s) =>
s.replace(/[-]/g, (c) =>
'\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
// rawBody must be the untouched request body string, not a re-serialized object
export function verifyCallback(rawBody, signatureHeader, signKey) {
const canonical = escapeNonAscii(
JSON.stringify(canonicalise(JSON.parse(rawBody)), null, 4)
);
const expected = crypto
.createHmac('sha256', signKey)
.update(canonical, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}In Express, register the route with
express.raw({ type: 'application/json' })so you receive the original bytes —express.json()parses and discards them.