decimal = atomic / 10^atomic_decimals.
Paths are relative to https://api.southpay.io/api/v2.
Authentication and scope
GET requires refunds:read, POST requires refunds:write. Note that this is a different resource from payments, even though the path is nested under a payment intent. A key holding only payments:write cannot refund.
Agents use a separate endpoint with different error semantics. See Agentic API.
Where refunds come from
Automatic. When a payment is rejected, either overpaid on a store that does not accept overpayments or underpaid past expiry on a store that does not accept underpayments, SouthPay refunds the confirmed funds itself. These carryreason failed_overpaid or failed_underpaid.
Requested. You call POST /payments/{id}/refunds. These carry reason merchant_requested.
List refunds for a payment intent
GET /payments/{payment_intent_id}/refunds. Scope refunds:read.
Returns a bare array, newest first. No envelope, no pagination.
Request a refund
POST /payments/{payment_intent_id}/refunds. Scope refunds:write. Idempotency-Key is required.
An intent is refundable while its status is completed, failed or expired and it still holds confirmed funds that have not already been refunded.
All fields go inside an optional refund object. Sending them at the top level also works.
string
Decimal amount in major units of the asset. Omit to refund the full remaining balance. Only valid for
completed intents.string
Restrict the refund to one asset when the intent received several. Required alongside
amount or destination_address in that case.string
Send the refund somewhere other than the address the customer paid from. Must be valid for the asset’s chain and must not be a SouthPay deposit address.
amount and asset_id selection apply only to completed intents. For failed or expired intents the endpoint refunds the full confirmed balance across every asset, and sending amount returns 422.Idempotency behaviour
YourIdempotency-Key is not used verbatim. SouthPay derives a per-asset key from it, so a payment that received two assets produces two refund records under one key you sent. Replaying the same key returns the existing refunds, still with status 201, not 200.
For failed and expired intents the response contains every refund on the intent, not only the ones this call created.
If an earlier refund already failed, sending a new destination_address starts a fresh attempt to that address. While a refund is pending or submitted, the existing record is returned instead of a second one being created.
Response fields
string
required
Bare UUID.
string
required
pending, submitted, confirmed or failed.string
required
merchant_requested, failed_underpaid or failed_overpaid.string
Where the funds are being sent.
string
required
Atomic units of
asset_id.string | null
Null until the refund is broadcast.
integer
required
Broadcast attempts made so far.
string
required
string
required
string
required
integer
required
Divisor exponent for converting
amount_atomic to a decimal.Refund lifecycle
When a refund reaches
confirmed, its payment intent moves to refunded with state_reason: "refund_completed" and fires payment_intent.refunded. Subscribe to that webhook rather than polling.
Errors
Every failure on this endpoint returns422 with the flat envelope {"error": "<message>"}. The value is the human-readable sentence below. It is not a machine-readable code and not an internal identifier.
A
404 for an unknown payment intent comes from the global handler and uses the structured envelope instead: {"error": {"message": "…", "code": null}}. Handle both shapes.
Only the agentic refund endpoint returns machine-readable codes:
not_refundable, nothing_to_refund, invalid_amount, asset_not_found, ambiguous_asset, invalid_destination, insufficient_balance. This endpoint returns the message text only, so match on status code and endpoint, never on the string.
