Skip to main content

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/refundsMain API: /refunds/{refund_id}
Base URL / authapi.tensorrail.com, Authorization: Bearerapi.tensorrail.com, api-key header
What it doesCreates refunds; retrieves and lists themRetrieves, updates and lists refunds; does not create them
Object returnedThe 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

HeaderRequiredDescription
AuthorizationYesBearer rail_…
Idempotency-KeyYesReuse the same key to retry safely; see behavior below

Body

FieldTypeRequiredDescription
payment_idstringYesThe captured payment to refund
amount_minorintegerYesRefund amount in minor units, > 0
currencystringNoMust match the payment's currency; defaults to it
reasonstringNoFree-text reason, stored on the record
This endpoint is gated per account

/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"
}
}
FieldTypeDescription
idintegerThe ledger refund id; use it with GET /v1/refunds/{id}
statusstringSee statuses
amount_minor / currencyinteger / stringThe refund amount
parent_payment_idstringThe refunded payment
refund_idstringThe 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_minorintegerThe 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_typestringFee classification, e.g. voluntary_refund
idempotency_keystringThe key this refund was created under
created_atstringRFC 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​

StatusMeaning
initiatedRecorded on your ledger; being submitted for processing
acceptedAccepted for processing; refund_id is now set
pendingProcessing in progress
succeededRefund completed (terminal)
failedRefund 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
cancelledRefund 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 → 200 with the refund in its current status, whatever that is (initiated, accepted, succeeded, failed, cancelled), and the Idempotent-Replay: true header. Nothing is created, reserved or charged again. If the answer to your first request was lost, a retry is how you learn the refund's id.
  • 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 → 409 ERR_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 403 ERR_1012, a retry of a refund you already made included. That 403 does not mean an earlier request with the key made no refund: GET /v1/refunds lists 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​

CodeHTTPCause
ERR_1012403Refunds are not enabled for your account
ERR_2001400Missing Idempotency-Key, payment_id, or a positive amount_minor
ERR_3002409Idempotency key reused with a different body
ERR_3033404The payment was not found on your account
ERR_3034422The payment is not in a refundable state (must be captured/succeeded)
ERR_3035422Amount exceeds what is left to refund on the payment: its captured amount less the refunds that succeeded or are in flight
ERR_3066422The payment's method has no refund API. See Methods without a refund API
ERR_3069422No 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_3017404Refund 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.

Dashboard refunds

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.