Get started
Callbacks
Status callbacks
Give us an HTTPS URL and we will call it:
- when a transaction we answered pending reaches its final state;
- when a final transaction changes later: a successful transaction our team reverses and refunds is sent again as
FAILED(and a cleared complaint re-sends the status, see Complaint callbacks). - on callback version 3, also a money transfer refunded after success and a cancelled bus ticket: each is sent again as
FAILED. Version 2 does not send these. - on callback version 3, also when your request got no answer from us at all (a timeout, or an HTTP
5xx): you get the final status once it is known.
A reply that was already final (SUCCESS or FAILED in the response itself) gets no callback, and pending is never called. Delivery is retried with a growing back-off until your endpoint answers 2xx.
GET https://your-server.example/callback
?v=2
&status=SUCCESS
&orderid=R2607230429028DB15
&urid=ABC123
&amount=100.0000
&account=9876543210
&ts=1755400000
&nonce=3f1c0a9b2e5d4867b1a0c7d9e2f34856
&hash=<hmac>Verify hash before trusting a callback. It is an HMAC-SHA256, keyed with your API token, over every parameter we send except hash itself — sorted by key and joined as key=value with &. Build the pre-image from what actually arrived rather than from a fixed list of names, so a field we add later is covered automatically:
$params = $_GET;
unset($params['hash']);
ksort($params, SORT_STRING);
$pairs = [];
foreach ($params as $k => $v) {
$pairs[] = $k . '=' . $v;
}
$expected = hash_hmac('sha256', implode('&', $pairs), $yourApiToken);
if (!hash_equals($expected, (string) ($_GET['hash'] ?? ''))) {
http_response_code(403);
exit;
}
// Freshness: refuse anything outside a five-minute window.
if (abs(time() - (int) ($_GET['ts'] ?? 0)) > 300) {
http_response_code(403);
exit;
}
// Replay: refuse a nonce you have already seen inside that window.
if ($youHaveSeen[$_GET['nonce']] ?? false) {
http_response_code(403);
exit;
}
// Duplicate delivery: we retry until you answer 2xx, so the SAME orderid and
// status may arrive more than once with different ts/nonce. Book each orderid +
// status once. A FAILED after a SUCCESS is a reversal: the money is back in your
// wallet — apply it, do not drop it as a duplicate.amount and account. A verifier that hashes only some of them will happily accept a callback whose amount or destination account was rewritten in transit — the signature over the fields it does check still matches.Version 3: signed JSON callbacks
Optional, and recommended. The details travel in a JSON body instead of the URL, so the amount and the account no longer land in your web-server logs, and they are signed with a callback secret of their own instead of your API token. It applies to status and complaint callbacks alike.
- In the client portal, open API → IP allowlist and, on the Callback security card, press Create callback secret. It starts with
cbk_and is shown once. - Make your receiver accept version 3 (below).
- Press Switch to version 3. Every callback from then on is version 3. To go back to version 2, ask your account manager.
POST https://your-server.example/callback
Content-Type: application/json
X-Callback-Signature: 2a3f4596f94782b4244eed67bfc5e91ae6d78f12e136cb17006356b0a4841612
X-Callback-Timestamp: 1760432460
{"status":"SUCCESS","orderid":"R2607230429028DB15","urid":"ORD-1001","optid":"BR000ABC123","amount":"10.0000","account":"9876543210","v":"3","ts":"1760432460","nonce":"3f1c0a9b2e5d4867b1a0c7d9e2f34856"}The body carries the same fields as version 2, as JSON strings, plus "v":"3", ts and nonce; there is no hash field. X-Callback-Signature is the HMAC-SHA256 of the raw body bytes, keyed with your whole callback secret, in lowercase hex (the example above is signed with cbk_fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210). Check it over the bytes exactly as they arrived, before you parse the JSON:
$raw = file_get_contents('php://input'); // the exact bytes — BEFORE any JSON parsing
$expected = hash_hmac('sha256', $raw, $yourCallbackSecret);
if (!hash_equals($expected, (string) ($_SERVER['HTTP_X_CALLBACK_SIGNATURE'] ?? ''))) {
http_response_code(403);
exit;
}
$callback = json_decode($raw, true);
// Freshness and replay, exactly as for version 2.
if (abs(time() - (int) ($callback['ts'] ?? 0)) > 300) {
http_response_code(403);
exit;
}
if ($youHaveSeen[$callback['nonce']] ?? false) {
http_response_code(403);
exit;
}
// Book it once: keyed on orderid + status (status callback; a FAILED after a SUCCESS is a
// reversal) or complaint_ref (complaint callback).const crypto = require('crypto');
const express = require('express');
const app = express();
// express.raw keeps the exact bytes; never re-serialise parsed JSON to check it.
app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', CALLBACK_SECRET).update(req.body).digest('hex');
const got = String(req.get('X-Callback-Signature') || '');
if (got.length !== expected.length
|| !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(403);
}
const callback = JSON.parse(req.body.toString('utf8'));
// Check ts (within 300 seconds) and that the nonce is new, then book it once on
// orderid + status (status callback) or complaint_ref (complaint callback).
res.sendStatus(200);
});https:// address. Private, loopback and link-local addresses are refused. Callbacks are informational: the status endpoint is always authoritative, so an integration that only polls is still correct.Complaint callbacks
When a complaint you raised is cleared, two things happen:
- The transaction's status is sent to your callback URL again, in the usual status-callback format —
SUCCESSif the transaction stands,FAILEDif it was failed and refunded. This happens even if you already received that status earlier. - If you have set a complaint callback URL (client portal → API access), the complaint's outcome is sent there.
GET https://your-server.example/complaint-callback
?v=2
&event=complaint
&complaint_ref=CP261001103000A1B2C3D4E5F6
&orderid=R2610011030…
&urid=ABC123
&status=REFUNDED
&refund_amount=100.0000
&resolved_at=2026-10-01 12:05:41
&ts=1759300541
&nonce=9c2e…
&hash=<hmac>| Field | Meaning |
|---|---|
event | Always complaint. A status callback never carries event, so one URL can take both if you prefer. |
status | RESOLVED — checked; the transaction stands and no money moved. REFUNDED — the transaction failed and the money is back in your wallet. |
refund_amount | The amount returned to your wallet for REFUNDED; 0.0000 for RESOLVED. |
reason | Version 3 only: one sentence saying what was decided, for your own records and your customer. |
resolved_at | When the complaint was cleared, India time. |
complaint_ref, orderid, urid | The complaint, our order id and your own reference. |
Verify it exactly like a status callback: the hash covers every parameter except itself (see Status callbacks). Delivery is retried the same way until you answer 2xx, so book each complaint_ref once.
OPEN and no complaint callback is sent for it. GET /api/corp/v1/complaints/{ref} is always authoritative. You can send yourself a test complaint callback from the client portal; it carries test=1 inside the signed message and TEST- references — never refund or credit anyone against it.