Webhook Security
Verify webhook signatures to ensure authenticity
Verify webhook signatures to ensure requests are genuinely from RizPay and haven't been tampered with.
Why Verify Signatures?
Without verification, anyone who discovers your webhook URL could send fake events to your server. Signature verification ensures:
- Authenticity - The request came from RizPay
- Integrity - The payload hasn't been modified
- Protection - Against replay attacks
Signing Secret
Each webhook has a unique signing secret (prefixed with whsec_). Find it in your webhook settings at Settings > Webhooks.
Keep this secret safe. Never expose it in client-side code or public repositories.
Webhook Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
X-RizPay-Signature | HMAC-SHA256 signature, formatted sha256=<hex digest> |
X-RizPay-Timestamp | Unix timestamp (seconds) when the signature was created |
X-RizPay-Event-ID | Unique event id (also present as id in the body) |
Example:
X-RizPay-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-RizPay-Timestamp: 1705312200
X-RizPay-Event-ID: evt_abc123def456
The signature is an HMAC-SHA256 hex digest computed over the string timestamp.rawBody (the value of X-RizPay-Timestamp, a literal period, then the raw request body), keyed with your signing secret. The digest is prefixed with sha256= in the header.
Verification Steps
1. Read the Timestamp and Signature
The timestamp and signature arrive in separate headers. Strip the sha256= prefix from the signature to get the raw hex digest:
function readSignatureHeaders(headers) {
const timestamp = headers["x-rizpay-timestamp"];
const signatureHeader = headers["x-rizpay-signature"]; // "sha256=<hex>"
const signature = signatureHeader?.replace(/^sha256=/, "");
return { timestamp, signature };
}
2. Prepare the Signed Payload
Concatenate the timestamp and raw request body with a period:
const signedPayload = `${timestamp}.${rawBody}`;
3. Compute Expected Signature
const crypto = require("crypto");
function computeSignature(signedPayload, secret) {
return crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
}
4. Compare Signatures
Use constant-time comparison to prevent timing attacks:
const crypto = require("crypto");
function secureCompare(a, b) {
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
5. Check Timestamp (Optional but Recommended)
Reject requests older than 5 minutes to prevent replay attacks:
function isTimestampValid(timestamp, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - parseInt(timestamp)) <= toleranceSeconds;
}
Complete Verification Example
Node.js / Express
const express = require("express");
const crypto = require("crypto");
const WEBHOOK_SECRET = "whsec_your_signing_secret";
function verifyWebhookSignature(req, secret) {
const timestamp = req.headers["x-rizpay-timestamp"];
const signatureHeader = req.headers["x-rizpay-signature"];
if (!timestamp || !signatureHeader) {
throw new Error("Missing signature headers");
}
// Strip the "sha256=" prefix to get the raw hex digest
const receivedSig = signatureHeader.replace(/^sha256=/, "");
// Check timestamp (5 minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error("Timestamp too old");
}
// Compute expected signature
const payload = `${timestamp}.${req.rawBody}`;
const expectedSig = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Constant-time comparison
if (
!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expectedSig))
) {
throw new Error("Signature mismatch");
}
return true;
}
// Middleware to capture raw body
app.use(
"/webhooks/rizpay",
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
})
);
app.post("/webhooks/rizpay", (req, res) => {
try {
verifyWebhookSignature(req, WEBHOOK_SECRET);
} catch (error) {
console.error("Signature verification failed:", error.message);
return res.status(401).send("Invalid signature");
}
// Process the verified webhook
const event = req.body;
console.log(`Verified event: ${event.type}`);
res.status(200).send("OK");
});
Python / Flask
import hmac
import hashlib
import time
from flask import Flask, request, abort
WEBHOOK_SECRET = 'whsec_your_signing_secret'
def verify_signature(payload, signature_header, timestamp, secret):
if not signature_header or not timestamp:
raise ValueError('Missing signature headers')
# Strip the "sha256=" prefix to get the raw hex digest
received_sig = signature_header.removeprefix('sha256=')
# Check timestamp (5 minute tolerance)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(received_sig, expected_sig):
raise ValueError('Signature mismatch')
return True
app = Flask(__name__)
@app.route('/webhooks/rizpay', methods=['POST'])
def webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get('X-RizPay-Signature')
timestamp = request.headers.get('X-RizPay-Timestamp')
try:
verify_signature(payload, signature, timestamp, WEBHOOK_SECRET)
except ValueError as e:
print(f'Signature verification failed: {e}')
abort(401)
event = request.json
print(f"Verified event: {event['type']}")
return 'OK', 200
Ruby / Rails
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
WEBHOOK_SECRET = 'whsec_your_signing_secret'
def rizpay
payload = request.raw_post
signature = request.headers['X-RizPay-Signature']
timestamp = request.headers['X-RizPay-Timestamp']
unless verify_signature(payload, signature, timestamp, WEBHOOK_SECRET)
head :unauthorized
return
end
event = JSON.parse(payload)
Rails.logger.info "Verified event: #{event['type']}"
head :ok
end
private
def verify_signature(payload, signature_header, timestamp, secret)
return false unless signature_header && timestamp
# Strip the "sha256=" prefix to get the raw hex digest
received_sig = signature_header.delete_prefix('sha256=')
# Check timestamp (5 minute tolerance)
return false if (Time.now.to_i - timestamp.to_i).abs > 300
# Compute expected signature
signed_payload = "#{timestamp}.#{payload}"
expected_sig = OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload)
# Constant-time comparison
ActiveSupport::SecurityUtils.secure_compare(received_sig, expected_sig)
end
end
Regenerating Secrets
If your signing secret is compromised:
- Go to Settings > Webhooks
- Click on your webhook
- Click "Regenerate Secret"
- Update your server with the new secret
- The old secret is invalidated immediately
Troubleshooting
Signature Mismatch
- Ensure you're using the raw request body, not parsed JSON
- Check that you're using the correct signing secret
- Confirm you sign
timestamp.rawBodyusing theX-RizPay-Timestampvalue - Remember to strip the
sha256=prefix before comparing digests
Timestamp Too Old
- Check your server's clock synchronization
- Increase timestamp tolerance if needed (but not too much)
Missing Header
- Ensure your server preserves both
X-RizPay-SignatureandX-RizPay-Timestamp - Check for proxy/load balancer stripping headers
Next Steps
- Events Reference - All webhook event types
- Webhooks Overview - Setup and configuration
