Verify PayerOne webhook signatures

PayerOne signs a webhook with **HMAC SHA-256** using your merchant **Private Key**. The hexadecimal signature is sent in `X-PayerOne-Signature`. Your Merchant Token authenticates outbound API requests and is not the webhook-signing key.

Verification sequence

1. Read the original request body bytes before JSON middleware changes them.

2. Compute HMAC SHA-256 over those bytes using the merchant Private Key from your server configuration.

3. Compare the expected signature with `X-PayerOne-Signature` in constant time.

4. Reject a missing or mismatched signature.

5. Parse the verified JSON, validate the event shape, and safely accept it before returning HTTP 2xx.

Node.js and Express

Register the raw-body route before a general `express.json()` middleware. Replace the comment with your durable, idempotent event storage or queue before using this receiver.

const crypto = require('node:crypto');
const express = require('express');
const app = express();

app.post('/webhooks/payerone', express.raw({ type: 'application/json' }), async (req, res) => {
  const key = process.env.PAYERONE_WEBHOOK_PRIVATE_KEY;
  const signature = req.get('X-PayerOne-Signature') || '';
  if (!key || !Buffer.isBuffer(req.body) || !/^[a-f0-9]{64}$/i.test(signature)) {
    return res.sendStatus(401);
  }
  const expected = crypto.createHmac('sha256', key).update(req.body).digest();
  const received = Buffer.from(signature, 'hex');
  if (!crypto.timingSafeEqual(expected, received)) return res.sendStatus(401);

  let event;
  try { event = JSON.parse(req.body.toString('utf8')); }
  catch { return res.sendStatus(400); }
  if (!event || typeof event.id !== 'string' || typeof event.type !== 'string') {
    return res.sendStatus(400);
  }
  // Await durable, idempotent storage or queueing of event here.
  // Do not acknowledge until your application has safely accepted it.
  return res.sendStatus(200);
});

PHP

Read `php://input` directly. Store the same merchant Private Key in the server environment; never include it in the script or repository.

<?php
$key = getenv('PAYERONE_WEBHOOK_PRIVATE_KEY');
$signature = $_SERVER['HTTP_X_PAYERONE_SIGNATURE'] ?? '';
$raw = file_get_contents('php://input');
if (!$key || !preg_match('/^[a-f0-9]{64}$/i', $signature) ||
    !hash_equals(hash_hmac('sha256', $raw, $key), strtolower($signature))) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
if (!is_array($event) || !isset($event['id'], $event['type'])) {
    http_response_code(400);
    exit;
}
// Durably store or queue the event once before acknowledging.
http_response_code(200);

These examples illustrate verification and the acceptance boundary. Implement persistent duplicate protection and error handling for your application. A receiver that cannot safely save the event should not return a success response.

Common signature mistakes

Do not decode and re-encode JSON before hashing it. Whitespace, property order, escaping and encoding changes alter the bytes. Verify with the Private Key for the merchant that owns the event. Preserve the signature header through your proxy. A matching User-Agent or a familiar JSON shape is not signature verification.