https://api.southpay.io, and no sandbox subdomain. Mode is carried on the credential you send.
Every query is filtered by the mode on the credential, so the two datasets never mix. Test payments, balances, payouts, webhook endpoints and their signing secrets are all separate records. A test key cannot see a live payment and returns
404 if you ask for one by id.
OAuth is not a test-only mechanism. A grant carries the mode chosen at authorize time, and live-mode OAuth tokens work exactly like live API keys. See Authentication.
Test mode has one asset
This is the biggest limitation of test mode and the one that catches integrations at launch. What test mode does cover well: the intent lifecycle, expiry, webhook signing and delivery, idempotency, scopes and error handling. That is most of an integration.Paying a test intent
There are three ways to pay a test intent. Simulate it, which needs no chain at all and is what most development and automated testing should use. Have SouthPay pay it from the test wallet, which puts a real transaction on Sepolia without costing you a faucet claim. Or send Sepolia ETH from your own wallet. Simulation is the fastest and the only one that does not wait for confirmations. Reach for one of the other two when the chain itself is what you are testing.Simulate a payment
POST /payments/{id}/simulated_payments credits a payment as if a deposit had arrived. Nothing is broadcast, so there is no testnet transaction, no faucet balance to exhaust and no confirmation to wait for. The deposit runs through the same pipeline a real one does, so the payment transactions, status transitions, events and webhook deliveries are the ones you will see in live mode. Only the origin of the deposit is invented.
201 Created and the payment has already been re-evaluated by the time it returns, so payment_status on the body is the settled status rather than a status you have to wait for.
string
default:"exact"
Which outcome to produce:
exact, underpaid, overpaid, unconfirmed or failed.string
Amount to credit, in the currency the payment is priced in, such as
12.50. Converted at the payment’s own rate, so half the settlement amount credits exactly half the required deposit. Overrides the amount the scenario would have chosen.string
Amount to credit, in the deposit asset’s own units, such as
0.0125.string
Amount to credit, in the deposit asset’s smallest unit. The exact form, for a test that asserts on a precise figure.
simulated_payment_ambiguous_amount. settlement_amount is usually the one you want, because it is the same figure you priced the payment in.
string
Names the simulated deposit. Reuse a reference to update the deposit it already created; use distinct references to credit several deposits against one payment. Defaults to a random value.
integer
How many confirmations the deposit reports. Only the
unconfirmed scenario reads it, and it is clamped below the required count so the deposit stays unconfirmed.overpaid deliberately clears the tolerance, so it trips the overpayment path on a one-dollar payment as reliably as on a large one.
Partial deposits and confirmations
A freshreference on each call credits a separate deposit, which is how you build a payment up from partial payments:
processing with reason pending_confirmations. The second confirms the same deposit and the payment completes.
Simulation errors
Simulation is available in the dashboard under Developers → Test payments → Simulate, which is the quickest way to drive a payment to a given state by hand. It does not change which assets test mode offers; the single-asset limit above still applies.
Pay from the test wallet
POST /payments/{id}/test_payments sends Sepolia ETH from a SouthPay-funded wallet to the deposit address on the intent. You do not need a wallet, a faucet or a balance of your own.
202 Accepted. The transfer is broadcast, not yet confirmed, so the intent is still pending when the call returns and moves to processing and then completed as the transaction confirms, exactly as it would for a customer payment. Watch your webhook endpoint, not the response body.
Send amount_atomic to pay something other than the exact amount. Below the required amount produces a real underpayment on chain:
amount_atomic and required_atomic, so you can assert against the amount your own checkout displayed rather than against the amount SouthPay chose to send.
Because the wallet holds a finite balance, the endpoint is bounded. A single payment above the per-payment cap is refused with 422 and code test_payment_amount_too_large. A store that exhausts its rolling daily allowance is refused with 429. If the wallet itself runs dry the call returns 503 with code test_payment_faucet_unfunded, and remaining_daily_atomic on every successful response tells you how much of your allowance is left.
The test wallet has an allowance per store per day. Price test intents low, and keep your own funded wallet for a load test. Live credentials are refused with
403 and code test_payment_live_mode.Send from your own wallet
- Create a payment intent with a test key.
- Take
payment_addresses[0].addressandpayment_addresses[0].crypto_amount_atomicfrom the response. - Fund a Sepolia wallet from any public faucet. See Sepolia test wallet for the network details, wallet setup and a list of faucets.
- Send exactly
crypto_amount_atomic / 10 ** 18ETH to that address.
pending → processing → completed as the transaction confirms, and your webhook endpoint receives each transition. Leave the intent alone for an hour to exercise expiry.
The test wallet is for integration and webhook tests, where you want a real intent to settle from a real transaction and your endpoint to receive real deliveries. It is not a substitute for stubbing the SouthPay client in unit tests, which should not make network calls at all.
Testing webhooks
Deliveries in test mode are real HTTP requests to a real URL. Your endpoint must be publicly reachable. Outbound deliveries to private, loopback, link-local and unique-local addresses are blocked before the request is sent, and the block marks your endpointerror. Use a tunnel, not localhost.
Fire a synthetic delivery without paying anything:
event_type defaults to the first event on the endpoint’s subscription list, falling back to payment_intent.completed. An unrecognised name returns 422.
Going live
Test and live are separate in every dimension, so nothing carries over. Work through this before you flip.Before switching to live mode, confirm:
- A live secret key exists, created in the dashboard, stored in your secrets manager
- A live webhook endpoint is registered, since test endpoints do not receive live events
- The live signing secret is deployed; it is a different value from the test one
- Your handler verifies
Southpay-Signatureagainst the raw body with a timing-safe comparison - Your handler returns
2xxfor events it ignores, since any non-2xx is retried ten times - Your handler deduplicates on the event
id -
completed,failed,expiredandrefundedall have a code path - Every money-moving
POSTsends anIdempotency-Key, and live keys cannot collide with test keys - A payout destination is configured and selected
- The assets you expect are enabled on the store, which test mode never showed you
Configuration
Keep both credential sets in the environment, never in source..env.test
.env.production

