Developers

Webhooks

The signed server-to-server notification that is your only proof a payment succeeded.

Last updated: 31 October 2026

Setting up

Never trust the browser

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 2xx within 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:

HeaderValue
X-Gateway-EventSUCCESS, FAILED, EXPIRED or CANCELLED
X-Gateway-Signaturesha256=<hex digest>
X-Gateway-Delivery-IdUnique per delivery record. The same value across retries of that delivery.
X-Gateway-TimestampMilliseconds 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.

Use the raw body

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.

AttemptSent after
1Immediately on the event
25 seconds
330 seconds
42 minutes
510 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.

Design for at-least-once, unordered delivery

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

SymptomUsual cause
Every signature failsThe body was parsed and re-serialised before verifying. Use the raw body.
Signatures failed after a rotationYour server still has the old whsec_. Deploy the new one.
No webhook at allNo webhook URL saved, or no webhook secret exists. Both are required; a delivery without a secret is recorded as failed.
Delivery blockedYour URL resolves to a private or loopback address.
TimeoutsYou are doing the work before responding. Acknowledge, then process.
Duplicate orders createdFulfilment is not idempotent.