PHP
function canonicalV4(string $version, array $fields): string {
ksort($fields);
$parts = [];
foreach ($fields as $k => $v) {
$s = (string) $v;
$parts[] = $k . ':' . strlen($s) . ':' . $s;
}
return $version . '|' . implode('|', $parts);
}
$fields = json_decode($request->getContent(), true);
$expected = 'v4=' . hash_hmac('sha256', canonicalV4('v4.webhook', $fields), $webhookSecret);
$given = $request->header('PokeaPay-Signature');
if (! hash_equals($expected, $given)) {
abort(401); // not from Pokea Pay — discard
}
// Deduplicate on $fields['event_id'], then process async.
Node.js
const crypto = require('crypto');
function canonicalV4(version, fields) {
const parts = Object.keys(fields).sort()
.map((k) => `${k}:${Buffer.byteLength(String(fields[k]))}:${fields[k]}`);
return `${version}|${parts.join('|')}`;
}
app.post('/webhooks/pokeapay', (req, res) => {
const expected = 'v4=' + crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(canonicalV4('v4.webhook', req.body)).digest('hex');
const given = req.get('PokeaPay-Signature') || '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(401);
}
res.sendStatus(200); // ack fast, process async
});
- Always compare with a constant-time function (
hash_equals / timingSafeEqual). - Reject events whose
ts is far outside your clock window. - Deduplicate on
event_id — delivery is at-least-once.