Take UPI payments with Rupe
Your server creates an order, your customer pays on a page we host or in your own screen, and a signed webhook tells your server when the money has arrived. You can also send payouts from your balance to bank accounts and UPI IDs.
- Base URL
- https://rupe.live
- Authentication
- Authorization: Bearer sk_test_…
- Format
- JSON in and out. Amounts in paise (₹499.00 is 49900).
Contents
Getting started
Sign in to your Rupe dashboard and open the developer settings. There you make API keys and see your webhook signing secret. Each key is shown once when it is made: keep it on your server, never in an app or a web page.
sk_test_…keys make test orders. No money moves; you pay them with a button (see Test mode).sk_live_…keys make real orders. They work once live payments are switched on for your account.
Send the key with every request, and send requests to your base URL:
curl "https://rupe.live/v1/orders/INV-1042" \
-H "Authorization: Bearer $SECRET_KEY"A payment goes like this:
- Your server creates an order with your own reference and the amount.
- Your customer pays: send them to the order's
pay_url, or show its UPI link in your own page. - We send your server a signed webhook when the order is paid (or fails, or runs out of time). Mark your order paid only then.
For live keys you can list the server addresses allowed to use them. A request from any other address is refused with IP_NOT_ALLOWED.
Create an order
POST/v1/orders
curl -X POST "https://rupe.live/v1/orders" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "INV-1042",
"amount": 49900,
"description": "Order 1042",
"customer": { "name": "Asha Rao", "phone": "9876543210" },
"return_url": "https://shop.example/orders/1042",
"callback_url": "https://shop.example/webhooks/payments"
}'const res = await fetch("https://rupe.live/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference: "INV-1042",
amount: 49900, // paise: ₹499.00
description: "Order 1042",
customer: { name: "Asha Rao", phone: "9876543210" },
return_url: "https://shop.example/orders/1042",
callback_url: "https://shop.example/webhooks/payments",
}),
});
const order = await res.json();
if (!res.ok) throw new Error(`${order.code}: ${order.message}`);
// Send the customer to order.pay_url (or show order.upi_intent in your own page).<?php
$ch = curl_init("https://rupe.live/v1/orders");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SECRET_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"reference" => "INV-1042",
"amount" => 49900, // paise: ₹499.00
"description" => "Order 1042",
"customer" => ["name" => "Asha Rao", "phone" => "9876543210"],
"return_url" => "https://shop.example/orders/1042",
"callback_url" => "https://shop.example/webhooks/payments",
]),
]);
$order = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
throw new Exception($order["code"] . ": " . $order["message"]);
}
// Send the customer to the pay page.
header("Location: " . $order["pay_url"]);import os
import requests
res = requests.post(
"https://rupe.live/v1/orders",
headers={"Authorization": f"Bearer {os.environ['SECRET_KEY']}"},
json={
"reference": "INV-1042",
"amount": 49900, # paise: ₹499.00
"description": "Order 1042",
"customer": {"name": "Asha Rao", "phone": "9876543210"},
"return_url": "https://shop.example/orders/1042",
"callback_url": "https://shop.example/webhooks/payments",
},
timeout=30,
)
order = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{order['code']}: {order['message']}")
# Send the customer to order["pay_url"]Request fields
- referencestring, required
- Your own id for this payment, 1–64 characters: letters, digits and _ . : - /. Unique per mode. Sending the same reference and amount again returns the same order, so a retry never charges twice.
- amountinteger, required
- In paise: ₹499.00 is 49900. Whole numbers only.
- currencystring
- "INR", the only currency. May be left out.
- flowstring
- "INTENT" (a payment the processor confirms) or "P2P" (the customer pays a UPI ID by QR). Left out: your account's default.
- descriptionstring
- Up to 100 characters, for your own records.
- customerobject
- Who is paying. Every field is optional; see below.
- return_urlstring
- Where the pay page sends the customer when the payment ends. order_id, reference and status are added to it. Left out: your account's return URL, if set.
- callback_urlstring
- Where this order's webhooks go. Left out: your account's webhook URL.
- expires_ininteger
- Seconds the customer has to pay, 120–86400. It can shorten your account's window, never lengthen it.
- metadataobject
- Up to 20 keys of your own (names up to 40 characters; values are strings up to 200 characters, numbers or true / false), returned on the order and in webhooks.
customer
- customer.namestring
- Up to 80 characters.
- customer.emailstring
- An email address.
- customer.phonestring
- 10–13 digits, optionally with a leading +.
- customer.vpastring
- The customer's UPI ID, when you know it.
- customer.ipstring
- The customer's IP address (not your server's).
The answer
201 with the new order. Sending the same reference and amount again answers 200 with the same order, so a request that timed out can safely be sent again. The same reference with another amount is refused with REFERENCE_REUSED. Test and live orders are kept apart: a reference used in test mode is free in live mode.
{
"id": "ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"reference": "INV-1042",
"status": "PENDING",
"amount": 49900,
"amount_paid": null,
"currency": "INR",
"flow": "INTENT",
"mode": "live",
"checkout": "H2H",
"pay_url": "https://rupe.live/pay/ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"upi_intent": "upi://pay?pa=yourshop@bank&pn=Your%20Shop&tr=t5f2c9a1e7b3d4086c1a2&am=499.00&cu=INR",
"utr": null,
"failure": null,
"description": "Order 1042",
"customer": {
"name": "Asha Rao",
"email": null,
"phone": "9876543210"
},
"metadata": {
"cart_id": "c_77"
},
"created_at": "2026-10-25T10:30:00.000Z",
"expires_at": "2026-10-25T10:45:00.000Z",
"paid_at": null
}The order object
Every answer about an order, and every webhook, carries the same object.
- idstring
- The order's id: ord_ and 24 letters and digits. Read the order by it, or by your reference.
- referencestring
- Your reference, as you sent it.
- statusstring
- PENDING, SUCCESS, FAILED or EXPIRED. See Statuses.
- amountinteger
- The amount asked for, in paise.
- amount_paidinteger | null
- What was paid, in paise, once the order is paid.
- currencystring
- "INR".
- flowstring
- "INTENT" or "P2P": how the customer pays.
- modestring
- "test" or "live", from the key that made it.
- checkoutstring
- "H2H": upi_intent is set and you may show it in your own page. "REDIRECT": send the customer to pay_url.
- pay_urlstring
- The hosted pay page for this order. Works for every order.
- upi_intentstring | null
- A upi://pay link for H2H orders. Use it exactly as given.
- utrstring | null
- The bank reference (UTR) of the payment, once paid, when known.
- failureobject | null
- For a FAILED order: { code, message } saying why.
- descriptionstring | null
- Your description.
- customerobject
- { name, email, phone } as you sent them, or null.
- metadataobject
- Your metadata, as you sent it.
- created_atstring
- When the order was made (ISO 8601, UTC).
- expires_atstring
- When the time to pay ends.
- paid_atstring | null
- When it was paid.
Statuses
- PENDING
- Waiting for the customer. Show the pay page and wait for the webhook. Don't deliver anything yet.
- SUCCESS
- Paid. Final: it never changes again. Check the amount and your reference, then deliver.
- FAILED
- The payment did not go through. The customer can start again with a new order (a new reference).
- EXPIRED
- The time to pay ended without a payment. The customer can start again with a new order.
Money can arrive late: the customer's bank may take longer than the time to pay. A FAILED or EXPIRED order then becomes SUCCESS, and you get an order.success webhook after the earlier one. Don't treat FAILED or EXPIRED as final in a way you cannot undo, and if the customer has already paid again on a new order, refund one of them.
Showing the payment
The order's checkout says how the customer can pay. pay_url works for every order: send the customer there and the page does the rest, on phones and computers, and returns them to your return_url.
H2H: in your own page
With "checkout": "H2H" the order has a upi_intent, a upi://pay link. On a phone, make it a link or a button: it opens the customer's UPI app with the amount filled in. On a computer, show it as a QR code to scan with a phone. Use the link exactly as given; changing any part of it can stop the payment being matched.
<!-- On a phone: a link that opens the customer's UPI app -->
<a href="{{upi_intent}}">Pay with a UPI app</a>
<!-- On a computer: the same link as a QR code, made with any QR library -->
<div id="qr"></div>
<script>
// e.g. qrcode.js: new QRCode(document.getElementById("qr"), order.upi_intent);
</script>P2P orders are always H2H: the customer scans and pays a UPI ID, and must pay the exact amount, paise included.
Redirect
With "checkout": "REDIRECT" there is no UPI link: send the customer to pay_url. From there they finish on a secure payment page and come back to your return_url.
Either way, the status added to your return URL is for showing the right screen only. Deliver on the webhook (or on reading the order), never on the return URL alone.
Read an order
GET/v1/orders/{id or reference}
The order, by its ord_… id or by your reference, in the mode of the key you use. While a live order waits, reading it also asks the payment processor for news, so polling shows the latest. Webhooks are still the better way to hear about a payment: poll every 10–30 seconds at most.
# By the order's id or by your own reference
curl "https://rupe.live/v1/orders/ord_8m2k4q7x9c1v3b5n6z0w2e4r" \
-H "Authorization: Bearer $SECRET_KEY"
curl "https://rupe.live/v1/orders/INV-1042" \
-H "Authorization: Bearer $SECRET_KEY"The customer's bank reference (P2P)
POST/v1/orders/{id or reference}/utr
A P2P payment is usually confirmed within a minute. When a customer says they paid but the order still waits, send the 12-digit bank reference (UTR) from their UPI app. The order is paid only when a payment with exactly that UTR, the order's amount and the order's UPI ID has reached us. The pay page offers the same to the customer.
curl -X POST "https://rupe.live/v1/orders/INV-1042/utr" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "utr": "412345678901" }'200with"status": "SUCCESS": the payment was found and the order is paid.200with"status": "WAITING": the UTR is kept, and the order is paid as soon as that payment reaches us.- A
422with a code when that UTR cannot be this order's payment; the message says why.
{
"status": "WAITING",
"message": "Thanks. The order is confirmed as soon as the payment with this UTR reaches us, usually within a minute.",
"order": {
"id": "ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"reference": "INV-1042",
"status": "PENDING",
"amount": 49900,
"amount_paid": null,
"currency": "INR",
"flow": "P2P",
"mode": "live",
"checkout": "H2H",
"pay_url": "https://rupe.live/pay/ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"upi_intent": "upi://pay?pa=yourshop@bank&pn=Your%20Shop&tr=t5f2c9a1e7b3d4086c1a2&am=499.00&cu=INR",
"utr": null,
"failure": null,
"description": "Order 1042",
"customer": {
"name": "Asha Rao",
"email": null,
"phone": "9876543210"
},
"metadata": {
"cart_id": "c_77"
},
"created_at": "2026-10-25T10:30:00.000Z",
"expires_at": "2026-10-25T10:45:00.000Z",
"paid_at": null
}
}- UTR_INVALIDHTTP 422
- Enter the 12-digit UTR (UPI reference number) shown in the UPI app.
- NOT_P2PHTTP 422
- This payment is confirmed automatically; no UTR is needed.
- ORDER_PAIDHTTP 422
- This order is already paid.
- UTR_USEDHTTP 422
- That UTR has already paid another order. Check the number in the UPI app.
- UTR_WRONG_ACCOUNTHTTP 422
- The payment with that UTR was made to a different UPI ID than this order's.
- UTR_AMOUNT_MISMATCHHTTP 422
- The payment with that UTR is for a different amount than this order.
- UTR_BEFORE_ORDERHTTP 422
- The payment with that UTR was made before this order was created.
- UTR_TOO_LATEHTTP 422
- The payment with that UTR was made too long after this order was created. Contact support to have it checked.
- UTR_REVIEWEDHTTP 422
- The payment with that UTR was checked and is not for an order. Contact support.
- UTR_TOO_MANYHTTP 429
- Too many UTRs were sent for this order. Contact support with the payment screenshot.
Webhooks
When an order is paid, fails or runs out of time, we send a POST to the order's callback_url, or else to your account's webhook URL. If your account is set to send only successful payments, you get only order.success.
- order.success
- The order is paid. Sent again if a FAILED or EXPIRED order is paid late.
- order.failed
- The payment did not go through.
- order.expired
- The time to pay ended without a payment.
The request
- Webhook-Id
- The event's id. The same on every retry of one event: use it to skip an event you have already handled.
- Webhook-Event
- The event type, also in the body.
- Webhook-Signature
- t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your webhook signing secret>
- Body
- { id, type, created_at, data }, where data is the order object, the same as reading the order.
{
"id": "evt_4h7k2m9p3q8r1s6t5v0w2x4y",
"type": "order.success",
"created_at": "2026-10-25T10:31:35.412Z",
"data": {
"id": "ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"reference": "INV-1042",
"status": "SUCCESS",
"amount": 49900,
"amount_paid": 49900,
"currency": "INR",
"flow": "INTENT",
"mode": "live",
"checkout": "H2H",
"pay_url": "https://rupe.live/pay/ord_8m2k4q7x9c1v3b5n6z0w2e4r",
"upi_intent": "upi://pay?pa=yourshop@bank&pn=Your%20Shop&tr=t5f2c9a1e7b3d4086c1a2&am=499.00&cu=INR",
"utr": "429817365012",
"failure": null,
"description": "Order 1042",
"customer": {
"name": "Asha Rao",
"email": null,
"phone": "9876543210"
},
"metadata": {
"cart_id": "c_77"
},
"created_at": "2026-10-25T10:30:00.000Z",
"expires_at": "2026-10-25T10:45:00.000Z",
"paid_at": "2026-10-25T10:31:35.000Z"
}
}Check the signature
Check every webhook before you act on it. Use the body exactly as it arrived (parsing and writing the JSON again changes it), and refuse a t more than 5 minutes from your clock.
import crypto from "node:crypto";
// Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
export function verifyWebhook(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header).split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // a Buffer or string: the body exactly as received
.digest("hex");
const a = Buffer.from(parts.v1);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}<?php
// Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
function verify_webhook(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(",", $header) as $piece) {
$kv = explode("=", trim($piece), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts["t"], $parts["v1"]) || !ctype_digit($parts["t"])) {
return false;
}
$t = (int) $parts["t"];
if (abs(time() - $t) > $tolerance) {
return false;
}
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"]);
}import hashlib
import hmac
import time
# Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
def verify_webhook(secret, header, raw_body, tolerance=300):
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if "v1" not in parts or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body # raw_body: bytes, exactly as received
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
const app = express();
// Keep the raw body: JSON parsed and written again would not match the signature.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWebhook(process.env.WEBHOOK_SECRET, req.get("Webhook-Signature") ?? "", req.body);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// The same event can arrive twice: skip a Webhook-Id you have already handled.
if (event.type === "order.success") {
// Check event.data.reference and event.data.amount, then mark your order paid.
}
res.sendStatus(200);
});
app.listen(3000);$rawBody = file_get_contents("php://input"); // the body exactly as received
$header = $_SERVER["HTTP_WEBHOOK_SIGNATURE"] ?? "";
if (!verify_webhook(getenv("WEBHOOK_SECRET"), $header, $rawBody)) {
http_response_code(400);
exit("bad signature");
}
$event = json_decode($rawBody, true);
// The same event can arrive twice: skip a Webhook-Id you have already handled.
if ($event["type"] === "order.success") {
// Check $event["data"]["reference"] and the amount, then mark your order paid.
}
http_response_code(200);import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/payments")
def payments_webhook():
raw = request.get_data() # bytes, exactly as received
if not verify_webhook(os.environ["WEBHOOK_SECRET"], request.headers.get("Webhook-Signature", ""), raw):
return "bad signature", 400
event = request.get_json()
# The same event can arrive twice: skip a Webhook-Id you have already handled.
if event["type"] == "order.success":
pass # Check event["data"]["reference"] and the amount, then mark your order paid.
return "", 200Answer and retries
Answer with any 2xx within 10 seconds; do slow work after answering. Anything else (an error, a redirect, no answer) is tried again: 7 attempts in all, at once and then after 1 min, 5 min, 15 min, 1 h, 6 h, 24 h. The same event can reach you more than once, and events can arrive out of order: read the order if in doubt.
Errors
A refused request answers with an HTTP status and a body you can show in your logs:
{
"code": "AMOUNT_TOO_LOW",
"message": "The minimum is 100 paise",
"details": {
"field": "amount",
"limit": 100
}
}code is stable: match on it. message is plain words and can change. details is there when there is more to say, such as the field or the limit.
- INVALID_REQUESTHTTP 400
- The body is not JSON, or a field is wrong. message and details.field say which. Fix the request.
- UNAUTHORIZEDHTTP 401
- The key is missing, wrong or revoked, or was sent to another business's address.
- IP_NOT_ALLOWEDHTTP 403
- A live key used from a server address that is not on your list. Add the address in your dashboard.
- PAYIN_NOT_ENABLEDHTTP 403
- Taking payments is not switched on for your account.
- ACCOUNT_SUSPENDEDHTTP 403
- Your account is suspended. Contact support.
- ACCOUNT_CLOSEDHTTP 403
- Your account is closed.
- LIVE_NOT_ENABLEDHTTP 403
- A live key, before live payments are switched on for your account.
- FLOW_NOT_ENABLEDHTTP 403
- flow asks for a way of paying your account does not take.
- NOT_FOUNDHTTP 404
- No order with that id or reference for this key's mode (or no such endpoint).
- REFERENCE_REUSEDHTTP 409
- The reference was already used for a different amount. Use a new reference.
- AMOUNT_TOO_LOWHTTP 422
- Below your account's minimum. details.limit is the minimum in paise.
- AMOUNT_TOO_HIGHHTTP 422
- Above your account's maximum, or the UPI limit. details.limit is the maximum in paise.
- DAILY_LIMITHTTP 429
- Today's amount or number of payments for your account is reached (the day is India time). details.limit says which.
- PAYMENT_NOT_CREATEDHTTP 502
- The payment processor could not create the payment. No order was made: try again with the same reference.
- NO_ACCOUNT_AVAILABLEHTTP 503
- No payment account can take this payment right now (limits or hours). Try again shortly.
- RETRYHTTP 503
- A rare clash. Send the same request again.
- INTERNALHTTP 500
- Something went wrong on our side. Send the same request again; the reference keeps it safe.
Test mode
Orders made with a sk_test_… key are test orders: no payment account is used and no money moves. Their pay page says "Test payment" and has two buttons, Simulate success and Simulate failure, that settle the order the way a real payment would, webhook included (with "mode": "test"). A test P2P order is also paid by sending any 12-digit UTR.
Before you go live, take one order through each outcome and check that your server handles the webhook and the return.
Payouts
Payouts send money from your balance to bank accounts and UPI IDs: your customers' withdrawals, refunds to a bank account, payments to suppliers. First you add who you pay (a beneficiary), then you send payouts to it. A signed webhook tells your server when each one is paid, fails, or comes back from the bank.
- Add a beneficiary once: a bank account (account number and IFSC) or a UPI ID. Keep its
ben_…id. - Send a payout to it with your own reference and the amount in paise.
- Wait for the
payout.successwebhook (or read the payout) before you treat it as paid.
The same keys work as for orders: sk_test_… for test payouts that move no money, sk_live_… for real ones. Test and live beneficiaries and payouts are separate lists. You can also add beneficiaries and send payouts from your dashboard.
Live payouts come only from your servers
With a live key, adding a beneficiary or sending a payout needs your servers' IP addresses on file in your dashboard (until then they are refused with PAYOUT_IP_LIST_MISSING), and a request from any other address is refused with IP_NOT_ALLOWED. Test keys are not limited.
Beneficiaries
POST/v1/beneficiaries
# A bank account
curl -X POST "https://rupe.live/v1/beneficiaries" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Asha Rao", "account_number": "001234567890", "ifsc": "HDFC0001234" }'
# A UPI ID
curl -X POST "https://rupe.live/v1/beneficiaries" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Ravi Kumar", "vpa": "ravi.kumar@okicici" }'const res = await fetch("https://rupe.live/v1/beneficiaries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Asha Rao", account_number: "001234567890", ifsc: "HDFC0001234" }),
});
const beneficiary = await res.json();
if (!res.ok) throw new Error(`${beneficiary.code}: ${beneficiary.message}`);
// Keep beneficiary.id. A live one is "PENDING" until it is approved.<?php
$ch = curl_init("https://rupe.live/v1/beneficiaries");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SECRET_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"name" => "Asha Rao",
"account_number" => "001234567890",
"ifsc" => "HDFC0001234",
]),
]);
$beneficiary = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
throw new Exception($beneficiary["code"] . ": " . $beneficiary["message"]);
}
// Keep $beneficiary["id"]. A live one is "PENDING" until it is approved.import os
import requests
res = requests.post(
"https://rupe.live/v1/beneficiaries",
headers={"Authorization": f"Bearer {os.environ['SECRET_KEY']}"},
json={"name": "Asha Rao", "account_number": "001234567890", "ifsc": "HDFC0001234"},
timeout=30,
)
beneficiary = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{beneficiary['code']}: {beneficiary['message']}")
# Keep beneficiary["id"]. A live one is "PENDING" until it is approved.Request fields
- kindstring
- "BANK" or "UPI". Left out: worked out from the fields you send.
- namestring, required
- Who the money is for, 2–100 characters: letters, digits, spaces and . , & ' ( ) / -
- account_numberstring
- Bank beneficiaries: 6–18 digits, as a string (spaces and - are removed). Only the last 4 are ever shown again.
- ifscstring
- Bank beneficiaries: the branch's 11-character IFSC, like HDFC0001234.
- vpastring
- UPI beneficiaries: the UPI ID, like name@bank.
The answer
201 with the beneficiary. Adding the same details again (same mode, not switched off) answers 200 with the first one, so it is safe to retry. A test beneficiary is APPROVED at once. A live one is PENDING while it is checked; payouts to it can start once it is APPROVED.
Bank details never change: to change them, add a new beneficiary. A beneficiary is never deleted, only switched off (DISABLED) in your dashboard.
{
"id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"kind": "BANK",
"name": "Asha Rao",
"account_last4": "7890",
"ifsc": "HDFC0001234",
"vpa": null,
"status": "APPROVED",
"mode": "live",
"created_at": "2026-10-25T11:00:00.000Z"
}{
"id": "ben_6t1v4x8z2b5d9f3h7k0m2p4q",
"kind": "UPI",
"name": "Ravi Kumar",
"account_last4": null,
"ifsc": null,
"vpa": "ravi.kumar@okicici",
"status": "APPROVED",
"mode": "live",
"created_at": "2026-10-25T11:00:00.000Z"
}- idstring
- The beneficiary's id: ben_ and 24 letters and digits. Pay it with beneficiary_id.
- kindstring
- "BANK" or "UPI".
- namestring
- The name you sent.
- account_last4string | null
- The last 4 digits of the account number (bank beneficiaries).
- ifscstring | null
- The IFSC (bank beneficiaries).
- vpastring | null
- The UPI ID (UPI beneficiaries).
- statusstring
- APPROVED: payouts can go to it. PENDING: a live beneficiary still being checked. DISABLED: switched off.
- modestring
- "test" or "live", from the key that added it.
- created_atstring
- When it was added (ISO 8601, UTC).
List and read
GET/v1/beneficiaries
GET/v1/beneficiaries/{id}
The list is newest first, as { "data": [ … ], "has_more": false }. Filter with status and kind, take up to limit (1–100, default 50), and pass the last one's created_at as before for the next page.
# Newest first; ?status=APPROVED|PENDING|DISABLED, ?kind=BANK|UPI, ?limit=1-100, ?before=<created_at>
curl "https://rupe.live/v1/beneficiaries?status=APPROVED&limit=20" \
-H "Authorization: Bearer $SECRET_KEY"
curl "https://rupe.live/v1/beneficiaries/ben_3k7m2q9x4c8v1b6n5z0w2e4r" \
-H "Authorization: Bearer $SECRET_KEY"Send a payout
POST/v1/payouts
curl -X POST "https://rupe.live/v1/payouts" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "WD-1001",
"beneficiary_id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"amount": 50000,
"purpose": "Player withdrawal",
"callback_url": "https://shop.example/webhooks/payouts",
"metadata": { "user": "u-77" }
}'const res = await fetch("https://rupe.live/v1/payouts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference: "WD-1001", // your own id: retrying with it never pays twice
beneficiary_id: "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
amount: 50000, // paise: ₹500.00
purpose: "Player withdrawal",
callback_url: "https://shop.example/webhooks/payouts",
metadata: { user: "u-77" },
}),
});
const payout = await res.json();
if (!res.ok) throw new Error(`${payout.code}: ${payout.message}`);
// payout.status is usually "PROCESSING" here: wait for the webhook before you mark it paid.<?php
$ch = curl_init("https://rupe.live/v1/payouts");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SECRET_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"reference" => "WD-1001", // your own id: retrying with it never pays twice
"beneficiary_id" => "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"amount" => 50000, // paise: ₹500.00
"purpose" => "Player withdrawal",
"callback_url" => "https://shop.example/webhooks/payouts",
"metadata" => ["user" => "u-77"],
]),
]);
$payout = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
throw new Exception($payout["code"] . ": " . $payout["message"]);
}
// $payout["status"] is usually "PROCESSING" here: wait for the webhook.import os
import requests
res = requests.post(
"https://rupe.live/v1/payouts",
headers={"Authorization": f"Bearer {os.environ['SECRET_KEY']}"},
json={
"reference": "WD-1001", # your own id: retrying with it never pays twice
"beneficiary_id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"amount": 50000, # paise: ₹500.00
"purpose": "Player withdrawal",
"callback_url": "https://shop.example/webhooks/payouts",
"metadata": {"user": "u-77"},
},
timeout=30,
)
payout = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{payout['code']}: {payout['message']}")
# payout["status"] is usually "PROCESSING" here: wait for the webhook.Request fields
- referencestring, required
- Your own id for this payout, 1–64 characters: letters, digits and _ . : - /. Unique per mode. Sending the same reference, amount and beneficiary again returns the same payout, so a retry never pays twice.
- beneficiary_idstring, required
- An APPROVED beneficiary of the same mode (ben_…).
- amountinteger, required
- In paise, at least 100 (₹1.00). Whole numbers only.
- currencystring
- "INR", the only currency. May be left out.
- railstring
- IMPS, NEFT, RTGS or UPI. Left out: UPI for a UPI beneficiary, else IMPS (NEFT above ₹5,00,000). UPI only for UPI beneficiaries, up to ₹1,00,000; IMPS up to ₹5,00,000; RTGS from ₹2,00,000.
- purposestring
- 1–40 characters: letters, digits, spaces and . _ -. Left out: "Payout".
- callback_urlstring
- Where this payout's webhooks go. Left out: your account's webhook URL.
- metadataobject
- Up to 20 keys of your own (names up to 40 characters; values are strings up to 200 characters, numbers or true / false), returned on the payout and in webhooks.
The reference keeps it safe to retry
201 with the new payout. Sending the same reference with the same amount and beneficiary again answers 200 with the same payout: nothing is sent twice, and a retry is never refused by a limit. So when a request times out, send exactly the same request again. The same reference with another amount or beneficiary is refused with REFERENCE_REUSED. References are kept per mode: one used in test mode is free in live mode.
Your balance is charged the amount, your payout fee and GST on the fee (fee and tax on the payout). A payout above your account's approval amount waits as ON_HOLD until it is approved; its money is set aside meanwhile.
{
"id": "po_9h2k5m8p3q7r1s4t6v0w2x4y",
"reference": "WD-1001",
"status": "PROCESSING",
"amount": 50000,
"fee": 500,
"tax": 90,
"currency": "INR",
"mode": "live",
"rail": "IMPS",
"purpose": "Player withdrawal",
"beneficiary": {
"id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"name": "Asha Rao",
"account_last4": "7890",
"ifsc": "HDFC0001234",
"vpa": null
},
"utr": null,
"failure": null,
"metadata": {
"user": "u-77"
},
"created_at": "2026-10-25T11:00:00.000Z",
"updated_at": "2026-10-25T11:00:00.000Z"
}Read a payout
GET/v1/payouts/{id or reference}
The payout, by its po_… id or by your reference, in the mode of the key you use. While it is PROCESSING, reading it also asks the bank for news (at most every 10 seconds), so polling shows the latest. Webhooks are still the better way to hear the outcome.
# By the payout's id or by your own reference
curl "https://rupe.live/v1/payouts/po_9h2k5m8p3q7r1s4t6v0w2x4y" \
-H "Authorization: Bearer $SECRET_KEY"
curl "https://rupe.live/v1/payouts/WD-1001" \
-H "Authorization: Bearer $SECRET_KEY"The payout object
Every answer about a payout, and every payout webhook, carries the same object.
- idstring
- The payout's id: po_ and 24 letters and digits. Read the payout by it, or by your reference.
- referencestring
- Your reference, as you sent it.
- statusstring
- ON_HOLD, PENDING, PROCESSING, SUCCESS, FAILED or REVERSED. See Payout statuses.
- amountinteger
- What the beneficiary gets, in paise.
- feeinteger
- Your payout fee, in paise (0 when no fee is set).
- taxinteger
- GST on the fee, in paise. Your balance is charged amount + fee + tax.
- currencystring
- "INR".
- modestring
- "test" or "live", from the key that made it.
- railstring
- How it is sent: IMPS, NEFT, RTGS or UPI.
- purposestring
- The purpose you sent, or "Payout".
- beneficiaryobject
- { id, name, account_last4, ifsc, vpa } of who is paid. The UPI ID is partly hidden.
- utrstring | null
- The bank reference (UTR) of the transfer, once the bank gives one.
- failureobject | null
- For FAILED and REVERSED: { code, message } saying why. See the failure codes below.
- metadataobject
- Your metadata, as you sent it.
- created_atstring
- When the payout was made (ISO 8601, UTC).
- updated_atstring
- When it last changed.
failure
A FAILED or REVERSED payout says why in failure: a code to match on, and a message in plain words, with the reason the bank gave when there is one.
- PAYOUT_FAILED
- The transfer did not go through. No money left your balance.
- REJECTED
- This payout was not approved. No money left your balance.
- NOT_SENT
- This payout could not be sent. No money left your balance.
- REVERSED
- The bank returned this transfer. The amount is back in your balance.
{
"id": "po_9h2k5m8p3q7r1s4t6v0w2x4y",
"reference": "WD-1001",
"status": "FAILED",
"amount": 50000,
"fee": 500,
"tax": 90,
"currency": "INR",
"mode": "live",
"rail": "IMPS",
"purpose": "Player withdrawal",
"beneficiary": {
"id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"name": "Asha Rao",
"account_last4": "7890",
"ifsc": "HDFC0001234",
"vpa": null
},
"utr": null,
"failure": {
"code": "PAYOUT_FAILED",
"message": "The transfer did not go through. No money left your balance. Reason: Beneficiary account is closed"
},
"metadata": {},
"created_at": "2026-10-25T11:00:00.000Z",
"updated_at": "2026-10-25T11:00:00.000Z"
}Payout statuses
- ON_HOLD
- Above your account's approval amount: it waits for approval before it is sent. Its money is set aside. It becomes PENDING, or FAILED (code REJECTED) if it is not approved.
- PENDING
- Accepted and about to be sent.
- PROCESSING
- Sent; waiting for the bank. Not a failure: wait for the webhook, or read the payout.
- SUCCESS
- Paid. It can only change once more, to REVERSED, if the beneficiary's bank returns it.
- FAILED
- Final: it did not go through, and the whole amount (with the fee) is back in your balance. Send a new payout with a new reference if you still want to pay.
- REVERSED
- Final: the bank returned a paid payout. The amount is back in your balance; the fee is kept.
A payout moves one way: ON_HOLD → PENDING → PROCESSING → SUCCESS, and from SUCCESS only to REVERSED. Any step before SUCCESS can end in FAILED. A payout is failed only when the bank's answer is definite: one with no answer yet stays PROCESSING and is asked again, so never treat a slow payout as failed and pay the beneficiary another way.
Balance
GET/v1/balance
curl "https://rupe.live/v1/balance" \
-H "Authorization: Bearer $SECRET_KEY"{
"available": 3000000,
"on_hold": 50000,
"currency": "INR",
"mode": "live"
}- availableinteger
- What can be paid out now, in paise: payouts on their way are already taken out.
- on_holdinteger
- Payouts on their way (waiting for approval, sent, not yet paid), in paise.
- currencystring
- "INR".
- modestring
- "test" or "live". Test keys see a fixed test balance.
A live payout is refused with INSUFFICIENT_BALANCE when the balance does not cover its amount, fee and tax. Your balance can be held in more than one part, and one payout is paid from one part: a single payout close to your whole balance can be refused even when available covers it. Send it as two payouts.
Payout webhooks
When a payout is paid, fails or is returned, we send a POST to the payout's callback_url, or else to your account's webhook URL. The headers, the signature and the retries are the same as for orders: check Webhook-Signature with the same function, and answer with any 2xx within 10 seconds (7 attempts in all, at once and then after 1 min, 5 min, 15 min, 1 h, 6 h, 24 h). If your account is set to send only successful payments, you get only payout.success.
- payout.success
- The payout is paid; utr is set when the bank gave one.
- payout.failed
- The payout did not go through (or was not approved). The money is back in your balance.
- payout.reversed
- The bank returned a paid payout. The amount is back in your balance.
{
"id": "evt_7c2m5q8t1w4z6b9e3h0k2n4r",
"type": "payout.success",
"created_at": "2026-10-25T11:00:42.318Z",
"data": {
"id": "po_9h2k5m8p3q7r1s4t6v0w2x4y",
"reference": "WD-1001",
"status": "SUCCESS",
"amount": 50000,
"fee": 500,
"tax": 90,
"currency": "INR",
"mode": "live",
"rail": "IMPS",
"purpose": "Player withdrawal",
"beneficiary": {
"id": "ben_3k7m2q9x4c8v1b6n5z0w2e4r",
"name": "Asha Rao",
"account_last4": "7890",
"ifsc": "HDFC0001234",
"vpa": null
},
"utr": "430912345678",
"failure": null,
"metadata": {
"user": "u-77"
},
"created_at": "2026-10-25T11:00:00.000Z",
"updated_at": "2026-10-25T11:00:42.000Z"
}
}app.post("/webhooks/payouts", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWebhook(process.env.WEBHOOK_SECRET, req.get("Webhook-Signature") ?? "", req.body);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
const payout = event.data; // the payout object, as GET /v1/payouts/{id} returns it
switch (event.type) {
case "payout.success":
// Mark payout.reference paid; keep payout.utr for your records.
break;
case "payout.failed":
// Nothing was paid and the money is back in your balance. payout.failure says why.
break;
case "payout.reversed":
// A paid payout came back from the bank: the amount is back in your balance.
break;
}
res.sendStatus(200);
});$rawBody = file_get_contents("php://input");
if (!verify_webhook(getenv("WEBHOOK_SECRET"), $_SERVER["HTTP_WEBHOOK_SIGNATURE"] ?? "", $rawBody)) {
http_response_code(400);
exit("bad signature");
}
$event = json_decode($rawBody, true);
$payout = $event["data"]; // the payout object
switch ($event["type"]) {
case "payout.success": // mark $payout["reference"] paid, keep $payout["utr"]
break;
case "payout.failed": // nothing paid; money back in your balance; see $payout["failure"]
break;
case "payout.reversed": // a paid payout came back: amount back in your balance
break;
}
http_response_code(200);@app.post("/webhooks/payouts")
def payouts_webhook():
raw = request.get_data()
if not verify_webhook(os.environ["WEBHOOK_SECRET"], request.headers.get("Webhook-Signature", ""), raw):
return "bad signature", 400
event = request.get_json()
payout = event["data"] # the payout object
if event["type"] == "payout.success":
pass # mark payout["reference"] paid, keep payout["utr"]
elif event["type"] == "payout.failed":
pass # nothing paid; money back in your balance; see payout["failure"]
elif event["type"] == "payout.reversed":
pass # a paid payout came back: amount back in your balance
return "", 200The same event can reach you more than once (skip a Webhook-Id you have handled), and a payout.reversed can follow a payout.success hours or days later.
Payout errors
Refusals have the same body as for orders: a stable code to match on, a plain message, and details when there is more to say.
{
"code": "INSUFFICIENT_BALANCE",
"message": "The balance is not enough for this payout and its fee",
"details": {
"field": "amount",
"required": 50590
}
}- INVALID_REQUESTHTTP 400
- The body is not JSON, or a field is wrong. message and details.field say which.
- UNAUTHORIZEDHTTP 401
- The key is missing, wrong or revoked, or was sent to another business's address.
- PAYOUT_NOT_ENABLEDHTTP 403
- Payouts are not switched on for your account.
- ACCOUNT_SUSPENDEDHTTP 403
- Your account is suspended. Contact support.
- ACCOUNT_CLOSEDHTTP 403
- Your account is closed.
- LIVE_NOT_ENABLEDHTTP 403
- A live payout before live payouts are switched on for your account.
- PAYOUT_IP_LIST_MISSINGHTTP 403
- A live key, and no server addresses on file. Add your servers' IP addresses in your dashboard first.
- IP_NOT_ALLOWEDHTTP 403
- A live key used from a server address that is not on your list.
- NOT_FOUNDHTTP 404
- No payout or beneficiary with that id or reference for this key's mode.
- REFERENCE_REUSEDHTTP 409
- The reference was already used for a payout of another amount or beneficiary. Use a new reference.
- BENEFICIARY_NOT_FOUNDHTTP 422
- No beneficiary with that id in this mode (test and live beneficiaries are separate).
- BENEFICIARY_NOT_APPROVEDHTTP 422
- The beneficiary is still being checked. Payouts to it can start once it is approved.
- BENEFICIARY_DISABLEDHTTP 422
- The beneficiary is switched off.
- RAIL_NOT_ALLOWEDHTTP 422
- The rail does not fit the beneficiary or the amount (UPI needs a UPI beneficiary; RTGS a large amount; IMPS a smaller one).
- AMOUNT_TOO_LOWHTTP 422
- Below the minimum payout. details.limit is the minimum in paise.
- AMOUNT_TOO_HIGHHTTP 422
- Above your account's maximum payout, or the UPI limit. details.limit is the maximum in paise.
- INSUFFICIENT_BALANCEHTTP 422
- Your balance is not enough for the amount, the fee and its tax. details.required is what it needs, in paise.
- DAILY_LIMITHTTP 429
- Today's payout amount for your account is reached (the day is India time). details.limit is the limit in paise.
- NO_ACCOUNT_AVAILABLEHTTP 503
- No payout can be sent right now. Nothing was made: try again shortly with the same reference.
- RETRYHTTP 503
- A rare clash. Send the same request again.
- INTERNALHTTP 500
- Something went wrong on our side. Send the same request again; the reference keeps it safe.
Test payouts
Payouts made with a sk_test_… key never reach a bank and never touch your real balance: test keys see a fixed test balance. Test beneficiaries are ready at once. The amount's paise decide what happens, webhooks included (with "mode": "test"):
- … .99
- Paid at once: the answer is already SUCCESS, with a test UTR. For example 50099 (₹500.99).
- … .13
- Fails: the answer is FAILED with failure.code PAYOUT_FAILED. For example 25013 (₹250.13).
- anything else
- PROCESSING first, then paid about 10 seconds later. For example 50000 (₹500.00).
Before you go live, take one payout through each outcome and check that your server handles every webhook.