Webhook Signature Verification
This guide is for receivers of Paysense webhooks. It explains how to verify that a delivery genuinely came from Paysense and has not been tampered with or replayed.
Paysense follows the same signing approach as Stripe and GitHub: the delivery carries a timestamp and an HMAC-SHA256 signature computed over the timestamp and the raw request body together.
Prerequisites
| Item | Description |
|---|---|
| Webhook subscription | A subscription configured against your business, with your endpoint URL registered. |
| Subscription secret | The Secret configured on your webhook subscription. This is the signing key. Signatures are only sent when a secret is configured. |
| Raw request body | Your endpoint must be able to read the exact bytes of the request body, before any re-serialisation or parsing. |
Headers sent with every delivery
| Header | Description |
|---|---|
X-Webhook-Timestamp | Unix time in seconds when the delivery was signed. Sent on every delivery and generated fresh on every attempt, including retries. |
X-Webhook-Signature-256 | sha256=<hex> — HMAC-SHA256 over "{timestamp}.{rawBody}" using your subscription secret. Present when the subscription has a secret configured. Verify this. |
X-Webhook-Event | The event type. |
X-Webhook-Entity-Id | The ID of the entity the event relates to. |
X-Webhook-Subscription-Id | The subscription that produced this delivery. |
X-Webhook-Business-Id | The business the event belongs to. |
X-Webhook-Log-Id | A per-attempt delivery log ID. It changes on every attempt — do not use it for deduplication. |
note
The signing secret is the Secret configured on your webhook subscription. If no secret is configured, X-Webhook-Signature-256 is not sent and deliveries cannot be verified.
How to verify
- Read the raw body exactly as received — do not re-serialise or pretty-print it. The signature is computed over the exact bytes sent.
- Read
X-Webhook-TimestampandX-Webhook-Signature-256. - Check freshness. Reject the request if
|now - timestamp|is more than your tolerance (300 seconds / 5 minutes is the usual choice). This rejects replayed old requests. The tolerance also absorbs small clock differences between your server and Paysense. - Recompute the signature:
HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encode it, and prefixsha256=. - Compare in constant time against
X-Webhook-Signature-256. Never use a plain string equality check — use a constant-time comparison to avoid timing attacks.
Example (C#)
// tsHeader = X-Webhook-Timestamp
// sigHeader = X-Webhook-Signature-256
// rawBodyBytes = the exact bytes of the request body
// secret = your subscription secret
if (!long.TryParse(tsHeader, out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
return Unauthorized(); // stale or missing timestamp
var signed = Encoding.UTF8.GetBytes($"{ts}.").Concat(rawBodyBytes).ToArray();
var expected = "sha256=" + Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed)).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(sigHeader)))
return Unauthorized(); // signature mismatch
Example (Node.js)
const crypto = require("crypto");
function verify(rawBody, tsHeader, sigHeader, secret) {
const ts = Number(tsHeader);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sigHeader ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Security model
- Verify every delivery. Reject any request whose signature is missing, stale, or does not match. An unverified request must not be processed.
- Keep the secret server-side. The subscription secret is the signing key. Never expose it to browser or mobile code — anyone holding it can forge deliveries.
- Constant-time comparison only. A plain equality check leaks timing information that can be used to recover a valid signature.
- Sign over raw bytes. Verification must run against the exact body received. Re-serialising the payload changes the bytes and breaks the signature.