Skip to main content
A payment intent is a single payment request. You give it a fiat amount and currency. SouthPay allocates a deposit address for every crypto asset your store has enabled, locks an exchange rate against each one, and returns a hosted checkout URL. Which assets appear is a property of your store, not of the request. You cannot pick them per intent, and the customer chooses one at checkout. See Supported currencies and assets.

Create

Every field goes inside a required payment_intent object.
string
required
Fiat amount in major units, as a decimal string: "149.00". Must be greater than zero. Multiplied by 100 into settlement_amount_cents internally.
string
required
One of USD, EUR, GBP, HKD, SGD, JPY, DKK, CHF, SEK, NOK. Upcased for you. Anything else is 422.
string
Where the hosted checkout sends the customer after a completed payment.
string
Where the hosted checkout sends the customer after a failed or expired payment.
string
Your own order identifier, up to 255 characters. Indexed, so you can filter the list endpoint by it, which metadata does not allow.
string
Shown on the checkout page and the PDF invoice. Up to 150 characters.
string
Shown on the checkout page. Up to 500 characters.
string
Product or order image for the checkout page. Must be https, up to 2048 characters. Returned as display_image_url.
object
Up to 50 keys. Every value must be a string. A number or a nested object returns 422 "metadata values must be strings". Returned verbatim on the intent and in every webhook.
array
Up to 50 items, rendered as an order summary on checkout.
Idempotency-Key is optional here and strongly recommended. Read Idempotency first. A replay returns 201 with the original intent, and a changed amount under the same key is discarded without an error.
EVM assets share one deposit address across chains, so two addresses on the same intent can be the same string. They are still distinct records with distinct amounts and distinct assets. Match a received payment on (address, asset), never on address alone.

Response fields

string (UUID)
required
The identifier for every subsequent API call. A bare UUID, with no pi_ prefix.
string
required
Sixteen uppercase alphanumeric characters. The checkout token, the customer-facing identifier, and the value that arrives as data.object.id in webhooks.
string
The hosted checkout page. Redirect here rather than composing the URL yourself.
string
required
One of six values. See Payment lifecycle.
string | null
Why the intent is in its current status. Ten possible values, including completed_overpaid and canceled_by_agent.
string
The fiat amount, formatted to two decimal places. Note that the webhook envelope carries settlement_amount_cents as an integer instead.
string (ISO 8601)
Sixty minutes after created_at, always. Not configurable.
boolean
true for completed, expired, failed and refunded. Means the outcome is settled, not that the record is frozen. Any of them can still become refunded.
array
One entry per enabled asset that had a live rate at creation time.
string | null
pending, processing, completed or failed. Never settled.
string | null
completed, pending, failed or skipped.
string | null
final, under_investigation, disputed or closed.
An asset with no live rate at creation time is omitted rather than failing the request. If you expect five addresses and get four, a rate lookup failed for the fifth.

Converting atomic amounts

Do not put an atomic amount through Number. An 18-decimal value exceeds Number.MAX_SAFE_INTEGER and you will display the wrong figure.

Failures on create

Most of these arrive as {"error": "<plain string>"}. See Errors.

Send the customer to checkout

Redirect to hosted_url, which resolves to https://pay.southpay.io/{reference}. The page shows a QR code and the exact amount for every address on the intent, updates as confirmations land, and redirects to your success_url on completion. There is no frontend work. GET /api/v2/checkout/{reference} is the JSON behind that page. It is public, unauthenticated, and lazily expires a pending intent whose window has closed. Use it if you are building your own checkout UI; do not send customers to it.

Retrieve

The single-intent read returns a considerably richer object than create does. On top of every field above: transaction.status uses the transaction state set: pending, confirming, confirmed, submitted, failed. confirming is a transaction status and never a payment intent status.

Cancel

Closes a payment’s window early instead of waiting for it to expire on its own.
A pending payment — one where nothing has arrived on-chain yet — becomes expired with state_reason: "canceled_by_merchant". There is no canceled status; see Payment lifecycle. The response is the same detail object as Retrieve, so you can read the resulting status straight off it. Cancelling also pulls expires_at back to now. That is what the status change means: the window is over.

Payments that already have funds

Once any transaction is seen on-chain the payment is processing, and a plain cancel returns 422 — you cannot void money that is already moving. To close it out anyway, force it:
force=true brings the deadline forward and resolves the payment down exactly the path a natural expiry takes:
force=true can therefore complete a payment rather than cancel it, if funds arrived and your store accepts underpayments. Read the status on the response rather than assuming the payment is dead.
Forcing while a transaction is still confirming ends the payment before the chain settles it. A confirmation that lands afterwards has no open intent to attach to and becomes an unmatched deposit requiring manual reconciliation. Prefer letting processing payments finish on their own, and reach for force only when you know nothing further is coming.
A payment that is already completed, expired, failed or refunded returns 422 with or without force. To reverse one of those, create a refund instead. Cancels emit payment_intent.expired, the same event a natural expiry emits — if you already handle expiry you already handle cancels. Branch on state_reason to tell them apart.

List

integer
default:"1"
integer
default:"10"
Maximum 100.
string
One of the six statuses. confirming is accepted but always matches zero rows.
string
Case-insensitive substring match on reference.
string
Exact match.
string
ISO date. Inclusive.
string
ISO date. Inclusive.
Results are newest first, scoped to the credential’s store and mode.
Response 200
Pagination envelopes are not consistent across this API. Payments, billing and platform endpoints use meta. Payouts, balance swaps and x402 use pagination. Balances, payout schedules and billing subscriptions return a bare array with no pagination at all. Do not write one shared pager.

Event history

Returns a bare array, oldest first. event_type values match webhook event names exactly, so you can reconcile this log against the deliveries you received.
Response 200
This log contains events that no webhook endpoint can subscribe to, such as payment_intent.underpaid_detected. It is the more complete record of the two.
The reconciliation route uses the path segment payment_intents, while every other payment route uses payments. That inconsistency is real; the route will 404 if you normalise it.
Creating a refund requires an Idempotency-Key header. Without one you get 422.

Integration shape

Persist id, reference and expires_at before you redirect. The webhook identifies the intent by reference, and you will need the id for every read. See Best practices for the rest.
Last modified on August 31, 2026