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.
Signature Header
Every webhook request includes a signature in the X-RizPay-Signature header:
X-RizPay-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The header contains:
t- Unix timestamp when the signature was generatedv1- HMAC-SHA256 signature of the payload
Verification Steps
1. Extract the Timestamp and Signature
function parseSignatureHeader(header) {
const parts = header.split(",");
const timestamp = parts.find((p) => p.startsWith("t=")).slice(2);
const signature = parts.find((p) => p.startsWith("v1=")).slice(3);
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(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).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 signature = req.headers["x-rizpay-signature"];
if (!signature) {
throw new Error("No signature header");
}
// Parse signature header
const parts = signature.split(",");
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
const receivedSig = parts.find((p) => p.startsWith("v1="))?.slice(3);
if (!timestamp || !receivedSig) {
throw new Error("Invalid signature format");
}
// 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, secret):
if not signature_header:
raise ValueError('No signature header')
# Parse signature header
parts = dict(p.split('=') for p in signature_header.split(','))
timestamp = parts.get('t')
received_sig = parts.get('v1')
if not timestamp or not received_sig:
raise ValueError('Invalid signature format')
# 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')
try:
verify_signature(payload, signature, 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']
unless verify_signature(payload, signature, 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, secret)
return false unless signature_header
parts = signature_header.split(',').map { |p| p.split('=') }.to_h
timestamp = parts['t']
received_sig = parts['v1']
return false unless timestamp && received_sig
# 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
- Verify the timestamp format (Unix seconds, not milliseconds)
Timestamp Too Old
- Check your server's clock synchronization
- Increase timestamp tolerance if needed (but not too much)
Missing Header
- Ensure your server preserves all request headers
- Check for proxy/load balancer stripping headers
Next Steps
- Events Reference - All webhook event types
- Webhooks Overview - Setup and configuration
