NEW APP AVAILABLE FOR DOWNLOAD NOW

Get it on Google PlayDownload on the App Store

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:

text
X-RizPay-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The header contains:

  • t - Unix timestamp when the signature was generated
  • v1 - HMAC-SHA256 signature of the payload

Verification Steps

1. Extract the Timestamp and Signature

javascript
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:

javascript
const signedPayload = `${timestamp}.${rawBody}`;

3. Compute Expected Signature

javascript
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:

javascript
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:

javascript
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

javascript
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

python
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

ruby
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:

  1. Go to Settings > Webhooks
  2. Click on your webhook
  3. Click "Regenerate Secret"
  4. Update your server with the new secret
  5. 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