ZyloPaydocs

Verify webhook signatures

Check the ZyloPay-Signature header with HMAC-SHA256 over the timestamp and raw body. Examples in Node, Python, Go, Ruby, Java and PHP.

Every delivery carries a ZyloPay-Signature header:

ZyloPay-Signature: t=1790604160,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
PartMeaning
tUnix time, in seconds, when this attempt was signed. Each retry is signed again.
v1Hex HMAC-SHA256 of "<t>.<raw body>" with your endpoint's signing secret. During a secret rotation there are two v1 values, one per active secret.

The algorithm

  1. Read the raw body bytes exactly as received. Do not parse and re-serialize the JSON: any change in spacing or key order breaks the signature.
  2. Split the header on ,, and each part on the first =. Take t and every v1.
  3. Reject if t is more than 300 seconds away from your clock (replay protection).
  4. Compute HMAC-SHA256(key = your whsec_… secret, message = t + "." + raw body) and hex-encode it. Use the whole secret string, whsec_ prefix included, as the key.
  5. Accept if the result equals any v1, using a constant-time comparison.

Every example below is tested against payloads signed by ZyloPay's own signing code, including a rotation header with two signatures, a tampered body, a wrong secret and a stale timestamp.

Examples

With the SDK, constructEvent verifies and parses in one call. It throws ZyloPay.errors.SignatureVerificationError on any failure. The fourth argument is the tolerance in seconds (default 300).

import ZyloPay from '@zylopay/node';

const zp = new ZyloPay(process.env.ZYLOPAY_SECRET_KEY!);

// rawBody: the request body as a string or Buffer, before JSON parsing
const event = zp.webhooks.constructEvent(rawBody, req.headers['zylopay-signature'], process.env.ZYLOPAY_WEBHOOK_SECRET!);

Without the SDK, with node:crypto only:

verify.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyZyloPaySignature(rawBody, header, secret, toleranceSeconds = 300) {
  let timestamp = null;
  const signatures = [];
  for (const part of String(header ?? '').split(',')) {
    const i = part.indexOf('=');
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(Buffer.from(value, 'hex'));
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody) // Buffer or string, exactly as received
    .digest();
  return signatures.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected));
}

With Express, keep the raw body for this route:

app.post('/zylopay/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyZyloPaySignature(req.body, req.get('ZyloPay-Signature'), process.env.ZYLOPAY_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  enqueue(event); // process asynchronously
  res.sendStatus(200);
});

Replay protection

The timestamp is part of the signed message, so an attacker cannot change it without breaking the signature. Rejecting timestamps more than 5 minutes from your clock means a captured delivery cannot be replayed later.

  • Keep your servers' clocks synchronised with NTP.
  • Retries are signed again with a new t, so a legitimate retry always passes.
  • For defence in depth, also store processed event IDs and ignore repeats. You need this anyway, because delivery is at least once. See Ordering and duplicates.

Common mistakes

SymptomCause
Every signature failsThe body was parsed and re-serialized (for example by a JSON body parser) before verification. Verify the raw bytes.
Every signature failsThe secret is from another endpoint or mode, or the whsec_ prefix was stripped.
Failures after some hoursYour server clock drifted beyond 5 minutes.
Failures only during rotationThe code checks only the first v1. Accept a match with any of them.
Timing leaks in a security reviewPlain == string comparison. Use the constant-time function of your language.

On this page