Refunds
Refund a payment in full or in part with POST /refunds. 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.
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. The account flag gates the Reporting API's refund surface:
if refunds are not enabled, POST /v1/refunds returns 403 with code
ERR_1012 and the message "Refunds are not enabled for this account. Contact support.",
regardless of the payment or the amount. POST /refunds — the endpoint this page documents —
is not behind that flag, so an integration built against this route keeps working either way.
Either way, 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. Your dashboard shows what is enabled on your
account; ask us and we will confirm refundability for the methods you plan to use.
GET /v1/capabilities answers a different
question — it is the unauthenticated, platform-wide catalogue of what TensorRail supports, not
a per-account list.
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. Where the API cannot carry the reversal for a given method, returning the money out of band and reconciling it against the original collection remains an option. The sandbox exercises the full refund path, so you can build and test the flow while method-level availability is being confirmed.
Create a refund
- curl
- Node
- Python
curl -X POST https://api.tensorrail.com/refunds \
-H "api-key: rail_full_test_xxx" \
-H "Idempotency-Key: 9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10" \
-H "Content-Type: application/json" \
-d '{
"payment_id": "pay_N5c…",
"amount": 50000,
"reason": "requested_by_customer",
"metadata": { "rma": "RMA-4471" }
}'
const response = await fetch("https://api.tensorrail.com/refunds", {
method: "POST",
headers: {
"api-key": "rail_full_test_xxx",
"Idempotency-Key": "9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10",
"Content-Type": "application/json",
},
body: JSON.stringify({
payment_id: "pay_N5c…",
amount: 50000,
reason: "requested_by_customer",
metadata: { rma: "RMA-4471" },
}),
});
const refund = await response.json();
import requests
response = requests.post(
"https://api.tensorrail.com/refunds",
headers={
"api-key": "rail_full_test_xxx",
"Idempotency-Key": "9b1c7f4e-2d6a-4f8b-8c1e-3a5d9e7b2f10",
},
json={
"payment_id": "pay_N5c…",
"amount": 50000,
"reason": "requested_by_customer",
"metadata": {"rma": "RMA-4471"},
},
)
refund = response.json()
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id | string | Yes | The payment to refund |
amount | integer | No | Minor units; defaults to the full amount. Must be a positive integer, not exceeding the unrefunded remainder |
refund_id | string | No | Your id for the refund; auto-generated if omitted; creation is idempotent on it |
reason | string | No | Some methods require duplicate, fraudulent, or requested_by_customer |
refund_type | instant | scheduled | No | Default instant |
metadata | object | No | Your own key-value data |
The response
{
"refund_id": "ref_8Kj2…",
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"amount": 50000,
"currency": "INR",
"status": "pending",
"reason": "requested_by_customer",
"metadata": { "rma": "RMA-4471" },
"error_code": null,
"error_message": null,
"unified_code": null,
"unified_message": null,
"created_at": "2026-07-22T10:15:00.000Z",
"updated_at": "2026-07-22T10:15:00.000Z"
}
Refunds are asynchronous like collections: the create response is usually pending, and the
terminal outcome arrives as a refund_succeeded / refund_failed
webhook (and on GET /refunds/{refund_id}).
Idempotency (live)
Refund creation carries the same live idempotency contract as payment creation:
- Send an
Idempotency-Keyheader; retrying with the same key returns the original refund, so a network retry can never refund twice. - Reusing a key with a different body returns that same original refund too — this surface
does not compare bodies, so the second body is silently ignored rather than rejected. Use one
key per logical refund. (
POST /v1/refundson the Reporting API does compare bodies and rejects a mismatch withERR_3002.) - You can also supply your own
refund_id; creation is idempotent on it, which is the natural fit when your system already has a returns reference (like an RMA number).
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 amount, and repeat for multiple partials.
Give each partial its own refund_id (or 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/refunds \
-H "api-key: rail_full_test_xxx" \
-H "Idempotency-Key: 41d2a6a0-88a7-4b0d-9d3e-7c2f8b4a1c55" \
-H "Content-Type: application/json" \
-d '{ "payment_id": "pay_N5c…", "amount": 15000, "refund_id": "RMA-4471-part-1" }'
Guardrails the platform enforces for you:
- The total refunded can never exceed the captured amount; exceeding it is rejected with
ERR_3005(refund_exceeds_capture). - Each refund must be at least
100minor units; below that the request is rejected as too small. - There is a limit on the number of partial refunds per payment (
ERR_3025when exceeded). - Refunding a payment that is not in a refundable state returns a business-state error rather than silently queueing.
The refund object
The stable field set on every refund:
refund_id, payment_id, amount, currency, status, reason, metadata, error_code,
error_message, unified_code, unified_message, created_at, updated_at.
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.
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.