Signature Verification
ZenPay signs every webhook delivery so you can verify it came from ZenPay and was not tampered with.
How Signatures Work
- Algorithm: HMAC-SHA256 by default (configurable per endpoint to
sha256orsha512in the dashboard). - Signed content: The raw JSON request body as a UTF-8 string.
- Signature format:
{algorithm}={hex_digest}— e.g.sha256=a1b2c3d4e5f6... - Header:
X-ZenPay-Signature(default, configurable per endpoint). - Secret: Your webhook endpoint secret from the dashboard.
Always verify against the raw request body (bytes/string), not a parsed-then-re-serialized JSON object. Re-serialization can change key ordering or whitespace, which invalidates the signature.
Webhook Headers
Every webhook delivery includes the following headers:
| Header | Description | Example |
|---|---|---|
X-ZenPay-Signature | {algorithm}={hex_hmac_digest} of the raw JSON body using your webhook secret | sha256=a1b2c3d4... |
X-Zenpay-Event | Event type string | payment.success |
X-Zenpay-Event-Id | Unique event ID for deduplication | evt_abc123 |
X-Zenpay-Timestamp | ISO 8601 delivery timestamp | 2026-02-28T10:30:45.123Z |
X-Zenpay-Attempt | Delivery attempt number (starts at 1) | 1 |
User-Agent | Delivery user agent | Zenpay-Webhook/1.0 |
Verification Implementation
- JavaScript / Node.js
- Python
Use the helpers shipped in the SDK — you do not need to implement HMAC yourself. Both are exported at the package root and take no API key, so they run inside a webhook route with no client configured:
import { constructWebhookEvent, verifyWebhookSignature } from '@zenxdigitalholdings/zenpays'
// Returns a boolean.
const ok = await verifyWebhookSignature(rawBody, signatureHeader, secret)
// Or: verify and parse in one step. Throws Error('Invalid webhook signature')
// on failure, so a successful return guarantees authenticity.
const event = await constructWebhookEvent<PaymentSuccessEvent>(rawBody, signatureHeader, secret)
| Parameter | Type | Description |
|---|---|---|
rawBody | string | The raw request body, exactly as received |
signatureHeader | string | undefined | null | X-ZenPay-Signature value. Accepts sha256=… or bare hex |
secret | string | Your webhook signing secret |
Both are async (they use Web Crypto, with a node:crypto fallback below Node 18) and both return false / throw rather than error out on a missing body, header, or secret.
The bundled helpers implement HMAC-SHA256. If you have configured an endpoint to sign with sha512, verify it with the manual implementation below instead.
Full Express example:
import { constructWebhookEvent } from '@zenxdigitalholdings/zenpays'
import express from 'express'
const app = express()
app.post(
'/webhooks/zenpays',
// express.raw() keeps the body unparsed — express.json() would re-serialize
// it and break the signature.
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-zenpay-signature'] as string
const timestamp = req.headers['x-zenpay-timestamp'] as string
const eventId = req.headers['x-zenpay-event-id'] as string
// 1. Verify the signature and parse in one step.
let event: unknown
try {
event = await constructWebhookEvent(
req.body.toString(),
signature,
process.env.WEBHOOK_SECRET!,
)
}
catch {
return res.status(401).json({ error: 'Invalid signature' })
}
// 2. Check timestamp freshness (prevent replay attacks)
const deliveryTime = new Date(timestamp)
const now = new Date()
if (Math.abs(now.getTime() - deliveryTime.getTime()) > 5 * 60 * 1000) {
return res.status(401).json({ error: 'Webhook timestamp expired' })
}
// 3. Deduplicate using event ID
// if (await isEventAlreadyProcessed(eventId)) {
// return res.status(200).send('OK')
// }
// 4. Process
handleWebhookEvent(event)
res.status(200).send('OK')
}
)
Manual implementation (no SDK, or sha512 endpoints)
import crypto from 'node:crypto'
function verifyWebhookSignature(
rawBody: Buffer | string,
signature: string,
secret: string
): boolean {
const [algorithm, receivedHash] = signature.split('=')
const expectedHash = crypto
.createHmac(algorithm, secret)
.update(rawBody)
.digest('hex')
// Timing-safe comparison to prevent timing attacks
const received = Buffer.from(receivedHash, 'hex')
const expected = Buffer.from(expectedHash, 'hex')
if (received.length !== expected.length) return false
return crypto.timingSafeEqual(received, expected)
}
import hmac
import hashlib
from datetime import datetime, timezone, timedelta
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
algorithm, received_hash = signature.split("=", 1)
expected_hash = hmac.new(
secret.encode("utf-8"),
raw_body,
getattr(hashlib, algorithm),
).hexdigest()
return hmac.compare_digest(received_hash, expected_hash)
@app.route("/webhooks/zenpays", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-ZenPay-Signature", "")
timestamp = request.headers.get("X-Zenpay-Timestamp", "")
event_id = request.headers.get("X-Zenpay-Event-Id", "")
# 1. Verify signature
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 401
# 2. Check timestamp freshness
delivery_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if abs((now - delivery_time).total_seconds()) > 300:
return jsonify({"error": "Webhook timestamp expired"}), 401
# 3. Parse and process
event = request.get_json(force=True)
handle_webhook_event(event)
return "OK", 200
Replay Attack Prevention
Validate X-Zenpay-Timestamp is within 5 minutes of the current server time. This prevents attackers from replaying captured webhook deliveries.
Retry Behavior
If your endpoint fails to respond with a 2xx status code within 30 seconds, ZenPay will retry the delivery:
- Max retries: 3 attempts
- Backoff: Exponential backoff between retries
- Each retry includes an incremented
X-Zenpay-Attemptheader
Best Practices
- Respond quickly — Return a
2xxresponse within 30 seconds. Queue events for asynchronous background processing. - Handle duplicates — Events may be delivered more than once. Use
X-Zenpay-Event-Idto deduplicate. - Use HTTPS — Always use secure endpoints in production.
- Verify signatures — Never trust unverified payloads. Always validate
X-ZenPay-Signatureusing the raw request body. - Check timestamps — Validate
X-Zenpay-Timestampis within 5 minutes of current time to prevent replay attacks. - Log everything — Keep records of received webhooks for debugging.
- Monitor failures — Check the dashboard for failed deliveries and fix endpoint issues promptly.