Skip to main content

Signature Verification

ZenPay signs every webhook delivery so you can verify it came from ZenPay and was not tampered with.

How Signatures Work

  1. Algorithm: HMAC-SHA256 by default (configurable per endpoint to sha256 or sha512 in the dashboard).
  2. Signed content: The raw JSON request body as a UTF-8 string.
  3. Signature format: {algorithm}={hex_digest} — e.g. sha256=a1b2c3d4e5f6...
  4. Header: X-ZenPay-Signature (default, configurable per endpoint).
  5. Secret: Your webhook endpoint secret from the dashboard.
Use the raw body

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:

HeaderDescriptionExample
X-ZenPay-Signature{algorithm}={hex_hmac_digest} of the raw JSON body using your webhook secretsha256=a1b2c3d4...
X-Zenpay-EventEvent type stringpayment.success
X-Zenpay-Event-IdUnique event ID for deduplicationevt_abc123
X-Zenpay-TimestampISO 8601 delivery timestamp2026-02-28T10:30:45.123Z
X-Zenpay-AttemptDelivery attempt number (starts at 1)1
User-AgentDelivery user agentZenpay-Webhook/1.0

Verification Implementation

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)
ParameterTypeDescription
rawBodystringThe raw request body, exactly as received
signatureHeaderstring | undefined | nullX-ZenPay-Signature value. Accepts sha256=… or bare hex
secretstringYour 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.

SHA-256 only

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)
}

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-Attempt header

Best Practices

  1. Respond quickly — Return a 2xx response within 30 seconds. Queue events for asynchronous background processing.
  2. Handle duplicates — Events may be delivered more than once. Use X-Zenpay-Event-Id to deduplicate.
  3. Use HTTPS — Always use secure endpoints in production.
  4. Verify signatures — Never trust unverified payloads. Always validate X-ZenPay-Signature using the raw request body.
  5. Check timestamps — Validate X-Zenpay-Timestamp is within 5 minutes of current time to prevent replay attacks.
  6. Log everything — Keep records of received webhooks for debugging.
  7. Monitor failures — Check the dashboard for failed deliveries and fix endpoint issues promptly.