๐ณ PayRails Merchant PayIn Integration Guide
Live API console — authenticate, test, and explore the PayRails PayIn API in real time.
| Environment | Live Demo |
| Base URL | https://api.payrails.in/api/v1 |
| Authentication | x-api-key |
| Payment Method | UPI (Unified Payments Interface) |
| Currency | INR (hardcoded by platform) |
๐ 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 →
Create a live UPI transaction using the console on this page.
/index.php →
Point your callback URL at the dedicated PAYIN Callback Lab endpoint to see a real inbound delivery, tagged with its Callback Type.
/callbacks/payin.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
โก Quick Start
Everything you need to accept your first UPI payment in under 5 minutes.
x-api-key from PayRails. Include it on every request.POST /transactionsSupply amount, merchantTxnId, paymentMethod.
upi_intent_url from response. Redirect the customer.FAILED — prompt retry.
Return HTTP 200 to webhook.
๐ Integration Flow
Follow this sequence for every PayIn transaction.
Send merchantTxnId, amount, paymentMethod, customer
transaction_id = PENDING • upi_intent_url in response
Open UPI intent on mobile • Show QR on desktop
GPay • PhonePe • Paytm • BHIM and others
PayRails POSTs to your callback URL • Return HTTP 200
SUCCESS • FAILED • CANCELLED • EXPIRED
๐ค Create Transaction API
Initiates a UPI payment session. Returns a upi_intent_url that the merchant redirects the customer to.
Request Body
| Field | Required | Type | Notes |
|---|---|---|---|
| merchantTxnId | Required | string | Your unique transaction reference. Must be unique per merchant. |
| amount | Required | number | Transaction amount in INR. Positive number. |
| paymentMethod | Required | string | Must be UPI for the merchant API. |
| customer.name | Required | string | Customer's name for logging and provider handoff. |
| customer.email | Required | string | Customer's email address. |
| customer.mobile | Required | string | Customer's mobile number. |
| s2s_client_ip | Recommended | string | Customer's actual IP address. Used for fraud detection. Falls back to request IP if omitted. |
| s2s_device_info | Recommended | string | Customer's browser User-Agent. Falls back to request header if omitted. |
provider_id, provider_code, upi_vpa, routing_rule_id, routing_strategy โ these fields are rejected with HTTP 400.
Silently ignored:
callbackUrl / callback_url โ configure your webhook URL in the PayRails merchant settings instead.
The live form below creates a real transaction using this exact endpoint.
๐ Live Demo
Enter an amount below and click Create UPI Transaction to execute a real API call. All debug panels below will populate with live data.
๐ฑ UPI Intent URL
The upi_intent_url field in the response is a deep link that opens the customer's UPI app directly. It follows the standard upi://pay?... URI scheme.
upi://pay?pa=...&pn=...&am=...&tr=...&tn=...
- Google Pay (GPay)
- PhonePe
- Paytm
- BHIM
- Amazon Pay
- Any UPI-enabled bank app
| Platform | Recommended Approach |
|---|---|
| Android | Use <a href="upi://pay?..."> or trigger via JavaScript. Opens UPI app selector automatically. |
| iOS | Same deep link works on iOS. Not all UPI apps support iOS — GPay and PhonePe have iOS apps. |
| Desktop | Generate and display a QR code from the intent URL. Customer scans with phone. |
| Mobile web | Redirect directly. Device opens UPI app selector. |
โป๏ธ Transaction Status Lifecycle
A PayIn transaction transitions through these states after creation.
Transaction created, awaiting payment
Customer completed UPI payment
Payment declined or error
Cancelled or EXPIRED (mapped to CANCELLED in webhook)
๐ Webhook
PayRails sends a webhook to your configured callback URL when a transaction reaches a terminal state (SUCCESS, FAILED, CANCELLED/EXPIRED). Configure your callback URL in the PayRails merchant settings.
{
"transaction_id": "internal-uuid",
"merchant_txn_id": "YOUR-REF-001",
"status": "SUCCESS",
"amount": "100",
"currency": "INR",
"provider": "razorpay",
"provider_txn_id": "pay_xxxxx",
"bank_reference": "HDFC123456",
"failure_code": null,
"failure_reason": null,
"completed_at": "2026-07-08T..."
}
| Field | Type | Description |
|---|---|---|
| transaction_id | string | PayRails internal UUID. Same as returned by Create Transaction. |
| merchant_txn_id | string | Your original reference from the Create Transaction request. |
| status | string | SUCCESS ยท FAILED ยท CANCELLED |
| amount | string | Transaction amount as stored (may be string). |
| currency | string | Always INR. |
| provider | string | Payment provider that processed the transaction. |
| provider_txn_id | string|null | Provider's own transaction reference. Null if payment never reached the provider. |
| bank_reference | string|null | Bank reference number (UTR). Available on SUCCESS for reconciliation. |
| failure_code | string|null | Provider-specific failure code. Null on SUCCESS. |
| failure_reason | string|null | Human-readable failure description. Null on SUCCESS. |
| completed_at | string | ISO-8601 UTC timestamp of webhook dispatch. |
transaction_id to prevent double-fulfilment on retries.
bank_reference in PayIn callbacks, but bank_reference_number in Payout
callbacks (see the Payout Callback Behaviour 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 →
โ ๏ธ Error Reference
All errors from the PayIn backend. Every entry is verified from the actual implementation.
| HTTP | Error / Condition | Cause | Resolution |
|---|---|---|---|
| 400 | merchantTxnId is required | merchantTxnId missing from body. | Always include a unique merchantTxnId. |
| 400 | amount is required | amount missing or zero. | Send a positive numeric amount. |
| 400 | paymentMethod is required | paymentMethod field absent. | Send "paymentMethod": "UPI". |
| 400 | provider_id/provider_code not allowed | Merchant sent provider routing hints. | Remove provider_id / provider_code from body. |
| 400 | upi_vpa is not allowed | Merchant sent a VPA in the request. | Remove upi_vpa field. |
| 400 | routing hints not allowed | routing_rule_id or routing_strategy present. | Remove routing hint fields. |
| 401 | Unauthorized | Missing or invalid x-api-key header. | Include a valid API key. |
| 409 | Duplicate merchantTxnId | A transaction with this merchantTxnId already exists for your merchant. | Use a new unique reference. |
| 503 | No provider available | No payment provider is currently eligible for routing. | Retry with backoff. Contact support if persistent. |
๐ป cURL Reference Example
Copy-paste ready. Replace YOUR_API_KEY with your actual key.
Create UPI Transaction
curl -X POST \
'https://api.payrails.in/api/v1/transactions' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"merchantTxnId": "ORDER-20261104145430",
"amount": 100,
"paymentMethod": "UPI",
"customer": {
"name": "Customer Name",
"email": "customer@example.com",
"mobile": "9876543210"
},
"s2s_client_ip": "1.2.3.4",
"s2s_device_info": "Mozilla/5.0 ..."
}'
โ Production Checklist
Verify each item before going live with PayRails PayIn API.
- Store your
x-api-keysecurely on the server. Never expose it in frontend JavaScript or mobile apps. - Generate a unique
merchantTxnIdfor every transaction (e.g. your internal order ID). - Persist the
transaction_idreturned by the Create Transaction response immediately. - Redirect the customer to
upi_intent_url— do not attempt to parse or modify it. - On desktop, generate a QR code from
upi_intent_urlrather than showing the raw URL. - Never confirm an order based on a browser redirect alone. Always wait for the webhook.
- Configure a publicly accessible webhook URL in your PayRails merchant settings.
- Your webhook handler must return HTTP 200 within the timeout window to prevent retries.
- Implement idempotency in your webhook handler using
transaction_idto prevent double-fulfilment. - Handle
FAILEDcorrectly: logfailure_reasonand prompt the customer to retry. - Handle
PENDINGstate: show a waiting indicator. Do not fulfil the order yet. - Handle
CANCELLED: the payment window expired. Allow the customer to initiate a new transaction. - Use
bank_referencefrom the SUCCESS webhook for payment reconciliation. - Implement exponential backoff on HTTP 5xx responses before retrying a failed request.