Verify before you trust
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.