Skip to main content
There is one API host, 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.
Test mode gives you Ethereum Sepolia (SETH) and nothing else. Sepolia USDT and USDC exist in the catalogue but are seeded inactive, so every test payment intent comes back with a single deposit address on Sepolia regardless of which assets your store has enabled in live mode.You cannot test multi-asset checkout, a Bitcoin or Solana deposit, a stablecoin payment, an ERC-20 token flow, or fiat off-ramp eligibility in test mode. Anything that depends on the asset mix has to be validated in live mode with a small real payment.
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.
The response is 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.
Send at most one of the three. Sending two is refused with 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.
Each scenario credits an amount relative to what the payment still needs, so it behaves the same whether the payment is untouched or already part paid. 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 fresh reference on each call credits a separate deposit, which is how you build a payment up from partial payments:
Reusing a reference updates the deposit it already created rather than adding another, which is how a real deposit gains confirmations. Simulate the wait, then resolve it:
The first call leaves the payment 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.
The response is 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:
The response echoes both 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

  1. Create a payment intent with a test key.
  2. Take payment_addresses[0].address and payment_addresses[0].crypto_amount_atomic from the response.
  3. Fund a Sepolia wallet from any public faucet. See Sepolia test wallet for the network details, wallet setup and a list of faucets.
  4. Send exactly crypto_amount_atomic / 10 ** 18 ETH to that address.
The intent moves 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 endpoint error. 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-Signature against the raw body with a timing-safe comparison
  • Your handler returns 2xx for events it ignores, since any non-2xx is retried ten times
  • Your handler deduplicates on the event id
  • completed, failed, expired and refunded all have a code path
  • Every money-moving POST sends an Idempotency-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
That last idempotency item is worth spelling out.
Reusing an idempotency key across modes fails in a confusing way. The uniqueness constraint on payment intents is per store, not per store and mode, so a key you used in test and then reuse in live returns 404 rather than either creating or replaying. Prefix your keys with the mode.

Configuration

Keep both credential sets in the environment, never in source.
.env.test
.env.production
Because the host and paths are identical in both modes, switching environments is a credential swap. If your staging deploy is pointing at live data, the key is the only thing that told it to.
Last modified on September 16, 2026