Create
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.Converting atomic amounts
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 tohosted_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
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.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 isprocessing, 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:
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.
Response 200
Event history
event_type values match webhook event names exactly, so you can reconcile this log against the deliveries you received.
Response 200
payment_intent.underpaid_detected. It is the more complete record of the two.
Related operations
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.Idempotency-Key header. Without one you get 422.
Integration shape
Persistid, 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.
