Refunds
Refund a payment in full or in part with POST /v1/refunds. On a method with a refund API,
refunds settle back against the original collection, and post against it on the same ledger that
backs your balance, so a refunded payment nets out cleanly in your reporting and exports. On
UPI, mobile money and crypto a refund is returned as a payout instead: see
Methods without a refund API.
Before you build a refund flow
Refund availability depends on the payment method and on what is enabled for your account. Confirm it before you write code against this page, because it is not visible from the API response shape alone.
Refunds are enabled per account. If refunds are not enabled for your account,
POST /v1/refunds returns 403 with code ERR_1012, regardless of the payment or the amount,
and no refund is created by that request. Retrieving a refund you already made is not affected.
Ask support to confirm refunds are on for you before you build: whether the underlying rail can
carry the reversal is a separate question from whether the route answers.
Method-level support is activated per account too. A refund is a capability of the
underlying rail as well as of the API, so a method you can collect with is not automatically
a method you can reverse through this endpoint. You do not have to ask: every payment returned
by GET /v1/transactions carries a refundable boolean, and where it is
false a refund_unsupported_reason says so in a sentence you can show your own support team.
A refund attempted on such a rail is rejected before anything is written; see
Methods without a refund API for the codes and for how the
money goes back to the payer instead.
GET /v1/capabilities answers a different
question: it is the unauthenticated, platform-wide catalogue of what TensorRail supports, not
a per-account list.
A refund fee is charged in the payment's own currency. There is no conversion: the fee is
read from a figure configured for that exact currency, or the refund is refused. If no refund
fee is configured for the currency you are refunding in,
POST /v1/refunds answers 422 with ERR_3069 and the refund is
not created, so nothing is reserved against your balance and no refund webhook follows. It is not
transient: ask your account manager to have the fee set for that currency. A fee configured as
zero is a different thing and works normally; it means refunds in that currency are free.
Build your customer-service flow around what your account actually has, rather than around the assumption that every collection can be reversed through the API. Test keys answer refunds the way live keys do, including the refusal described below, so you can build and test the flow.
Methods without a refund API
On UPI, mobile money and crypto, a refund is returned to the payer as a payout rather than through the refund endpoints.
A payout has to have somewhere to go. On UPI it does: the payer's own bank account, which you give your account manager, so a UPI refund is returned that way. On mobile money and crypto there is no payout destination on file, so a refund on those methods is returned by manual transfer until one exists. Ask your account manager either way; the difference is only in how the money travels and in what you are shown afterwards.
What the API answers. The refund endpoints refuse the request before a refund is created, so nothing is reserved against your balance and no refund webhook follows:
| Call | Answer |
|---|---|
POST /v1/refunds on any of these methods | 422 with ERR_3066. If refunds are not enabled for your account, 403 with ERR_1012 comes first |
Read it as "request this refund as a payout", never as a transient failure: retrying will not change it.
How the money goes back.
- Ask your account manager for the refund, quoting the
payment_id, the amount, your reason and the payer's payout destination. For a UPI payment that is the account holder's name, the bank account number and the IFSC: a UPI payment does not carry the payer's bank details, so the payout cannot be sent without them. - The amount is returned to the payer as a payout. It is released only when a second person approves it: whoever raises the payout cannot also approve it.
- You bear the payout fee, on top of the refunded amount.
- It is ledgered and auditable. The payout and its fee post to your ledger, and each approval is recorded.
What you will see. The payment keeps its status succeeded, and a refund object is created
for it. That object carries method, which says how the money is going back:
method | What it means |
|---|---|
refund_api | The ordinary refund, through the refund endpoints. Every refund you create yourself |
payout | Returned as a payout. payout_id names the payout carrying it |
manual_transfer | Returned by a transfer made by hand, for a payer a payout cannot reach |
A payout refund appears in GET /v1/refunds beside your API refunds and follows the payout: it
reads pending while the payout waits for its second approval and while the rail works it,
succeeded once the rail confirms the money reached the payer, and failed if the rail was asked
and the money did not go through. A refund whose payout was never approved inside the approval
window reads cancelled: nothing was ever asked of the rail. Both failed and cancelled give the
amount back to the payment's refundable balance, so a replacement can be requested.
No refund_* webhook is sent for it, because the money is moving on the payout: your endpoint
receives that payout's payout_* events instead (the Refund payout group in
Event types). Those events carry the payout's own ID, which is
the same value as the refund object's payout_id. That is how you tie the two together, and it is
also how you reach the payment_id, which the payout events do not carry.
A refund returned by manual transfer is the one for a payer with no destination a payout can
reach. Someone moves the money and then records it, so the refund object is created already
succeeded: nothing is pending, because nothing is still to happen. It is a refund on your ledger
like any other, counted against the payment's refundable balance, and no fee is charged for it.
There is no payout, so payout_id is empty, and no webhook is sent, so read it from
GET /v1/refunds rather than waiting for an event. Ask your account manager for the bank reference
if you need to match it against your own statement.
Create a refund
Every refund is created with POST /v1/refunds, with your secret key as a Bearer token and an
Idempotency-Key. It is the one refund create on the API: it reserves the refund against the
payment before anything is sent, so no combination of refunds, including refunds returned as a
payout or by manual transfer, can give back more than the payment captured.
- curl
- Node
- Python
curl -X POST https://api.tensorrail.com/v1/refunds \
-H "Authorization: Bearer rail_full_test_xxx" \
-H "Idempotency-Key: 9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10" \
-H "Content-Type: application/json" \
-d '{
"payment_id": "pay_N5c…",
"amount_minor": 50000,
"currency": "INR",
"reason": "requested_by_customer"
}'
const response = await fetch("https://api.tensorrail.com/v1/refunds", {
method: "POST",
headers: {
Authorization: "Bearer rail_full_test_xxx",
"Idempotency-Key": "9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10",
"Content-Type": "application/json",
},
body: JSON.stringify({
payment_id: "pay_N5c…",
amount_minor: 50000,
currency: "INR",
reason: "requested_by_customer",
}),
});
const { refund } = await response.json();
import requests
response = requests.post(
"https://api.tensorrail.com/v1/refunds",
headers={
"Authorization": "Bearer rail_full_test_xxx",
"Idempotency-Key": "9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10",
},
json={
"payment_id": "pay_N5c…",
"amount_minor": 50000,
"currency": "INR",
"reason": "requested_by_customer",
},
)
refund = response.json()["refund"]
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id | string | Yes | The payment to refund |
amount_minor | integer | Yes | Minor units, a positive integer, not exceeding what is left to refund on the payment |
currency | string | No | Must match the payment's currency; defaults to it |
reason | string | No | Free-text reason, stored on the refund |
The Idempotency-Key header is required. The full request and response reference, the refund
statuses and every error code are on Refunds via the Reporting API.
The response
{
"refund": {
"id": 314,
"status": "accepted",
"amount_minor": 50000,
"currency": "INR",
"parent_payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"refund_id": "ref_8Kj2…",
"fee_amount_minor": 0,
"fee_type": "voluntary_refund",
"reason": "requested_by_customer",
"idempotency_key": "9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10",
"created_at": "2026-07-22T10:15:00.000000000Z"
}
}
The refund is recorded against your balance at once. refund_id is the refund's identifier on
the main API (ref_*), set once the refund is accepted for processing; read the processing view
with GET /refunds/{refund_id}.
Refunds are asynchronous like collections: the create response is not the outcome, and the
terminal outcome arrives as a refund_succeeded / refund_failed
webhook (and on GET /v1/refunds/{id} and GET /refunds/{refund_id}).
POST /refunds is withdrawnRefunds could once also be created with POST /refunds on the main API. That
route no longer creates refunds (it answers 404 with ERR_3012): create every refund with
POST /v1/refunds. Refunds created earlier are unaffected and stay retrievable, updatable and
listable on the /refunds routes below, including any created with your own refund_id.
Idempotency (live)
- Send an
Idempotency-Keyheader (it is required). Retrying with the same key and the same body returns the original refund in its current status, so a network retry can never refund twice; nothing is reserved or charged again. - Reusing a key with a different body is rejected with
409ERR_3002: use one key per logical refund.
The full behaviour, including concurrent retries and a refund that was refused, is under Idempotency behavior.
For a money-out operation, this is the property that matters most: your retry logic cannot over-refund a customer.
Partial refunds
Refund less than the collected amount by passing a smaller amount_minor, and repeat for multiple
partials. Give each partial its own Idempotency-Key so each is individually idempotent:
# Refund INR 150.00 of a INR 500.00 collection
curl -X POST https://api.tensorrail.com/v1/refunds \
-H "Authorization: Bearer rail_full_test_xxx" \
-H "Idempotency-Key: 41d2a6a0-88a7-4b0d-9d3e-7c2f8b4a1c55" \
-H "Content-Type: application/json" \
-d '{ "payment_id": "pay_N5c…", "amount_minor": 15000, "currency": "INR" }'
Guardrails the platform enforces for you:
- The total refunded can never exceed what the payment actually captured: the succeeded and
in-flight refunds of a payment, however they were made, count against it, even under
concurrent requests. On a payment the payer settled short, that is the amount that arrived,
not the amount asked for. A refund past it is refused with
422ERR_3035and nothing is created. - A refund must be a positive amount;
0and negative amounts are rejected as malformed. - Refunding a payment that is not in a refundable state returns a business-state error
(
ERR_3034) rather than silently queueing.
The refund object
The processing view of a refund, as GET /refunds/{refund_id} returns it. The stable field set:
refund_id, payment_id, amount, currency, status, method, payout_id, reason,
metadata, error_code, error_message, unified_code, unified_message, created_at,
updated_at.
method is always present and is refund_api on every refund made through the refund API.
payout_id is present only when method is payout; see
Methods without a refund API.
unified_code / unified_message are TensorRail's normalized, cross-rail error surface for a
refund that did not succeed. They mean the same thing whatever rail executed the refund; read
those rather than any rail-specific detail. Full per-field documentation is in the
API reference: Refunds.
Refund status
| Status | Meaning |
|---|---|
pending | Submitted; outcome not yet final |
review | Held for review |
succeeded | Refunded |
failed | Did not complete; read unified_code / unified_message |
Retrieve, update, and list
GET /refunds/{refund_id}retrieves a single refund.POST /refunds/{refund_id}updates a refund'sreasonormetadata.POST /refunds/listlists refunds; the list shape is{ "count": …, "total_count": …, "data": [ … ] }.
Tips
- Send
payment_method_typeexplicitly on the original confirm. Omitting it can block a later refund with a missing-field error. See Accept a payment. - Drive your customer communication ("your refund is on its way" vs "refund complete") off the
refund
statustransitions from the webhook, not off the create call returning2xx. - Reconcile refunds in your exports the same way as collections; each refund row references
its
payment_id. See Money integrity.
Next steps
You can issue and track a refund. Related paths:
- Handle webhooks: drive customer messaging off the refund
statusevents, not the create response. - Disputes guide: when a refund is not enough and a payment is formally contested.
- API reference: Refunds: every field and the full refund object.
TensorRail, Limassol, Cyprus.