Skip to main content

Refunds via the Reporting API

The Reporting API has its own refund surface at /v1/refunds. It creates the same refund as POST /refunds on the main API, but 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.

Which refund surface should I use?

Main API — /refundsReporting API — /v1/refunds
Base URL / authapi.tensorrail.com, api-key headerapi.tensorrail.com, Authorization: Bearer
Object returnedThe processing refund (ref_*, status: pending → succeeded/failed)The ledger record (numeric id, fee fields, balance impact)
Best forThe standard integration: refund, then confirm via refund_succeeded webhookProgrammatic refunds when you also reconcile balances via the Reporting API

Use one surface per refund — both roads lead to the same refund being processed, and the two records are linked: the ledger record's refund_id field carries the same ref_* identifier the main API returns, so you can join the two views on it.

Create a refund

POST /v1/refunds — scope refunds:write

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 can never exceed the captured amount, even under concurrent requests.

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. The engine-surface POST /refunds is not behind this flag, so an integration built against that route keeps working either way.

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 returned by POST /refunds. Empty until the refund is accepted for processing
fee_amount_minorintegerThe refund fee applied to your balance, in minor units (0 when no fee applies)
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 or could not be processed; ledger impact reversed (terminal)
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, refund reached a terminal state → 200 with the existing refund.
  • Same key + same body, refund still in flight → 409 ERR_3032 (refund_in_flight, retriable) — retry with the same key to fetch the result.
  • Same key + different body → 409 ERR_3002 (duplicate_idempotency_key).

A replayed response served from the short-term cache also carries the Idempotent-Replay: true header.

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_2001400Missing Idempotency-Key, payment_id, or a positive amount_minor
ERR_3002409Idempotency key reused with a different body
ERR_3032409A refund with this key is still being processed; retry shortly
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 the remaining refundable balance on the payment
ERR_3039422The refund was rejected during processing
ERR_3017404Refund id not found (GET /v1/refunds/{id})
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.