Skip to main content
A refund sends crypto back on-chain, by default to the address the customer paid from. Refunds always belong to a payment intent. One request can produce several refund records, one per asset the intent received, so both endpoints return an array. Amounts you send are decimal major units of the crypto asset. Amounts you read back are atomic-unit strings: 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 carry reason 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.
On-chain transfers are irreversible. A destination_address typo sends real funds to an address nobody controls. Verify it before you send.
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

Your Idempotency-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 returns 422 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.
Last modified on August 21, 2026