Skip to content
PayDirect Docs

Docs › Webhooks

Verifying signatures

Confirm a webhook really came from PayDirect - the exact algorithm, working code, and the mistakes that silently disable verification.

Your webhook endpoint is a public URL. Anyone can POST to it. The signature is what tells you a delivery is genuinely from us and has not been altered in transit.

The header

Every signed delivery carries:

text
X-UmoPay-Signature: t=1755634443118,v1=8f2d…c0
Part Meaning
t Unix timestamp in milliseconds, at the moment we signed
v1 Hex HMAC-SHA256 of "{t}.{raw body}", keyed with your webhook signing secret

The timestamp is signed alongside the body, not merely carried next to it - which is what stops a captured delivery from being replayed later under a fresh timestamp.

The algorithm

  1. Read the raw request body, exactly as bytes, before any JSON parsing.
  2. Split the header into t and v1.
  3. Reject if t is more than ~5 minutes old.
  4. Compute HMAC-SHA256("<t>.<rawBody>", signingSecret) as lowercase hex.
  5. Compare with v1 in constant time.
  6. Only then parse the JSON.
Every reject path matters - a missing header is as fatal as a wrong digest.

Verifying the signature

Node.js

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

const TOLERANCE_MS = 5 * 60 * 1000;

function verify(header, rawBody, secret) {
  if (!header) return false; // no header at all - see the warning below

  const parts = Object.fromEntries(
    String(header).split(',').map(kv => kv.split('=').map(s => s.trim())),
  );
  const timestamp = Number(parts.t);
  const provided = parts.v1;
  if (!Number.isFinite(timestamp) || !provided) return false;

  if (Math.abs(Date.now() - timestamp) > TOLERANCE_MS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody.toString('utf8')}`)
    .digest('hex');

  // timingSafeEqual throws on a length mismatch, so guard first.
  if (provided.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}

const app = express();

// express.raw, NOT express.json - the signature covers the bytes we sent.
app.post('/hooks/paydirect', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.headers['x-umopay-signature'], req.body, process.env.PAYDIRECT_WEBHOOK_SECRET)) {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.status(200).end();
  queue.enqueue(event);
});

Python

import hashlib, hmac, time
from flask import Flask, request, abort

TOLERANCE_MS = 5 * 60 * 1000
app = Flask(__name__)

def verify(header: str, raw_body: bytes, secret: str) -> bool:
    if not header:
        return False
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        timestamp = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(int(time.time() * 1000) - timestamp) > TOLERANCE_MS:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(provided, expected)

@app.post("/hooks/paydirect")
def hook():
    # request.get_data() gives the raw bytes; request.json would not.
    if not verify(request.headers.get("X-UmoPay-Signature"), request.get_data(), SECRET):
        abort(400)
    return "", 200

PHP

<?php
function verifyWebhook(?string $header, string $rawBody, string $secret): bool
{
    if ($header === null) {
        return false;
    }

    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', $pair, 2), 2, null);
        $parts[trim($key)] = $value;
    }

    if (!isset($parts['t'], $parts['v1'])) {
        return false;
    }
    if (abs(round(microtime(true) * 1000) - (int) $parts['t']) > 5 * 60 * 1000) {
        return false;
    }

    $expected = hash_hmac('sha256', "{$parts['t']}.{$rawBody}", $secret);
    return hash_equals($expected, $parts['v1']);
}

verifyWebhook(
    $_SERVER['HTTP_X_UMOPAY_SIGNATURE'] ?? null,
    file_get_contents('php://input'),  // raw body, not $_POST
    getenv('PAYDIRECT_WEBHOOK_SECRET')
);

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public final class WebhookVerifier {
    private static final long TOLERANCE_MS = 5 * 60 * 1000L;

    public static boolean verify(String header, byte[] rawBody, String secret) throws Exception {
        if (header == null) return false; // no header at all - see the warning below

        String timestamp = null, provided = null;
        for (String pair : header.split(",")) {
            String[] kv = pair.trim().split("=", 2);
            if (kv.length != 2) continue;
            if (kv[0].equals("t")) timestamp = kv[1];
            if (kv[0].equals("v1")) provided = kv[1];
        }
        if (timestamp == null || provided == null) return false;

        if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > TOLERANCE_MS) {
            return false;
        }

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        byte[] digest = mac.doFinal(rawBody);

        StringBuilder expected = new StringBuilder();
        for (byte b : digest) {
            expected.append(String.format("%02x", b));
        }

        // MessageDigest.isEqual is the constant-time comparison in the JDK.
        return MessageDigest.isEqual(
                expected.toString().getBytes(StandardCharsets.UTF_8),
                provided.getBytes(StandardCharsets.UTF_8));
    }
}

// Spring Boot: bind the body as byte[], never as a parsed DTO - the signature covers the bytes.
// @PostMapping(value = "/hooks/paydirect", consumes = MediaType.ALL_VALUE)
// ResponseEntity<Void> hook(@RequestBody byte[] rawBody,
//                           @RequestHeader("X-UmoPay-Signature") String signature) { … }

C#

using System.Security.Cryptography;
using System.Text;

public static class WebhookVerifier
{
    private const long ToleranceMs = 5 * 60 * 1000;

    public static bool Verify(string? header, byte[] rawBody, string secret)
    {
        if (header is null) return false; // no header at all - see the warning below

        string? timestamp = null, provided = null;
        foreach (var pair in header.Split(','))
        {
            var kv = pair.Trim().Split('=', 2);
            if (kv.Length != 2) continue;
            if (kv[0] == "t") timestamp = kv[1];
            if (kv[0] == "v1") provided = kv[1];
        }
        if (timestamp is null || provided is null) return false;

        if (!long.TryParse(timestamp, out var sentAt)) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - sentAt) > ToleranceMs) return false;

        using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
        var signed = new byte[prefix.Length + rawBody.Length];
        Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
        Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);

        var expected = Convert.ToHexString(mac.ComputeHash(signed)).ToLowerInvariant();

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected),
            Encoding.UTF8.GetBytes(provided));
    }
}

// ASP.NET Core minimal API - read the raw body, do not model-bind it.
// app.MapPost("/hooks/paydirect", async (HttpRequest req) =>
// {
//     using var ms = new MemoryStream();
//     await req.Body.CopyToAsync(ms);
//     var raw = ms.ToArray();
//     if (!WebhookVerifier.Verify(req.Headers["X-UmoPay-Signature"], raw, Secret))
//         return Results.BadRequest();
//     return Results.Ok();
// });

The four ways verification silently fails

Caution

Each of these leaves an endpoint that looks verified and accepts forged requests. They are worth checking explicitly.

1. A missing header treated as valid. If no signing secret is configured, no signature header is sent at all. Code shaped like if (header && !matches) reject accepts every unsigned delivery. Reject a missing header outright, as every example above does.

2. Verifying re-serialised JSON. JSON.stringify(req.body) is not the body we signed - key order, spacing and number formatting all differ. Capture the raw bytes. In Express that means express.raw() on this route, mounted before any global express.json().

3. Skipping the timestamp check. Without it, a captured delivery replays forever. Five minutes is the right tolerance; if your clock drifts further than that, fix the clock rather than widening the window.

4. A non-constant-time comparison. === on the digest leaks timing. Use timingSafeEqual, hmac.compare_digest or hash_equals.

Testing it

Do not hand-craft a payload. Point webhookUrl at a tunnel (ngrok, localtunnel) in the sandbox, run a real collection, and verify against what actually arrives. Then break it deliberately:

  • Flip one character of the signature → must be rejected.
  • Strip the header entirely → must be rejected.
  • Replay a delivery from ten minutes ago → must be rejected.
  • Replay a valid delivery twice → must be accepted and deduped on eventId, not rejected.

Rotating the secret

Secret rotation is done by PayDirect: contact support and agree a time. The new secret is shown once, and it takes effect as soon as it is issued.

So before the rotation, deploy a receiver that accepts either the old or the new secret. Load the new one as soon as you receive it, then remove the old one once you have seen deliveries verify under the new one.

Next

Retries and delivery - what happens when your endpoint is down.