Skip to main content

Overview

Pepay signs every webhook request so you can verify authenticity and integrity. Validation requires the raw request body plus the X-Pepay-Timestamp and signature headers.

Headers

  • X-Pepay-Timestamp
  • X-Pepay-Signature
  • X-Pepay-Signature-Previous (optional during rotation)

Signed payload format

  • ${timestamp_ms}.${raw_body}
  • Signature: HMAC_SHA256(secret, signed_payload) (hex)
Important:
  • Verify against the raw request body bytes, not a re-serialized JSON object.
  • Reject requests with timestamps outside a small tolerance window (recommended: 5 minutes).

Example (Node, no SDK)

const crypto = require('crypto');
const express = require('express');

function timingSafeEqualHex(left, right) {
  const a = Buffer.from(String(left || ''), 'hex');
  const b = Buffer.from(String(right || ''), 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function computeSignature(secret, timestampMs, rawBody) {
  const signed = `${timestampMs}.${rawBody}`;
  return crypto.createHmac('sha256', secret).update(signed).digest('hex');
}

const app = express();
app.post('/webhooks/pepay', express.raw({ type: 'application/json' }), (req, res) => {
  const timestampMs = req.get('X-Pepay-Timestamp');
  const signature = req.get('X-Pepay-Signature');
  const signaturePrevious = req.get('X-Pepay-Signature-Previous');
  const rawBody = req.body.toString('utf8');

  const nowMs = Date.now();
  if (Math.abs(nowMs - Number(timestampMs)) > 5 * 60 * 1000) {
    return res.status(400).send('timestamp_out_of_range');
  }

  const expected = computeSignature(process.env.PEPAY_WEBHOOK_SECRET, timestampMs, rawBody);
  const expectedPrevious = process.env.PEPAY_WEBHOOK_SECRET_PREVIOUS
    ? computeSignature(process.env.PEPAY_WEBHOOK_SECRET_PREVIOUS, timestampMs, rawBody)
    : null;

  const ok =
    timingSafeEqualHex(signature, expected) ||
    (expectedPrevious && timingSafeEqualHex(signaturePrevious, expectedPrevious));

  if (!ok) return res.status(400).send('invalid_signature');
  return res.status(200).send('ok');
});

SDK helper (Node)

import express from 'express';
import { createWebhookVerifier } from 'pepay';

const verifier = createWebhookVerifier({
  secrets: [process.env.PEPAY_WEBHOOK_SECRET, process.env.PEPAY_WEBHOOK_SECRET_PREVIOUS].filter(Boolean),
  toleranceMs: 5 * 60 * 1000
});

app.post('/webhooks/pepay', express.raw({ type: 'application/json' }), async (req, res) => {
  const result = await verifier.verify({ rawBody: req.body, headers: req.headers });
  if (!result.valid) return res.status(400).send(result.reason || 'invalid_signature');
  return res.status(200).send('ok');
});
See Webhooks (merchant) for the full SDK walkthrough.

Multiple endpoints and rotation

  • If you run multiple endpoints, keep a list of active secrets and verify against any.
  • Use X-Pepay-Network-Environment to route devnet vs mainnet endpoints if you want strict separation.
  • During rotation, keep the previous secret configured until all endpoints are updated.

Failure behavior

  • Return 2xx quickly after enqueueing work.
  • Non-2xx responses are retried with backoff, so keep handlers idempotent.
Next: WebSockets