---
title: Webhook Security
description: 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](https://my.rizpay.app/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:

```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](https://my.rizpay.app/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

- [Events Reference](/docs/webhooks/events) - All webhook event types
- [Webhooks Overview](/docs/webhooks/overview) - Setup and configuration