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.

Webhook Headers

Every webhook request includes these headers:

HeaderDescription
X-RizPay-SignatureHMAC-SHA256 signature, formatted sha256=<hex digest>
X-RizPay-TimestampUnix timestamp (seconds) when the signature was created
X-RizPay-Event-IDUnique event id (also present as id in the body)

Example:

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

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

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

3. Compute Expected Signature

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

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

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, 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

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']
    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:

  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
  • Confirm you sign timestamp.rawBody using the X-RizPay-Timestamp value
  • 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-Signature and X-RizPay-Timestamp
  • Check for proxy/load balancer stripping headers

Next Steps