RupeAPI guide

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:

Every request
curl "https://rupe.live/v1/orders/INV-1042" \
  -H "Authorization: Bearer $SECRET_KEY"

A payment goes like this:

  1. Your server creates an order with your own reference and the amount.
  2. Your customer pays: send them to the order's pay_url, or show its UPI link in your own page.
  3. 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"
  }'

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.

201 Created
{
  "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.

H2H in your page
<!-- 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.

Read an order
# 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.

Send a UTR
curl -X POST "https://rupe.live/v1/orders/INV-1042/utr" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "utr": "412345678901" }'
  • 200 with "status": "SUCCESS": the payment was found and the order is paid.
  • 200 with "status": "WAITING": the UTR is kept, and the order is paid as soon as that payment reaches us.
  • A 422 with a code when that UTR cannot be this order's payment; the message says why.
200 OK
{
  "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.
Body of order.success
{
  "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);
}
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);

Answer 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:

Error body
{
  "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.

  1. Add a beneficiary once: a bank account (account number and IFSC) or a UPI ID. Keep its ben_… id.
  2. Send a payout to it with your own reference and the amount in paise.
  3. Wait for the payout.success webhook (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" }'

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.

201 Created
{
  "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"
}
A UPI beneficiary
{
  "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.

List and read
# 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" }
  }'

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.

201 Created
{
  "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.

Read a payout
# 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.
A failed payout
{
  "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

Read your balance
curl "https://rupe.live/v1/balance" \
  -H "Authorization: Bearer $SECRET_KEY"
200 OK
{
  "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.
Body of payout.success
{
  "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);
});

The 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.

Error body
{
  "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.

API guide · Rupe