Setting up
A success event in the browser is a user-interface hint. Anyone can fake it. Treat a payment as paid only after your server has verified a signed webhook, or has read the payment back from the API with your secret key.
Set your endpoint in Dashboard → Developers. Requirements:
- A public
https://URL. Private, loopback, link-local and cloud metadata addresses are refused, and the URL is re-validated before every delivery and every retry. - Responds
2xxwithin 10 seconds. Acknowledge first, then do slow work asynchronously. - Does not redirect. Redirects are never followed on a webhook delivery.
What we send
POST with Content-Type: application/json and:
| Header | Value |
|---|---|
X-Gateway-Event | SUCCESS, FAILED, EXPIRED or CANCELLED |
X-Gateway-Signature | sha256=<hex digest> |
X-Gateway-Delivery-Id | Unique per delivery record. The same value across retries of that delivery. |
X-Gateway-Timestamp | Milliseconds since epoch, as a string, at the moment of the attempt |
Payload
{
"event": "SUCCESS",
"paymentId": "7f3c1e62-...",
"orderId": "ORDER-1042",
"attemptId": "7f3c1e62-...",
"amount": "499.00",
"amountPaise": 49900,
"currency": "INR",
"method": "UPI",
"utr": "429913847265",
"verificationTag": "BANK_VERIFIED",
"verificationLevel": 2,
"completedAt": "2026-10-31T06:12:40.112Z",
"metadata": { "page_id": "...", "customer": { "name": "...", "email": "...", "phone": "..." } }
}
utr is the bank reference number, which is what you and your customer will both
quote when reconciling against a bank statement. It is an empty string when the bank did not
report one.
Verifying the signature
The signature is HMAC-SHA256, hex encoded, over the exact bytes of the
request body, keyed with your whsec_… webhook secret. No timestamp or
prefix is included in the signed data.
Parse the JSON only after verifying. If your framework parses and re-serialises the body first, the bytes change and every signature will fail.
Node.js (Express)
app.post('/webhooks/cashvya', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', process.env.CASHVYA_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const received = String(req.get('X-Gateway-Signature') || '').replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex'), b = Buffer.from(received, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).end();
handle(JSON.parse(req.body.toString('utf8')));
res.sendStatus(200);
});
PHP
<?php
$raw = file_get_contents('php://input');
$received = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $raw, getenv('CASHVYA_WEBHOOK_SECRET'));
if (!hash_equals($expected, $received)) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);
if (($event['event'] ?? '') === 'SUCCESS') {
fulfil_order($event['orderId'], $event['amountPaise'], $event['utr']);
}
http_response_code(200);
Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.post('/webhooks/cashvya')
def cashvya_webhook():
raw = request.get_data()
expected = hmac.new(os.environ['CASHVYA_WEBHOOK_SECRET'].encode(),
raw, hashlib.sha256).hexdigest()
received = request.headers.get('X-Gateway-Signature', '').removeprefix('sha256=')
if not hmac.compare_digest(expected, received):
abort(400)
event = request.get_json()
if event['event'] == 'SUCCESS':
fulfil_order(event['orderId'], event['amountPaise'], event['utr'])
return '', 200
Retries and ordering
Any response that is not 2xx, and any timeout, is retried.
| Attempt | Sent after |
|---|---|
| 1 | Immediately on the event |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
After 5 attempts the delivery is marked permanently failed and is not retried. Every attempt, with the status and the first 1000 characters of your response, is recorded and visible in the dashboard, so a failing endpoint is diagnosable rather than invisible.
Retries carry the same body and the same signature as the first attempt, so
verification behaves identically. Only X-Gateway-Timestamp changes.
Make fulfilment idempotent on orderId, and do not assume webhooks arrive in
order. If an event looks stale against what you have already recorded, ignore it.
Troubleshooting
| Symptom | Usual cause |
|---|---|
| Every signature fails | The body was parsed and re-serialised before verifying. Use the raw body. |
| Signatures failed after a rotation | Your server still has the old whsec_. Deploy the new one. |
| No webhook at all | No webhook URL saved, or no webhook secret exists. Both are required; a delivery without a secret is recorded as failed. |
| Delivery blocked | Your URL resolves to a private or loopback address. |
| Timeouts | You are doing the work before responding. Acknowledge, then process. |
| Duplicate orders created | Fulfilment is not idempotent. |