← Back home

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

eventFired when
payin.approvedA collection was verified and the wallet credited.
payin.rejectedA collection was rejected, or the payment window expired unpaid.
payout.approvedA payout was approved and sent to the bank (UTR available).
payout.rejectedA 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

HeaderMeaning
X-SignatureLowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook_secret.
X-TimestampUnix seconds when we sent it. Reject anything older than ~5 minutes.
Content-Typeapplication/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.