💸 PayRails Merchant Payout Integration Guide
Live API console — authenticate, test, and explore the PayRails Payout API in real time.
| Environment | Live Demo |
| Base URL | https://api.payrails.in/api/v1 |
| Authentication | x-api-key |
| Demo Amount | ₹100 (Fixed — server-side constant) |
| Payment Mode | IMPS |
| Beneficiary | Fixed test beneficiary (hardcoded on server) |
🚀 Merchant Onboarding Journey
The complete path from zero to a production-ready PayRails integration — including webhook authentication, which is documented in full further down this page.
Read the Authentication section above and note your merchant environment (sandbox / staging / production).
#section-auth →
Create an
x-api-key for your environment from Developer Settings.#section-api-keys →
Tell PayRails where to POST PayIn and Payout callbacks.
#section-callback-url →
A signing secret is created automatically the first time you configure a callback URL. Rotate or revoke it any time.
#section-webhook-auth →
Point your callback URL at the dedicated PAYOUT Callback Lab endpoint to see a real inbound delivery, tagged with its Callback Type.
/callbacks/payout.php →
Review the received headers, raw body, and parsed JSON in the Callback Inspector.
#section-callback-lab →
Confirm the HMAC signature using the same Callback Lab — or your own verifier from the HMAC guide below.
#section-hmac →
Build your own receiver using the same verification formula, then point your real callback URL at it instead of the Callback Lab.
#section-hmac →
🔑 API Keys
API keys are managed from your PayRails merchant Dashboard under Developer Settings → API Keys. This section mirrors exactly what you will see there so you know what to expect before you go looking for it.
Generating a key
Click Generate API Key, choose an environment (sandbox, staging,
or prod) and an optional label, then click Generate. The dashboard then shows:
Click Copy or Download, then I have saved this key to dismiss the dialog. The plaintext key is never displayable again after this point — if you lose it, generate a new one and revoke the old one.
Revoking a key
Click Revoke next to any active key. You will be asked to confirm:
Once generated, use the key exactly as shown in the Authentication
section above — in the x-api-key header on every request.
🔐 Authentication
x-api-key header. No OAuth, no login session required.| Base URL | https://api.payrails.in/api/v1 |
| Auth method | x-api-key request header |
| Content type | application/json |
| Protocol | HTTPS only |
Content-Type: application/json Accept: application/json x-api-key: YOUR_API_KEY X-Idempotency-Key: <uuid> ← recommended
X-Idempotency-Key per payout request.
If you submit the same key within 48 hours and a payout was already created, the original response is returned immediately
— no duplicate payout is created. Use a UUID or timestamp-based key generated server-side.
🔄 Integration Flow
Follow this sequence for every payout.
GET /payouts/wallet • verify available_balance ≥ payout amount
POST /payouts • receive platform_payout_id in response
GET /payouts/{platform_payout_id} • repeat every few seconds
SUCCESS • FAILED • CANCELLED • EXPIRED
⚡ Quick Start
Everything you need to send your first payout in under 5 minutes.
x-api-key from PayRails. Include it on every request.GET /payouts/walletVerify
available_balance ≥ payout amount.POST /payoutsSupply beneficiary details, amount, and mode.
platform_payout_id returned in the response. You will need it for status polling.GET /payouts/{platform_payout_id}Repeat every 3–5 s until terminal state.
FAILED — permanently terminal. Log the
failure_reason.EXPIRED — contact PayRails support.
🛡️ Submission Guarantees & Status Retrieval
Understanding what happens after you submit a payout request — especially when you do not receive a response.
Continue with callbacks or Status API
Query Status API with merchant_ref
When the Create Payout API returns HTTP 201, the payout has already been accepted by PayRails.
The response includes:
platform_payout_idmerchant_refstatus
From this point, determine the final payout outcome using either:
- Your configured Callback URL
- The Status API
POST /payouts response returns the initial status only.
Never use it alone to determine final settlement.
Timeout or Network Error
Sometimes your server may not receive the HTTP response due to:
- Internet interruption
- Reverse proxy timeout
- Gateway timeout
- Client disconnect
- Network failure
This does not automatically mean the payout failed.
Query the Status API using your merchant_ref:
GET /payouts/status?merchant_ref=ORDER12345
| HTTP 200 | Payout already exists. Continue tracking with callbacks or Status API. |
| HTTP 404 | Payout was never accepted. Safe to submit a new request using the same merchant_ref. |
Merchant reference numbers (merchant_ref) are unique per merchant.
The same merchant_ref cannot be reused once a payout has been accepted.
Attempting to create another payout with an existing merchant_ref returns:
HTTP 409 DUPLICATE_MERCHANT_REFERENCE
This guarantees idempotent merchant-side integrations.
📬 Merchant Callback Behaviour
PayRails sends callbacks whenever a payout reaches a meaningful lifecycle event. Callbacks contain the latest payout status together with reconciliation information available at that moment.
| Field | Description |
|---|---|
| platform_request_id | Platform-generated idempotency reference |
| platform_payout_id | Unique payout identifier (PPX…) |
| merchant_ref | Your original reference echoed back |
| status | Current payout status at time of callback |
| amount | Payout amount in INR |
| currency | Currency (INR) |
| fee_amount | Platform fee charged |
| mode | Transfer mode (IMPS / NEFT / RTGS / UPI) |
| provider_code | Payment provider used |
| provider_payout_id | Provider-side transaction reference |
| provider_reference | Provider settlement reference (e.g. UTR) |
| bank_reference_number | RRN / UTR from the bank (when available) |
| provider_completed_at | Timestamp of provider settlement (when available) |
| failure_reason | Human-readable reason (for failed payouts) |
{
"platform_request_id": "PRQ...",
"platform_payout_id": "PPX...",
"merchant_ref": "ORDER12345",
"status": "SUCCESS",
"amount": 1000,
"currency": "INR",
"fee_amount": 5,
"mode": "IMPS",
"provider_code": "bank_provider",
"provider_payout_id": "TXN98765",
"provider_reference": "IMPS/1234567",
"bank_reference_number": "123456789012",
"provider_completed_at": "2026-07-10T14:32:00Z",
"failure_reason": null,
"timestamp": "2026-07-10T14:32:05Z"
}
Some providers do not return a Bank Reference Number at the time of settlement. In those cases
bank_reference_number will be null.
This is provider behaviour — not an API error.
You can retrieve the latest value at any time using the Status API.
bank_reference_number in Payout callbacks, but bank_reference in PayIn
callbacks (see the PayIn Webhook section).
Both represent the same concept (the bank's UTR/RRN reconciliation reference) — the difference is
naming only, not behaviour. This is existing, unchanged API behaviour; it is documented here so you
do not need to guess which field name applies to which callback type.
🔗 Configure Callback URLs
Callback URLs are managed from your PayRails merchant Dashboard under Developer Settings → Webhook Callback. PAYIN and PAYOUT each have their own, independently configured callback URL (selected via a PAYIN / PAYOUT tab in the dashboard).
In the dashboard: select the PAYIN or PAYOUT tab, click Edit callback URL (or Configure if none is set yet), enter your URL, and click Save. If the URL fails validation the dashboard shows the exact rejection reason inline (for example, a malformed URL or a blocked/private address) — fix the URL and save again.
Once saved, the dashboard displays your current callback URL together with its type and the date it was registered, and offers Edit callback URL and Refresh actions.
Testing with the Callback Lab
While integrating, you can temporarily point your callback URLs at this portal's built-in Callback Lab to inspect real deliveries — headers, raw body, parsed JSON, and HMAC verification result — before your own endpoint is ready:
| Type | Callback URL to configure |
|---|---|
| PayIn | https://dummy.payrails.in/callbacks/payin.php |
| Payout | https://dummy.payrails.in/callbacks/payout.php |
Developer Settings walkthrough
https://dummy.payrails.in/callbacks/payin.phphttps://dummy.payrails.in/callbacks/payout.phpAfter saving: perform a test transaction (PayIn or Payout), open this Integration Portal, find the delivery in the Callback Inspector, verify its HMAC signature, compare the payload against the HMAC guide, and then update your own production implementation to match — finally, switch the callback URL over to your real endpoint once it's ready.
For the exact delivery behaviour (HTTP method, retry policy, expected response code, payload shape), see the Webhook section further down this page — that content is unchanged and still applies.
🔐 Webhook Authentication
Managed from your PayRails merchant Dashboard under Developer Settings → Webhook Authentication (PAYIN / PAYOUT tabs, same as Callback URL). This section documents the metadata and actions available there, exactly as they behave in the dashboard.
Metadata shown
| Field | Description |
|---|---|
| Key version | Increments by 1 every time the secret is rotated. Also sent on every signed callback as the X-Payrails-Key-Version header. |
| Secret created | Timestamp the current callback type's secret was first provisioned. |
| Secret last rotated | Timestamp of the most recent rotation, or blank if never rotated. |
| Previous key expires | End of the grace period during which the previous secret is still accepted. Blank if there is no previous secret. |
Rotate Secret
Click Rotate Secret. The dashboard asks you to confirm:
This will generate a new webhook secret.
The current secret remains valid during the grace period.
Continue?
On confirmation, a new secret is generated immediately and shown exactly once:
Copy it, then click I have saved this secret. The previous secret (the one just replaced) remains valid for a 48-hour grace period — both the old and new secret will successfully verify signatures during that window, so you can redeploy your verifier with the new secret without dropping any in-flight callbacks.
Revoke Previous Secret
If you need to immediately invalidate the previous secret before its grace period naturally expires (for example, suspected compromise), click Revoke Previous Secret. This button is disabled with the tooltip "There is no previous secret to revoke." when there is nothing to revoke (i.e. you have never rotated, or the grace period already ended). Confirmation dialog:
This immediately invalidates the previous webhook secret.
Continue?
The full HMAC signing scheme — headers, exact signing formula, and a PHP verification sample that supports both the current and previous secret — is documented in the HMAC Signature Verification section below.
🛡️ HMAC Signature Verification
Every signed callback includes an HMAC-SHA256 signature so you can verify it genuinely came from PayRails and was not tampered with in transit. This section is the authoritative reference for implementing that verification yourself. The PayIn and Payout Callback Labs (or the shared /callback-example.php) are working, runnable implementations of everything below.
Headers sent with a signed callback
| Header | Example | Description |
|---|---|---|
| Content-Type | application/json | Always sent, signed or not. |
| X-Payrails-Signature | v1=3f2c9a... | HMAC-SHA256 digest, hex-encoded, prefixed with the scheme version v1=. |
| X-Payrails-Timestamp | 1730678400 | Unix timestamp (seconds) captured when the callback was enqueued. |
| X-Payrails-Event-Id | 3d2f1a9e-... | Unique UUID for this callback delivery. |
| X-Payrails-Algorithm | HMAC_SHA256 | Signing algorithm identifier. |
| X-Payrails-Key-Version | 2 | Which of your secrets (by rotation count) produced this signature. |
Content-Type is sent — there is no signature to verify. Treat an unsigned delivery
as informational only until signing is enabled; do not reject it outright unless you require signing.
Exact signing formula
signing_input = raw_request_body + "." + X-Payrails-Timestamp + "." + X-Payrails-Event-Id signature = hex( HMAC_SHA256( secret, signing_input ) ) header value = "v1=" + signature
raw_request_body means the exact, unmodified bytes of the HTTP
request body — not a re-encoded/pretty-printed/re-ordered JSON string, and never PHP's
$_POST superglobal (which is form-decoded and will not match). Always read the body with
file_get_contents('php://input') before calling json_decode(), and
compute the signature over those raw bytes.
PHP verification sample
Supports both the current and previous secret (for the 48-hour rotation grace period described in
Webhook Authentication above). Uses
hash_equals() for a timing-safe comparison — never compare signatures with
== or ===.
📄 Full PHP verification sample (copy-paste ready)
<?php
// 1. Read the RAW body — never $_POST.
$rawBody = file_get_contents('php://input');
// 2. Read the PayRails headers.
$headers = getallheaders();
$signature = $headers['X-Payrails-Signature'] ?? ''; // "v1=<hex>"
$timestamp = $headers['X-Payrails-Timestamp'] ?? '';
$eventId = $headers['X-Payrails-Event-Id'] ?? '';
if (!preg_match('/^v1=([0-9a-f]{64})$/', $signature, $m)) {
http_response_code(400);
exit('Missing or malformed signature');
}
$providedHex = $m[1];
// 3. Rebuild the exact signing input.
$signingInput = $rawBody . '.' . $timestamp . '.' . $eventId;
// 4. Try the current secret, then the previous secret (grace period).
$currentSecret = getenv('PAYRAILS_WEBHOOK_SECRET_CURRENT');
$previousSecret = getenv('PAYRAILS_WEBHOOK_SECRET_PREVIOUS'); // may be empty
$verified = false;
foreach (array_filter([$currentSecret, $previousSecret]) as $secret) {
$expectedHex = hash_hmac('sha256', $signingInput, $secret);
if (hash_equals($expectedHex, $providedHex)) {
$verified = true;
break;
}
}
if (!$verified) {
http_response_code(401);
exit('Signature verification failed');
}
// 5. Only now is it safe to decode and act on the payload.
$payload = json_decode($rawBody, true);
// ... process $payload, using transaction_id / platform_payout_id for idempotency ...
http_response_code(200);
echo json_encode(['received' => true]);
Secret lifecycle
On first callback URL save for a type — key_version = 1
key_version increments • old secret becomes "previous"
Both current and previous secrets verify successfully
Only the current secret verifies from this point on
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | Verifying against $_POST or a re-encoded JSON string instead of the raw php://input bytes. |
| Signature matched yesterday, fails today | Secret was rotated and the grace period has now expired — update your configured secret. |
| Works for new callbacks, fails for retries | Should not happen — the signature is stable across retries. If it does, you are likely recomputing the signing input from a re-parsed payload rather than caching the raw body. |
| No signature headers at all | Webhook authentication has not been enabled/configured yet for this merchant/callback type — treat as unsigned. |
Signature format doesn't match v1=<hex> | Header was altered by a proxy/CDN, or you're reading the wrong header name. |
| Intermittent failures under load | Confirm you read the body exactly once per request — some frameworks buffer php://input such that a second read returns nothing. |
Security recommendations
- Always verify the signature before parsing or acting on the payload.
- Never log or expose your webhook secret — store it in server-side configuration only (environment variables, secrets manager), never in client-side code or version control.
- Use HTTPS for your callback URL — it is required in production and enforced at configuration time.
- Always use
hash_equals()(or an equivalent constant-time comparison) — never==/===, which are vulnerable to timing attacks. - Implement idempotency using
transaction_id(PayIn) orplatform_payout_id(Payout) — retries can deliver the same event more than once. - Log verification failures (without logging the secret itself) so you can investigate misconfiguration or genuine spoofing attempts.
- Keep both the current and previous secret configured during a rotation window so in-flight deliveries are not rejected.
🧪 Callback Lab — Live Reference Receiver
A working, runnable callback receiver you can point a real (or simulated) callback URL at to see the entire HMAC verification flow in action — not just described, but executed.
- Receives an inbound POST exactly the way your own server should — raw body via
php://input, never$_POST. - Verifies the
X-Payrails-Signatureusing the exact formula documented above. - Displays every received callback: Callback Type, headers, raw JSON, parsed JSON, verification status, algorithm, key version, and signature.
- Includes a Simulate Callback button so you can see a fully verified delivery even before your production callback URL is configured.
/callback-example.php
URL still works and infers the type from the payload shape instead.
Prefer one shared endpoint for both types? Open the canonical Callback Lab →
💡 Status API Best Practices
Follow these practices to build a reliable payout integration.
-
Always store both identifiers after payout creation:
merchant_ref— your own reference (used to query status after network failures)platform_payout_id— PayRails identifier (used for all subsequent status lookups)
- Prefer callbacks for real-time updates. Configure a callback URL in your merchant account to receive push notifications when payout status changes.
-
Use the Status API:
- After any network failure or timeout
- For end-of-day reconciliation
- When callbacks are delayed or not yet received
- Before retrying a payout — to confirm the original did not succeed
-
Never assume a network timeout means the payout failed.
Always verify using:
GET /payouts/status?merchant_ref=YOUR_REF GET /payouts/status?platform_payout_id=PPX...
HTTP 404 for your merchant_ref,
the payout was never accepted and it is safe to submit a new request.
If it returns HTTP 200, the payout exists — do not create a duplicate.
Track the existing payout using its platform_payout_id.
💰 Wallet Balance API
Check your available balance before creating a payout. Attempting a payout with insufficient funds returns an error without creating any record.
{
"currency": "INR",
"total_balance": 50000,
"reserved_balance": 3000,
"available_balance":47000,
"payouts_enabled": true,
"is_frozen": false
}
| Field | Type | Description |
|---|---|---|
| available_balance | number | Immediately usable funds. Formula: total_balance − reserved_balance. |
| reserved_balance | number | Funds locked for in-flight payouts not yet settled. |
| total_balance | number | Full balance including reserved funds. |
| payouts_enabled | boolean | False when payouts are administratively disabled. |
| is_frozen | boolean | True when wallet is frozen — all payout attempts will be rejected. |
The live card below calls this exact endpoint and displays your real-time balance.
Wallet balance is checked automatically before each payout creation.
📤 Create Payout API
Initiates a real-money bank transfer. The payout begins processing immediately after successful creation.
Request Body
| Field | Required | Type | Validation / Notes |
|---|---|---|---|
| amount | Required | number | Positive number. Must not exceed the merchant single-payout limit. |
| mode | Required | string | IMPS · NEFT · RTGS · UPI |
| beneficiary.name | Required | string | Non-empty string. |
| beneficiary.account_number | Required | string | 6–18 digits. Encrypted before storage — never stored in plaintext. |
| beneficiary.ifsc | Required | string | 11-char IFSC code. Pattern: ABCD0123456 |
| beneficiary.account_type | Required | string | Default SAVINGS. Allowed: SAVINGS CURRENT SALARY NRE NRO |
| beneficiary.bank_name | Required | string | Stored for reference. No validation. |
| beneficiary.mobile | Required | string | Stored as-is. No format validation. |
| beneficiary.email | Required | string | Stored as-is. No format validation. |
| merchant_ref | Required | string | Your reference ID. Not required to be unique. |
👤 Fixed Test Beneficiary
All payouts from this console go to the beneficiary below. These values are hardcoded constants on the server and cannot be changed from the browser.
📡 Status Poll API
After creating a payout, use this endpoint to check its current status. Poll until the status reaches a terminal state.
or
GET /api/v1/payouts/status?platform_payout_id=PPX...
{
"platform_payout_id": "PPX...",
"platform_request_id": "PRQ...",
"merchant_reference": "DEMO_...",
"status": "SUCCESS",
"amount": 100,
"currency": "INR",
"mode": "IMPS",
"provider_reference": "COLT12345",
"bank_reference_number": "HDFC9876",
"failure_reason": null,
"created_at": "...",
"updated_at": "..."
}
Rules: Supply exactly one lookup parameter. merchant_ref must be unique within your merchant account. The same response schema is returned regardless of lookup method.
Poll every 3–5 seconds. Stop immediately when status is a terminal value. The live polling card above uses this exact API automatically.
♻️ Payout Status Lifecycle
A payout transitions through these states. Poll the Status API until a terminal state is reached.
FAILED CANCELLED EXPIRED REVERSED have no further transitions.
SUCCESS is also terminal — once a payout succeeds, the only possible follow-on is REVERSED if the bank claws back the funds.
FAILED is permanent. The original payout row never changes after reaching
FAILED.
If an admin issues a retry, PayRails creates a brand-new payout with its own
platform_payout_id and independent lifecycle — the original payout remains FAILED forever.
Callbacks are sent separately for every payout attempt. Your system should track each payout by its own platform_payout_id.
EXPIRED means the polling window exceeded 24 h without a definitive outcome; wallet reservation is not automatically released.
👤 Beneficiary Handling
POST /payouts request body. No separate beneficiary management step is required.
In your production integration, include the beneficiary's bank details in every payout request as shown in the
Create Payout API section above. The fields you need are:
name, account_number, ifsc, account_type,
and optionally bank_name, mobile, and email.
All values are encrypted before storage and the account number is never returned in plaintext.
⚠️ Error Reference
All errors from the payout backend. Every field is from the actual implementation.
| HTTP | Error / Condition | Cause | Resolution |
|---|---|---|---|
| 400 | PAYOUT_API_DISABLED | Merchant's payout_mode is not API_ENABLED. | Contact PayRails support to enable API payouts. |
| 400 | amount must be positive | amount is 0, negative, or not a number. | Send a positive numeric amount. |
| 400 | mode must be one of… | Invalid mode value. | Use IMPS, NEFT, RTGS, or UPI. |
| 400 | beneficiary.account_number invalid | Not 6–18 digits. | Validate before submission. |
| 400 | beneficiary.ifsc invalid | Does not match [A-Z]{4}0[A-Z0-9]{6}. | Use a valid 11-char IFSC code. |
| 400 | Insufficient balance | available_balance < amount. | Check wallet before submitting. |
| 400 | Wallet frozen | Wallet is frozen by admin. | Contact PayRails support. |
| 400 | Daily limit exceeded | Merchant daily payout limit reached. | Wait for the next IST business day or request a limit increase. |
| 400 | Mode not allowed | Requested mode not in merchant's allowed list. | Use a permitted mode or contact support. |
| 403 | IP_NOT_ALLOWED | Caller IP not in the merchant IP whitelist. | Add your server IP via the admin portal. |
| 503 | IDEMPOTENCY_STORE_UNAVAILABLE | Idempotency Redis store unavailable. | Retry with backoff. Do not change your idempotency key. |
| 503 | PAYOUTS_SYSTEM_DISABLED | Global kill-switch active. | Contact PayRails support. |
| 429 | Rate limit exceeded | More than 10 payout creation requests in 60 seconds. | Implement exponential backoff. |
💻 cURL Reference Examples
Copy-paste ready examples. Replace YOUR_API_KEY with your actual key.
1. Check Wallet Balance
curl -X GET \\ 'https://api.payrails.in/api/v1/payouts/wallet' \\ -H 'x-api-key: YOUR_API_KEY' \\ -H 'Accept: application/json'
2. Create Payout
curl -X POST \\
'https://api.payrails.in/api/v1/payouts' \\
-H 'Content-Type: application/json' \\
-H 'Accept: application/json' \\
-H 'x-api-key: YOUR_API_KEY' \\
-H 'X-Idempotency-Key: unique-request-id-here' \\
-d '{
"amount": 100,
"beneficiary": {
"name": "Beneficiary Name",
"account_number": "55550100320090",
"ifsc": "HDFC0001234",
"account_type": "SAVINGS",
"bank_name": "HDFC Bank",
"mobile": "9876543210",
"email": "payee@example.com"
},
"mode": "IMPS",
"merchant_ref": "YOUR_REF_001"
}'
3. Poll Payout Status
curl -X GET \\ 'https://api.payrails.in/api/v1/payouts/status?merchant_ref=ORDER100' \\ -H 'x-api-key: YOUR_API_KEY' \\ -H 'Accept: application/json'
or
curl -X GET \\ 'https://api.payrails.in/api/v1/payouts/status?platform_payout_id=PPX...' \\ -H 'x-api-key: YOUR_API_KEY' \\ -H 'Accept: application/json'
✅ Production Checklist
Verify each item before going live with PayRails Payout API.
- Store your
x-api-keysecurely on the server. Never expose it in the browser or mobile app. - Always call
GET /payouts/walletand verifyavailable_balance ≥ amountbefore creating a payout. - Generate a unique
merchant_reffor every payout (e.g. your internal order ID). - Generate a new cryptographically random
X-Idempotency-Keyfor every new payout request. - Never reuse an idempotency key from a previous payout to avoid unintended duplicates or errors.
- Persist the
platform_payout_idreturned byPOST /payoutsimmediately after creation. - Poll
GET /payouts/{platform_payout_id}every 3–5 seconds until the status is terminal. - Terminal states: SUCCESS FAILED CANCELLED EXPIRED REVERSED
- On
FAILED: log thefailure_reason.FAILEDis a permanent terminal state — the original payout never changes. If a retry is needed, PayRails support issues a brand-new payout with a newplatform_payout_id. Track the new payout independently. - On
EXPIRED: the wallet reservation is not automatically released. Contact PayRails support. - On
SUCCESS: confirm thebank_reference_numberfor reconciliation. - Implement exponential backoff on HTTP 429 (rate limit: 10 payouts/60 s) and HTTP 503 responses.
- If your server has a fixed IP, configure the IP whitelist with PayRails to prevent unauthorized use of your API key.