Corporate APIv1

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.

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

PHP
$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.
Do not verify a hand-written list of field names. Every parameter is signed, including 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.

  1. 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.
  2. Make your receiver accept version 3 (below).
  3. Press Switch to version 3. Every callback from then on is version 3. To go back to version 2, ask your account manager.
Request
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:

PHP
$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).
Node.js
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);
});
Replacing the callback secret takes effect at once. A callback your receiver refuses while you change it is delivered again with the usual back-off.
Your callback URL must be a publicly resolvable 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:

  1. The transaction's status is sent to your callback URL again, in the usual status-callback format — SUCCESS if the transaction stands, FAILED if it was failed and refunded. This happens even if you already received that status earlier.
  2. If you have set a complaint callback URL (client portal → API access), the complaint's outcome is sent there.
Request
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>
FieldMeaning
eventAlways complaint. A status callback never carries event, so one URL can take both if you prefer.
statusRESOLVED — checked; the transaction stands and no money moved. REFUNDED — the transaction failed and the money is back in your wallet.
refund_amountThe amount returned to your wallet for REFUNDED; 0.0000 for RESOLVED.
reasonVersion 3 only: one sentence saying what was decided, for your own records and your customer.
resolved_atWhen the complaint was cleared, India time.
complaint_ref, orderid, uridThe 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.

A complaint decided against the transaction whose refund could not be posted automatically is passed to our team: it reads 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.
ECOBYTE Corporate APIv1