API documentation
PayRuh exposes a simple, body-authenticated payout API plus dynamic UPI collections.
Every request is processed securely before it settles. Your
account_id and
secret_key live on your
dashboard under Developers.
Base URL
https://payruh.com
1 · Initiate a payout
POST /api_payout_bank
Queues a bank/UPI payout for processing. Idempotent on request_id. Provide account_number + ifsc for a bank transfer, or upi_id for UPI.
curl -X POST https://payruh.com/api_payout_bank \
-H "Content-Type: application/json" \
-d '{
"initiate_payout": {
"account_id": "YOUR_ACCOUNT_ID",
"secret_key": "YOUR_SECRET_KEY",
"request_id": "ORDER-1001",
"payout_amount": "500.00",
"beneficiary_name": "Ravi Kumar",
"account_number": "50100234567890",
"ifsc": "HDFC0001234",
"payout_mode": "IMPS"
}
}'
Response returns the request status (Pending until processed), and — once complete — the bank utr.
2 · Fetch payout status
POST /api_payout_status
curl -X POST https://payruh.com/api_payout_status \
-H "Content-Type: application/json" \
-d '{
"fetch_payout": {
"account_id": "YOUR_ACCOUNT_ID",
"secret_key": "YOUR_SECRET_KEY",
"request_id": "ORDER-1001"
}
}'
Status maps to Success (approved, with UTR), Pending, or Failed.
Read the UTR from data[0].utr, not data.utr. This endpoint returns data as an array, because one request_id can carry more than one attempt. /api_payin_status returns data as a single object. Reading data.utr here silently yields nothing on every call.
Use a new request_id for every transaction
The request_id is your idempotency key. Sending the same id with the same details is treated as a retry: we return the original transaction and never charge twice, so it is safe to resend after a timeout.
Sending the same id with different details is rejected with 409, naming the fields that disagree. Previously this returned the earlier transaction, which made a new request look as though its beneficiary and account number had been replaced by an older one.
3 · Collections (pay-ins)
Create a pay-in and we mint a unique UPI QR encoding the exact amount and reference. Your customer scans, pays in any UPI app, and enters the UTR on a hosted page. The payment is verified before the wallet is credited.
curl -X POST https://payruh.com/api_payin \
-H "Content-Type: application/json" \
-d '{
"initiate_payin": {
"account_id": "YOUR_ACCOUNT_ID",
"secret_key": "YOUR_SECRET_KEY",
"request_id": "ORDER-2001",
"amount": "500.00"
}
}'
The response carries checkout_url and qr_url. Send your customer to the checkout page; poll the status endpoint, or wait for the webhook.
curl -X POST https://payruh.com/api_payin_status \
-H "Content-Type: application/json" \
-d '{
"fetch_payin": {
"account_id": "YOUR_ACCOUNT_ID",
"secret_key": "YOUR_SECRET_KEY",
"request_id": "ORDER-2001"
}
}'
4 · Wallet balance
POST /api_balance
curl -X POST https://payruh.com/api_balance \
-H "Content-Type: application/json" \
-d '{
"fetch_balance": {
"account_id": "YOUR_ACCOUNT_ID",
"secret_key": "YOUR_SECRET_KEY"
}
}'
Returns balance (the full wallet), available_balance and on_hold, all in rupees. Check payouts against available_balance, not balance — an administrative hold is included in the first and excluded from the second, so a payout sized against balance can still be refused for insufficient funds.
5 · Webhooks
When a transaction is approved or rejected we POST a small JSON body to the webhook URL set on your dashboard. Set that URL under Developers → Webhooks; the matching webhook_secret is on the same page.
How do I match a webhook to my order?
Use client_reference. It is the exact request_id you sent when you created the pay-in or payout — we store it and hand it straight back. If you sent "request_id": "ORDER-1001", the webhook carries "client_reference": "ORDER-1001".
Events
| event | Fired when |
|---|---|
| payin.approved | A collection was verified and the wallet credited. |
| payin.rejected | A collection was rejected, or the payment window expired unpaid. |
| payout.approved | A payout was approved and sent to the bank (UTR available). |
| payout.rejected | A payout was rejected; the reserved amount is refunded to the wallet. |
Request we send
POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
X-Signature: 42dc7ac03893b09cde04bea2438f75c5775994bd866e175cfc0c8b3e66044ad0
X-Timestamp: 1785900958
User-Agent: Sterling-Webhooks/1.0
{"event":"payin.approved","internal_ref":"pi_01k5wq8x7m3n2p9r4t6v8y0z1a","client_reference":"ORDER-1001","amount":500,"utr":"322145098765","timestamp":"2026-08-04T02:35:58+00:00"}
This example is real. That X-Signature is the genuine HMAC of the body above, keyed with the demo secret whsec_demo_3f9a1c7b5e2d48a6. Paste both into your verifier and it will pass. Note the body is a single line with no spaces, and amount is a plain number, so ₹500 is 500 and ₹512.50 is 512.5. Reformatting the body changes the bytes and the signature will no longer match.
Payload fields
| Field | Type | What it is |
|---|---|---|
| event | string | One of the four events above. |
| client_reference | string|null | Your order number. The exact request_id you sent us. null only if you created the transaction without one. Use this to reconcile. |
| internal_ref | string | Our id for the transaction — pi_… for pay-ins, po_… for payouts. Same value used in the checkout/QR URLs. |
| amount | number | Transaction amount in rupees (major units), sent as a plain JSON number: 500, not 500.00. This is the gross amount, before any fee. |
| utr | string|null | The bank reference. For pay-ins it is what the payer submitted on the checkout page; for payouts it is the reference from our banking portal. Always present as a key, and null when there is no reference yet, which is normal on *.rejected events and on an approval not yet stamped. If it arrives null on an approval, re-read it later from the status API. |
| timestamp | string | ISO-8601 time the event fired, e.g. 2026-08-04T02:35:58+00:00. |
Note on ids. The webhook sends internal_ref as pi_01k5wq8x…, while /api_payin_status returns the same transaction as transaction_id = TXN_01k5wq8x… — the prefix differs, the rest matches. To avoid dealing with either, reconcile on client_reference.
Headers
| Header | Meaning |
|---|---|
| X-Signature | Lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook_secret. |
| X-Timestamp | Unix seconds when we sent it. Reject anything older than ~5 minutes. |
| Content-Type | application/json |
Verify the signature
Sign the raw body exactly as received — parsing and re-encoding the JSON changes the bytes and the signature will not match.
PHP
<?php
// 1. Read the RAW body — do not re-encode it, the signature covers the exact bytes.
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '0';
$secret = 'YOUR_WEBHOOK_SECRET'; // Dashboard -> Developers
// 2. Recompute and compare in constant time.
$expected = hash_hmac('sha256', $raw, $secret);
if (! hash_equals($expected, $sig)) {
http_response_code(400);
exit('bad signature');
}
// 3. Reject anything older than 5 minutes (replay protection).
if (abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit('stale');
}
// 4. Trusted. Match it to YOUR order using client_reference.
$data = json_decode($raw, true);
$myOrderId = $data['client_reference']; // the request_id you sent us
$amount = $data['amount']; // e.g. 500.00
$event = $data['event']; // payin.approved | payin.rejected | ...
if ($event === 'payin.approved') {
// mark order $myOrderId as paid, for $amount
}
http_response_code(200); // ALWAYS 2xx once handled
echo 'ok';
Node.js (Express)
const crypto = require('crypto');
// Mount with the RAW body, e.g. express.raw({ type: 'application/json' })
app.post('/webhooks/payments', (req, res) => {
const raw = req.body; // Buffer, untouched
const sig = req.get('X-Signature') || '';
const ts = parseInt(req.get('X-Timestamp') || '0', 10);
const secret = process.env.WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
const ok = expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.status(400).send('bad signature');
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).send('stale');
const data = JSON.parse(raw.toString());
// data.client_reference === the request_id you sent us
// data.amount, data.event, data.internal_ref, data.timestamp
res.status(200).send('ok'); // ALWAYS 2xx once handled
});
Responding & retries
- • Reply with any 2xx status to acknowledge. Anything else counts as a failure.
- • Failures are retried up to 5 times, backing off
1m → 5m → 15m → 1h → 6h. - • Reply quickly (within 10s) and do slow work afterwards, or the delivery times out and retries.
- • The same event can arrive more than once — make your handler idempotent, keyed on
client_reference+event. - • Your endpoint must be a public HTTPS URL. Every attempt is logged under Developers → Webhook delivery log, where you can inspect the response and retry by hand.
Ready to integrate? Sign in to grab your keys, or request API access.