Zoflo Client Payments API

Client endpoints for authentication, wallet balance, payout creation, and transaction tracking.

Base URL

https://www.zoflo.io/ZofloAPI
All requests must be made over HTTPS. Protected endpoints require Authorization: Bearer <accessToken>.
Examples use placeholders: {{base_url}}, {{access_token}}, {{refresh_token}}, {{txnId}}.

Authentication

Generate access tokens using ClientId and ClientSecret, then call payout endpoints with Bearer JWT.

Authorization Header

Header
Authorization: Bearer {{access_token}}

Generate Access Token

Generate tokens using ClientId + ClientSecret (recommended) or Email + Password.

POST
/token

Request

curl --location '{{base_url}}/token' \
  --header 'Content-Type: application/json' \
  --data '{
    "clientId": "{{client_id}}",
    "clientSecret": "{{client_secret}}"
  }'
const response = await fetch('{{base_url}}/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    clientId: '{{client_id}}',
    clientSecret: '{{client_secret}}'
  })
});
const data = await response.json();
import requests
response = requests.post('{{base_url}}/token', json={
  'clientId': '{{client_id}}',
  'clientSecret': '{{client_secret}}'
})
data = response.json()
$ch = curl_init('{{base_url}}/token');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
  'clientId' => '{{client_id}}',
  'clientSecret' => '{{client_secret}}'
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "def50200...",
  "expiresIn": 600,
  "tokenType": "Bearer"
}

Refresh Token

Use refresh token to get a new access token.

POST
/refresh

Request

curl --location '{{base_url}}/refresh' \
  --header 'Content-Type: application/json' \
  --data '{
    "refreshToken": "{{refresh_token}}"
  }'
const response = await fetch('{{base_url}}/refresh', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ refreshToken: '{{refresh_token}}' })
});
const data = await response.json();
import requests
response = requests.post('{{base_url}}/refresh', json={'refreshToken': '{{refresh_token}}'})
data = response.json()
$ch = curl_init('{{base_url}}/refresh');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['refreshToken' => '{{refresh_token}}']));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "accessToken": "...new...",
  "refreshToken": "...new...",
  "expiresIn": 600,
  "tokenType": "Bearer"
}

Get Wallet Balance

Retrieve current available wallet balance.

GET
/Payout/getWalletBalance

Request

curl --location '{{base_url}}/Payout/getWalletBalance' \
  --header 'Authorization: Bearer {{access_token}}'
const response = await fetch('{{base_url}}/Payout/getWalletBalance', {
  headers: { 'Authorization': 'Bearer {{access_token}}' }
});
const data = await response.json();
import requests
response = requests.get('{{base_url}}/Payout/getWalletBalance',
  headers={'Authorization': 'Bearer {{access_token}}'}
)
data = response.json()
$ch = curl_init('{{base_url}}/Payout/getWalletBalance');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer {{access_token}}']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "statusCode": 200,
  "status": true,
  "message": "Balance fetched successfully",
  "availableBalance": 12345.67
}

Initiate Payout

Create a payout transaction from client wallet to beneficiary bank account.

POST
/Payout/CreateTransaction

Request Body

FieldTypeRequiredDescription
beneficiaryNamestringYesOnly letters and spaces; 3–200 chars
beneficiaryAccountNumberstringYes8–20 chars
ifscstringYes^[A-Z]{4}0[A-Z0-9]{6}$
amountnumberYes5000–10000000
clientTransactionRefNostringNoOptional idempotency reference
narrationstringNoPayment narration shown in bank records. Max 30 chars, letters/numbers/spaces only. Defaults to payout if not provided.

Request

curl --location '{{base_url}}/Payout/CreateTransaction' \
  --header 'Authorization: Bearer {{access_token}}' \
  --header 'Content-Type: application/json' \
  --data '{
    "beneficiaryName": "John Doe",
    "beneficiaryAccountNumber": "123456789012",
    "ifsc": "ABCD0123456",
    "amount": 5000,
    "clientTransactionRefNo": "INV-1001",
    "narration": "Salary April"
  }'
const response = await fetch('{{base_url}}/Payout/CreateTransaction', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer {{access_token}}',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    beneficiaryName: 'John Doe',
    beneficiaryAccountNumber: '123456789012',
    ifsc: 'ABCD0123456',
    amount: 5000,
    clientTransactionRefNo: 'INV-1001',
    narration: 'Salary April'
  })
});
const data = await response.json();
import requests

response = requests.post(
  '{{base_url}}/Payout/CreateTransaction',
  headers={'Authorization': 'Bearer {{access_token}}'},
  json={
    'beneficiaryName': 'John Doe',
    'beneficiaryAccountNumber': '123456789012',
    'ifsc': 'ABCD0123456',
    'amount': 5000,
    'clientTransactionRefNo': 'INV-1001',
    'narration': 'Salary April'
  }
)
data = response.json()
$ch = curl_init('{{base_url}}/Payout/CreateTransaction');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
  'beneficiaryName' => 'John Doe',
  'beneficiaryAccountNumber' => '123456789012',
  'ifsc' => 'ABCD0123456',
  'amount' => 5000,
  'clientTransactionRefNo' => 'INV-1001',
  'narration' => 'Salary April'
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  'Authorization: Bearer {{access_token}}',
  'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response (success)

{
  "statusCode": 201,
  "responseCode": 1,
  "status": true,
  "message": "Transaction created successfully",
  "transactionNumber": "TX26010300001",
  "transaction": {
    "transactionNumber": "TX26010300001",
    "clientTransactionRefNo": "INV-1001",
    "beneficiaryName": "John Doe",
    "beneficiaryAccountNumber": "123456789012",
    "ifsc": "ABCD0123456",
    "amount": 5000,
    "status": "Pending",
    "utr": null,
    "remarks": null,
    "narration": "Salary April",
    "createdAt": "2026-01-09T17:21:00",
    "updatedAt": null
  }
}

Response (idempotent duplicate reference)

{
  "statusCode": 200,
  "responseCode": 1,
  "status": true,
  "message": "Transaction already exists (idempotent)",
  "transactionNumber": "TX26010300001",
  "transaction": {
    "transactionNumber": "TX26010300001",
    "clientTransactionRefNo": "INV-1001",
    "beneficiaryName": "John Doe",
    "beneficiaryAccountNumber": "123456789012",
    "ifsc": "ABCD0123456",
    "amount": 5000,
    "status": "Pending"
  }
}

Create Bulk Payouts

Submit multiple payout transactions in a single API call (1–500 items).

POST
/Payout/CreateBulkTransactions

Request

curl --location '{{base_url}}/Payout/CreateBulkTransactions' \
  --header 'Authorization: Bearer {{access_token}}' \
  --header 'Content-Type: application/json' \
  --data '{
    "transactions": [
      {
        "beneficiaryName": "A Person",
        "beneficiaryAccountNumber": "1234567890",
        "ifsc": "ABCD0123456",
        "amount": 5000,
        "clientTransactionRefNo": "BULK-1",
        "narration": "Salary April"
      },
      {
        "beneficiaryName": "B Person",
        "beneficiaryAccountNumber": "9876543210",
        "ifsc": "WXYZ0123456",
        "amount": 7500,
        "clientTransactionRefNo": "BULK-2",
        "narration": "Bonus"
      }
    ]
  }'
const response = await fetch('{{base_url}}/Payout/CreateBulkTransactions', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer {{access_token}}',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    transactions: [
      { beneficiaryName:'A Person', beneficiaryAccountNumber:'1234567890', ifsc:'ABCD0123456', amount:5000, clientTransactionRefNo:'BULK-1', narration:'Salary April' },
      { beneficiaryName:'B Person', beneficiaryAccountNumber:'9876543210', ifsc:'WXYZ0123456', amount:7500, clientTransactionRefNo:'BULK-2', narration:'Bonus' }
    ]
  })
});
const data = await response.json();
import requests

response = requests.post(
  '{{base_url}}/Payout/CreateBulkTransactions',
  headers={'Authorization': 'Bearer {{access_token}}'},
  json={
    'transactions': [
      {'beneficiaryName':'A Person','beneficiaryAccountNumber':'1234567890','ifsc':'ABCD0123456','amount':5000,'clientTransactionRefNo':'BULK-1','narration':'Salary April'},
      {'beneficiaryName':'B Person','beneficiaryAccountNumber':'9876543210','ifsc':'WXYZ0123456','amount':7500,'clientTransactionRefNo':'BULK-2','narration':'Bonus'}
    ]
  }
)
data = response.json()
$ch = curl_init('{{base_url}}/Payout/CreateBulkTransactions');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
  'transactions' => [
    ['beneficiaryName'=>'A Person','beneficiaryAccountNumber'=>'1234567890','ifsc'=>'ABCD0123456','amount'=>5000,'clientTransactionRefNo'=>'BULK-1','narration'=>'Salary April'],
    ['beneficiaryName'=>'B Person','beneficiaryAccountNumber'=>'9876543210','ifsc'=>'WXYZ0123456','amount'=>7500,'clientTransactionRefNo'=>'BULK-2','narration'=>'Bonus']
  ]
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  'Authorization: Bearer {{access_token}}',
  'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "statusCode": 200,
  "status": true,
  "message": "Success: 2, Failed: 0",
  "successCount": 2,
  "failedCount": 0,
  "totalProcessed": 2,
  "successTransactions": [
    { "transactionNumber": "TX26010300001", "clientTransactionRefNo": "BULK-1", "beneficiaryName": "A Person", "amount": 5000 }
  ],
  "failedTransactions": []
}

Get Transactions (Client Filters)

Retrieve your transactions with pagination, search, and a wide set of column-level filters. All parameters are optional β€” combine any number of them to narrow down results.

GET
/Payout/GetClientTransactionsWithFilters

Query Parameters

ParameterTypeDefaultDescription
pageinteger1Page number for pagination
pageSizeinteger50Number of records per page
statusstringβ€” Filter by transaction status. Values: Pending, Success, Failed, Cancelled
searchstringβ€”Global keyword search across transaction number, beneficiary name, account number, client ref no, and UTR
transactionNumberstringβ€”Filter by transaction number (partial match)
beneficiaryNamestringβ€”Filter by beneficiary name (partial match)
accountNumberstringβ€”Filter by beneficiary account number (partial match)
ifscstringβ€”Filter by IFSC code (partial match)
utrstringβ€”Filter by UTR number (partial match)
remarksstringβ€”Filter by remarks (partial match)
batchStatusstringβ€” Filter by batch assignment. Values: batched, unbatched
amountdecimalβ€”Exact amount match. Takes priority over minAmount/maxAmount
minAmountdecimalβ€”Minimum transaction amount (used only if amount is not set)
maxAmountdecimalβ€”Maximum transaction amount (used only if amount is not set)
datestringβ€”Exact date filter β€” returns all transactions from that calendar day. Format: YYYY-MM-DD. Takes priority over date range.
startDatestringβ€”Start of date range. Format: YYYY-MM-DD
endDatestringβ€”End of date range (inclusive). Format: YYYY-MM-DD
Filter priority rules: When amount is provided, minAmount and maxAmount are ignored. When date is provided, it takes precedence over startDate/endDate.

Request

curl --location '{{base_url}}/Payout/GetClientTransactionsWithFilters?page=1&pageSize=50&status=Success&startDate=2026-01-01&endDate=2026-01-31' \
  --header 'Authorization: Bearer {{access_token}}'
const url = new URL('{{base_url}}/Payout/GetClientTransactionsWithFilters');
url.searchParams.set('page', '1');
url.searchParams.set('pageSize', '50');
url.searchParams.set('status', 'Success');
url.searchParams.set('startDate', '2026-01-01');
url.searchParams.set('endDate', '2026-01-31');

// Optional: add more filters as needed
// url.searchParams.set('beneficiaryName', 'John');
// url.searchParams.set('minAmount', '5000');
// url.searchParams.set('maxAmount', '100000');
// url.searchParams.set('utr', 'UTR123');
// url.searchParams.set('batchStatus', 'unbatched');

const response = await fetch(url, {
  headers: { 'Authorization': 'Bearer {{access_token}}' }
});
const data = await response.json();
import requests

response = requests.get(
  '{{base_url}}/Payout/GetClientTransactionsWithFilters',
  headers={'Authorization': 'Bearer {{access_token}}'},
  params={
    'page': 1,
    'pageSize': 50,
    'status': 'Success',
    'startDate': '2026-01-01',
    'endDate': '2026-01-31',
    # Optional filters:
    # 'beneficiaryName': 'John',
    # 'minAmount': 5000,
    # 'maxAmount': 100000,
    # 'utr': 'UTR123',
    # 'batchStatus': 'unbatched',
  }
)
data = response.json()
$params = http_build_query([
  'page'      => 1,
  'pageSize'  => 50,
  'status'    => 'Success',
  'startDate' => '2026-01-01',
  'endDate'   => '2026-01-31',
  // Optional filters:
  // 'beneficiaryName' => 'John',
  // 'minAmount' => 5000,
  // 'maxAmount' => 100000,
  // 'utr' => 'UTR123',
  // 'batchStatus' => 'unbatched',
]);
$ch = curl_init("{{base_url}}/Payout/GetClientTransactionsWithFilters?{$params}");
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer {{access_token}}']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "status": true,
  "message": "Transactions fetched successfully",
  "data": [
    {
      "transactionNumber": "TX26010300001",
      "clientTransactionRefNo": "INV-1001",
      "beneficiaryName": "John Doe",
      "beneficiaryAccountNumber": "123456789012",
      "ifsc": "ABCD0123456",
      "amount": 5000,
      "status": "Success",
      "utr": "UTR123456789",
      "remarks": null,
      "narration": "Salary April",
      "createdAt": "2026-01-09T17:21:00",
      "updatedAt": "2026-01-09T17:25:00"
    }
  ],
  "pagination": {
    "currentPage": 1,
    "pageSize": 50,
    "totalCount": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "totalUnbatchedCount": 1
}

Get Transaction by ID

Retrieve complete details using transaction number (txnId).

GET
/Payout/GetTransactionByTxnId/{txnId}

Request

curl --location '{{base_url}}/Payout/GetTransactionByTxnId/TX26010300001' \
  --header 'Authorization: Bearer {{access_token}}'
const response = await fetch('{{base_url}}/Payout/GetTransactionByTxnId/TX26010300001', {
  headers: { 'Authorization': 'Bearer {{access_token}}' }
});
const data = await response.json();
import requests
response = requests.get(
  '{{base_url}}/Payout/GetTransactionByTxnId/TX26010300001',
  headers={'Authorization': 'Bearer {{access_token}}'}
)
data = response.json()
$ch = curl_init('{{base_url}}/Payout/GetTransactionByTxnId/TX26010300001');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer {{access_token}}']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response

{
  "status": true,
  "message": "Transaction fetched successfully",
  "transaction": {
    "transactionNumber": "TX26010300001",
    "clientTransactionRefNo": "INV-1001",
    "beneficiaryName": "John Doe",
    "beneficiaryAccountNumber": "123456789012",
    "ifsc": "ABCD0123456",
    "amount": 5000,
    "status": "Success",
    "utr": "UTR123456789",
    "remarks": null,
    "narration": "Salary April",
    "createdAt": "2026-01-09T17:21:00",
    "updatedAt": "2026-01-09T17:25:00"
  }
}

Get Transaction by Client Reference

Retrieve transaction details using your own reference number (clientTransactionRefNo) that was supplied when creating the transaction.

GET
/Payout/GetTransactionByClientRef/{clientRefNo}

Path Parameter

ParameterTypeRequiredDescription
clientRefNostringYesThe client-supplied reference number used when creating the transaction (e.g. INV-1001)

Request

curl --location '{{base_url}}/Payout/GetTransactionByClientRef/INV-1001' \
  --header 'Authorization: Bearer {{access_token}}'
const clientRefNo = 'INV-1001';
const response = await fetch(`{{base_url}}/Payout/GetTransactionByClientRef/${clientRefNo}`, {
  headers: { 'Authorization': 'Bearer {{access_token}}' }
});
const data = await response.json();
import requests

client_ref_no = 'INV-1001'
response = requests.get(
  f'{{base_url}}/Payout/GetTransactionByClientRef/{client_ref_no}',
  headers={'Authorization': 'Bearer {{access_token}}'}
)
data = response.json()
$clientRefNo = 'INV-1001';
$ch = curl_init("{{base_url}}/Payout/GetTransactionByClientRef/{$clientRefNo}");
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer {{access_token}}']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

Response (success)

{
  "status": true,
  "message": "Transaction fetched successfully",
  "transaction": {
    "transactionNumber": "TX26010300001",
    "clientTransactionRefNo": "INV-1001",
    "beneficiaryName": "John Doe",
    "beneficiaryAccountNumber": "123456789012",
    "ifsc": "ABCD0123456",
    "amount": 5000,
    "status": "Success",
    "utr": "UTR123456789",
    "remarks": null,
    "narration": "Salary April",
    "createdAt": "2026-01-09T17:21:00",
    "updatedAt": "2026-01-09T17:25:00"
  }
}

Response (not found)

{
  "status": false,
  "message": "Transaction not found"
}
This endpoint only returns transactions belonging to your account. Use this to look up a specific transaction using the same reference number you passed in clientTransactionRefNo during creation β€” useful for idempotency checks.

Webhooks

Receive real-time HTTP POST notifications to your configured webhook URL whenever a transaction status is updated by the admin.

Webhook Payload

The request body is a flat JSON object sent directly to your endpoint:

{
  "transactionNumber": "TXN26021700002",
  "clientTransactionRefNo": null,
  "status": "Success",
  "previousStatus": "Pending",
  "utr": "CMS260217147206",
  "amount": 6000,
  "beneficiaryName": "John Doe",
  "beneficiaryAccountNumber": "2345678866",
  "ifsc": "SBIN0001234",
  "remarks": "Processed",
  "updatedAt": "2026-02-17T14:49:55.6725005+05:30",
  "timestamp": 1771319995
}

Payload Fields

FieldTypeDescription
transactionNumberstringUnique system-generated transaction ID
clientTransactionRefNostring | nullYour reference number supplied at creation. null if not provided.
statusstringNew (current) status β€” Success, Failed, or Cancelled
previousStatusstringStatus before this update (e.g. Pending)
utrstring | nullUTR reference number. Populated when status is Success.
amountnumberTransaction amount in INR
beneficiaryNamestringName of the beneficiary
beneficiaryAccountNumberstringBeneficiary bank account number
ifscstringBeneficiary bank IFSC code
remarksstring | nullAdmin remarks, if any
updatedAtstring (ISO 8601)Timestamp of the status change (IST, with offset)
timestampintegerUnix epoch seconds (UTC) β€” use this to detect and reject replayed webhooks

Security Headers

Every webhook request includes two custom headers. HTTP delivers them in lowercase β€” use the exact names below:

Header (exact, lowercase)Example valueDescription
x-webhook-signature55d01f91372a768f6d0c89f5c75871...HMAC-SHA256 lowercase hex digest of the raw JSON body, keyed with your webhook secret
x-webhook-timestamp1771319995Unix epoch seconds (UTC) at the moment the webhook was dispatched
How the signature is generated (server side):
HMAC-SHA256(key=webhookSecret, message=rawJsonBody) β†’ lowercase hex string.
The JSON body uses camelCase keys, no whitespace, and UTF-8 encoding β€” exactly as received in the request body. To verify, recompute the same HMAC over the raw bytes of the request body before any JSON parsing.

Verifying the Signature

const crypto = require('crypto');

// Call with the raw Buffer body (do NOT parse JSON first)
function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)           // raw UTF-8 Buffer or string
    .digest('hex');            // lowercase hex β€” matches server output
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature,  'hex')
  );
}

// Express example β€” use express.raw() so req.body is a Buffer
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig       = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];

  // Reject stale webhooks (older than 5 minutes)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('Stale webhook');
  }

  if (!sig || !verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(req.body);
  // payload.status     β†’ "Success" | "Failed" | "Cancelled"
  // payload.utr        β†’ UTR string (null if not Success)
  // payload.timestamp  β†’ Unix epoch β€” use for your own dedup logic
  console.log('Status:', payload.status, '| UTR:', payload.utr);
  res.status(200).send('OK');
});
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = 'your-webhook-secret'

@app.route('/webhook', methods=['POST'])
def webhook():
    # Flask normalises headers β€” both forms work, but lowercase matches actual delivery
    sig       = request.headers.get('x-webhook-signature', '')
    timestamp = request.headers.get('x-webhook-timestamp', '0')

    # Reject stale webhooks (older than 5 minutes)
    if abs(time.time() - int(timestamp)) > 300:
        abort(400, 'Stale webhook')

    raw_body = request.get_data()          # raw bytes β€” do NOT decode/parse first
    expected = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()                          # lowercase hex β€” matches server output

    if not hmac.compare_digest(expected, sig):
        abort(401, 'Invalid signature')

    payload = request.get_json()
    # payload['status']    β†’ "Success" | "Failed" | "Cancelled"
    # payload['utr']       β†’ UTR string or None
    # payload['timestamp'] β†’ Unix epoch int
    print('Status:', payload['status'], '| UTR:', payload.get('utr'))
    return 'OK', 200
<?php
$secret    = 'your-webhook-secret';
$sig       = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0;

// Reject stale webhooks (older than 5 minutes)
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(400);
    exit('Stale webhook');
}

$rawBody  = file_get_contents('php://input');
$expected = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($rawBody, true);
error_log('Status: ' . $payload['status'] . ' UTR: ' . ($payload['utr'] ?? 'N/A'));
http_response_code(200);
echo 'OK';
Responding to webhooks: Your endpoint must return an HTTP 2xx response. Webhooks are fire-and-forget β€” no automatic retries are performed. Configure your webhook URL and secret in the Client Dashboard.
Default secret: If no webhook secret is configured for your account, the signature is computed using the string default-secret. Always set a strong, unique secret in your dashboard before going to production.

Replay attack prevention: Always validate the X-Webhook-Timestamp header and reject requests older than 5 minutes.

Transaction Statuses

StatusDescription
PendingTransaction created and queued for processing
ProcessingTransaction is being processed by the payment gateway
SuccessTransaction completed successfully
FailedTransaction failed (insufficient balance, invalid account, etc.)
CancelledTransaction was reversed/refunded

Error Handling

API uses standard HTTP status codes and returns error details in JSON format.

Error Response Format

{
"statusCode": 400,
"responseCode": 0,
"status": false,
"message": "Invalid beneficiary name format. Only letters and spaces are allowed."
}

Common HTTP Status Codes

CodeDescription
200Success
201Created
400Bad Request - Validation error
401Unauthorized - Invalid or expired token
403Forbidden - Insufficient permissions
404Not Found
429Too Many Requests - Rate limit exceeded
500Internal Server Error

Validation Rules

Beneficiary Name

Account Number

IFSC Code

Amount

Narration

Security Best Practices