Skip to main content
A payout moves money off your SouthPay balance. You can send crypto on-chain to a wallet you control, or off-ramp USDT and USDC to a bank account. Payouts arrive two ways: on demand through POST /payouts, or automatically from a payout schedule. Both produce the same resource. Paths are relative to https://api.southpay.io/api/v2.

Before you can pay out

POST /payouts needs a payout destination: a saved bank account or wallet address, referenced by fiat_payout_detail_id or crypto_payout_detail_id. You cannot create one over the public API; they are configured in the dashboard. See payout destinations for what each kind is, where the ids come from, and what a fiat destination requires. The crypto path also accepts a raw destination_address instead of a saved detail id, which skips the saved-destination requirement but not the address validation.

Authentication and scope

Reads require payouts:read, writes require payouts:write. Publishable keys cannot call any of these endpoints.
Creating a crypto payout runs a step-up MFA check. For API keys and OAuth tokens that check is a no-op and never fires. It only gates dashboard sessions, which return 403 mfa_required without two-factor enabled, or 503 mfa_check_unavailable if the identity provider is down. The same applies to POST /balance_swaps and POST /payout_schedules/{id}/run.

Create a payout

POST /payouts. Scope payouts:write. Idempotency-Key is required. Parameters are sent at the top level, not wrapped in an object.
string
required
The asset to send, for example ETH_USDC. Must be an active asset in your current mode.
string
required
Amount in atomic units of asset_id, as a string. Must be positive.
string
Off-ramp to this saved bank account. Sending this selects the fiat path.
string
Send on-chain to this saved wallet. Its network must match the asset’s network.
string
Send on-chain to a raw address. Used only when crypto_payout_detail_id is absent.
Send exactly one destination. If fiat_payout_detail_id is present the payout takes the fiat path; otherwise it takes the crypto path and needs either crypto_payout_detail_id or destination_address.
Live-mode payouts are irreversible once broadcast. A replayed Idempotency-Key returns the original payout with status 201, so a retry after a network timeout is safe, but only if you send the same key. A new key sends a second payout.

Fiat off-ramp constraints

  • The store must have confirmed KYC. Otherwise 403 {"error": "Store KYC verification required"}.
  • Only USDT and USDC can be off-ramped. Anything else returns 422 {"error": "BTC is not eligible for fiat off-ramp"}.
  • Minimums are 10 USDT and 0.1 USDC. Below that: 422 {"error": "Minimum amount for USDT fiat payout is 10 USDT"}.

Response fields

string
required
Bare UUID.
string
required
pending, submitted, confirmed, failed, retrying or requires_review. See lifecycle.
string
required
crypto (on-chain), fiat_manual (off-ramp from an existing stablecoin balance) or fiat_autoswap (swap to a stablecoin, then off-ramp). POST /payouts produces crypto or fiat_manual; fiat_autoswap comes from schedules.
string | null
Set when the payout was created by a schedule.
string
required
Atomic units of asset.asset_id.
string
required
The same amount as a human decimal string with trailing zeros stripped.
string | null
On-chain destination. Null on fiat payouts.
string | null
On-chain hash, once broadcast.
string | null
Custody provider’s own reference.
string | null
Network fee charged for a crypto payout, as a decimal string. Null until known.
string | null
Currency of the receiving bank account. Fiat payouts only.
integer | null
What actually landed in the bank account, in minor units. Only known after settlement.
string | null
The rate actually applied, which may differ from the quoted rate.
integer | null
Off-ramp fee actually charged.
string | null
The bank reference on the outgoing transfer. Use this to match a SouthPay payout to a line on your bank statement.
string | null
Settlement timestamp.
object[]
required
Synthesized history, oldest first. Retries append a further submitted/failed pair rather than replacing earlier entries.
string | null
Null unless status is failed. One of insufficient_funds, destination_invalid, temporarily_unavailable, unknown. Branch on this, not on failure_reason.
string | null
The provider’s raw error text. Free-form and not stable. For humans only.
object
required

Quote a fiat payout

POST /payouts/quote. Scope payouts:write. Fiat only. Creates nothing.
string
required
Must be a USDT or USDC asset.
string
required
Atomic units. Must be positive.
string
required
The bank account you would pay out to. Its currency drives the quote.
estimated_fiat_cents and estimated_fee_cents are estimates. What actually lands is reported as paid_fiat_amount_cents and paid_fee_cents on the payout. Check meets_minimum before creating: a quote is still returned when the amount is below the minimum, but the payout would be rejected.

Retrieve a payout

GET /payouts/{id}. Scope payouts:read. Returns a bare payout object. Unknown id returns 404 {"error": "Payout not found"}.

List payouts

GET /payouts. Scope payouts:read. Newest first.
string
Filter by payout status.
string
crypto, fiat_manual or fiat_autoswap.
string
Only payouts produced by one schedule.
string
Only payouts of one asset.
integer
default:"1"
integer
default:"25"
Between 1 and 100.
Response 200
Payouts wrap the page in pagination, while payments and billing use meta, and balances and payout schedules return bare arrays. A shared client helper that assumes one envelope will silently read an empty page.

Retry a payout

POST /payouts/{id}/retry. Scope payouts:write. No body, no MFA check. Only a failed payout whose funds have not already been returned to your balance can be retried. Returns 200 with the updated payout.

Lifecycle

There are no payout webhooks. payout.created, payout.confirmed and payout.failed appear in the subscribable event list and pass validation, but no code path ever emits them. Subscribing to them produces silence. Poll GET /payouts/{id} or GET /payouts?status=… instead.

Create errors

Every one of these uses the flat {"error": "<message>"} envelope, except the retry responses above, which add a code. Errors raised deeper in the service layer, most notably from POST /payout_schedules/{id}/run, surface through the global handler as {"error": {"message": "…", "code": null}}. Handle both.
Last modified on August 21, 2026