Refunds via the Reporting API
The Reporting API refund surface at /v1/refunds is where every refund is created. The object it returns is the account-ledger view of that refund: it carries the refund fee applied to your balance and is listable account-wide alongside your balances. The Refunds guide walks through the flow.
Two views of one refund
Reporting API: /v1/refunds | Main API: /refunds/{refund_id} | |
|---|---|---|
| Base URL / auth | api.tensorrail.com, Authorization: Bearer | api.tensorrail.com, api-key header |
| What it does | Creates refunds; retrieves and lists them | Retrieves, updates and lists refunds; does not create them |
| Object returned | The ledger record (numeric id, fee fields, balance impact) | The processing refund (ref_*, status: pending → succeeded/failed) |
The two records are linked: the ledger record's refund_id field carries the same ref_* identifier the main API's refund carries, so you can join the two views on it. Refunds are created only with POST /v1/refunds: the main API's POST /refunds no longer creates them (it answers 404 with ERR_3012).
Create a refund
POST /v1/refunds: scope refunds:write. This /v1 surface is gateway-built, not generated from the engine's schema, so the reference operation above carries no field list of its own: its description points back to this page, and the fields are documented here.
The Idempotency-Key header is required (a missing key returns ERR_2001). The refundable amount is enforced: the sum of succeeded and in-flight refunds of a payment, however they were made (including refunds returned as a payout or by manual transfer), can never exceed the captured amount, even under concurrent requests. On a payment the payer settled short, the captured amount is what arrived, not what was asked for.
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer rail_… |
Idempotency-Key | Yes | Reuse the same key to retry safely; see behavior below |
Body
| Field | Type | Required | Description |
|---|---|---|---|
payment_id | string | Yes | The captured payment to refund |
amount_minor | integer | Yes | Refund amount in minor units, > 0 |
currency | string | No | Must match the payment's currency; defaults to it |
reason | string | No | Free-text reason, stored on the record |
/v1/refunds requires refunds enabled on your account. Until it is, the call returns
403 ERR_1012: Refunds are not enabled for this account, which no request change will fix;
ask support to enable it.
curl -X POST "https://api.tensorrail.com/v1/refunds" \
-H "Authorization: Bearer rail_full_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-12345" \
-d '{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"amount_minor": 5000,
"reason": "requested_by_customer"
}'
Response 200 OK
{
"refund": {
"id": 314,
"status": "accepted",
"amount_minor": 5000,
"currency": "EUR",
"parent_payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"refund_id": "ref_xyz789",
"fee_amount_minor": 25,
"fee_type": "voluntary_refund",
"profile_id": "pro_...",
"reason": "requested_by_customer",
"idempotency_key": "refund-order-12345",
"created_at": "2026-08-03T10:00:00.000000000Z"
}
}
| Field | Type | Description |
|---|---|---|
id | integer | The ledger refund id; use it with GET /v1/refunds/{id} |
status | string | See statuses |
amount_minor / currency | integer / string | The refund amount |
parent_payment_id | string | The refunded payment |
refund_id | string | The refund's identifier in the main API (ref_*), as GET /refunds/{refund_id} returns it. Empty until the refund is accepted for processing |
fee_amount_minor | integer | The refund fee applied to your balance, in minor units, always in the refund's own currency. 0 means no fee is charged for refunds in that currency. A currency with no refund fee configured at all is a different case: the refund is refused with ERR_3069 and nothing is created |
fee_type | string | Fee classification, e.g. voluntary_refund |
idempotency_key | string | The key this refund was created under |
created_at | string | RFC 3339 timestamp |
The refund is recorded against your balance immediately; the final outcome then arrives asynchronously (poll GET /v1/refunds/{id} or subscribe to the refund_succeeded / refund_failed webhooks). If the refund is rejected outright, the ledger entries are reversed and the record ends failed.
Refund statuses
| Status | Meaning |
|---|---|
initiated | Recorded on your ledger; being submitted for processing |
accepted | Accepted for processing; refund_id is now set |
pending | Processing in progress |
succeeded | Refund completed (terminal) |
failed | Refund rejected by the payment method; ledger impact reversed (terminal). A refund whose outcome is not yet confirmed is never marked failed: it stays initiated or accepted, with its amount reserved, until the outcome is known |
cancelled | Refund cancelled (terminal) |
Idempotency behavior
The Idempotency-Key is persisted with the refund, so it deduplicates across retries indefinitely (not just within the 24-hour replay window):
- Same key + same body →
200with the refund in its current status, whatever that is (initiated,accepted,succeeded,failed,cancelled), and theIdempotent-Replay: trueheader. Nothing is created, reserved or charged again. If the answer to your first request was lost, a retry is how you learn the refund'sid. - Two requests with the same key and body sent at the same time create one refund, and both are answered with it.
- Same key + different body →
409ERR_3002(duplicate_idempotency_key). - A request that was refused (for example
ERR_3035) records nothing against its key: once the cause is gone, the same key and body create the refund. - Once refunds are switched off for your account, every request is answered
403ERR_1012, a retry of a refund you already made included. That403does not mean an earlier request with the key made no refund:GET /v1/refundslists the refunds you made.
A refund's outcome can take longer to establish while it is being confirmed with the payment method. Until then a retry, like GET /v1/refunds/{id}, shows it as initiated or accepted, never failed.
Retrieve and list refunds
GET /v1/refunds/{id} and GET /v1/refunds: scope refunds:read
curl "https://api.tensorrail.com/v1/refunds?limit=50&offset=0" \
-H "Authorization: Bearer rail_full_live_xxx"
The list returns data (array of refund objects as above) plus a pagination envelope (total, limit, offset), the same shape as /v1/transactions. limit defaults to 50, max 500.
GET /v1/refunds/{id} returns {"refund": {...}} or ERR_3017 (404) when the id does not exist on your account.
Errors
| Code | HTTP | Cause |
|---|---|---|
ERR_1012 | 403 | Refunds are not enabled for your account |
ERR_2001 | 400 | Missing Idempotency-Key, payment_id, or a positive amount_minor |
ERR_3002 | 409 | Idempotency key reused with a different body |
ERR_3033 | 404 | The payment was not found on your account |
ERR_3034 | 422 | The payment is not in a refundable state (must be captured/succeeded) |
ERR_3035 | 422 | Amount exceeds what is left to refund on the payment: its captured amount less the refunds that succeeded or are in flight |
ERR_3066 | 422 | The payment's method has no refund API. See Methods without a refund API |
ERR_3069 | 422 | No refund fee is configured in this payment's currency, so the refund cannot be priced. The refund was not created, nothing was reserved and nothing was charged. Contact your account manager |
ERR_3017 | 404 | Refund id not found (GET /v1/refunds/{id}) |
A refund can also fail after it was accepted. POST /v1/refunds answering 2xx means the
refund was recorded and reserved, not that the money has moved; the rail is asked afterwards.
If the rail rejects it, the reservation is unwound and the refund's status becomes failed;
nothing is left half-posted and no HTTP error is returned to the original call, because that
call already completed. A refund whose outcome cannot be confirmed yet (for example, the rail
did not answer in time) is not failed: it stays initiated or accepted with its amount
reserved while the outcome is established, however long that takes, and a timeout never
turns it into failed. Watch the refund_failed webhook, or poll GET /v1/refunds/{id}.
ERR_3039 appears in the error catalog for this case and is recorded
internally; it is not returned by any endpoint on this page, which is why it is not in the
table above.
Refunds issued from the dashboard require a fresh two-factor verification of the operator. API-key calls are not subject to this: the key's refunds:write scope is the authority.
Related
- Refunds: the refund guide, and the main API's refund reads
- Reporting API: base URL and auth
- Balances: the balance the fee applies to
- Idempotency
- Error catalog