Skip to main content

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:

CallAnswer
POST /v1/refunds on any of these methods422 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.

  1. 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.
  2. 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.
  3. You bear the payout fee, on top of the refunded amount.
  4. 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:

methodWhat it means
refund_apiThe ordinary refund, through the refund endpoints. Every refund you create yourself
payoutReturned as a payout. payout_id names the payout carrying it
manual_transferReturned 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 -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"
}'

Request fields​

FieldTypeRequiredNotes
payment_idstringYesThe payment to refund
amount_minorintegerYesMinor units, a positive integer, not exceeding what is left to refund on the payment
currencystringNoMust match the payment's currency; defaults to it
reasonstringNoFree-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 withdrawn

Refunds 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-Key header (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 409 ERR_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 422 ERR_3035 and nothing is created.
  • A refund must be a positive amount; 0 and 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​

StatusMeaning
pendingSubmitted; outcome not yet final
reviewHeld for review
succeededRefunded
failedDid not complete; read unified_code / unified_message

Retrieve, update, and list​

Tips​

  • Send payment_method_type explicitly 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 status transitions from the webhook, not off the create call returning 2xx.
  • 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:

TensorRail, Limassol, Cyprus.