Skip to main content
Everything here comes from a failure mode this API actually has. Each item says what goes wrong if you skip it.

Parse errors before you parse anything else

The API returns three different error bodies, and error is a string more often than it is an object. A client that does body.error.message throws on the majority of 4xx responses, and it throws inside your error handler, which is where you are least likely to have coverage. Write the normalizer from Errors on day one. Log status, code and message together; code is null on every framework-rescued failure, so it is never enough on its own.

Persist the idempotency key before you send the request

The point of Idempotency-Key is surviving the case where you do not know whether the request landed. That only works if the key survives the same event.
Generate the key once, write it down, then send. If the process dies mid-flight, the next run replays the stored key and gets the original intent back instead of creating a second one. Two traps specific to SouthPay: keys never expire, so a key tied to an order id alone will hand a customer last month’s dead intent on a retry; and payout keys are not scoped to your store, so make them globally unique. Idempotency has the details.

Return 2xx for events you ignore

Any non-2xx response is retried up to ten times over about eighteen hours. There is no “client errors are not retried” rule here, so a handler that returns 400 on an event type it does not recognise gets hammered for every one of them, and eventually the endpoint flips to status: "error" and stops delivering the events you do care about.
Acknowledge first, work later. Your read timeout is 30 seconds and anything slower is a retry.

Verify signatures against the raw body

An unverified webhook endpoint is a fulfilment endpoint for anyone who finds the URL. Two details break most implementations:
  • Compute the HMAC over the raw request bytes. Parsing and re-serializing JSON changes whitespace and key order, and the digest will not match.
  • Key the HMAC with the secret exactly as issued, whsec_ prefix included. Do not strip it and do not base64-decode it.
Use a timing-safe comparison, never ===. SouthPay does not enforce a timestamp tolerance outbound, so add your own if you want replay protection. Webhooks has verified code in four languages.

Claim the event id before doing the work

Delivery is at-least-once. Deduplicate on the envelope id, and claim it atomically before fulfilment, not after. Two concurrent retries can otherwise both pass a read-then-write check.

Do not treat terminal: true as immutable

terminal covers completed, expired, failed and refunded. It means the outcome is settled. It does not mean the record is frozen. All four can still move to refunded, and an underpaid intent that failed is refunded automatically. Handle payment_intent.refunded as an update to an order you already shipped. If your fulfilment is irreversible, that event needs a human in the loop, not a silent status write.

Branch on state_reason, not just status

An overpayment is status: "completed" with state_reason: "completed_overpaid". So is an exact payment, minus the reason. If you only read status, you ship the order and never notice you owe the customer money. The excess is on the intent’s detail view under overpayment. The same applies to completed_underpaid, which is a completion your store opted into, and to canceled_by_agent and canceled_by_merchant, which both arrive as expired. The full table is on Payment lifecycle.

Handle all four terminal outcomes

Poll for payouts

Five webhook event names (payout.created, payout.confirmed, payout.failed, fee_sweep.confirmed, fee_sweep.failed) pass subscription validation and are never emitted. Subscribing to them is silent and permanent failure. Poll GET /api/v2/payouts/{id} and read status, failure_code and state_transitions instead. See Webhooks.

Store id, reference and expires_at at creation

The id is what you call the API with. The reference is what arrives in webhooks, as data.object.id. If you only store one you will need a lookup you did not plan for, in the middle of a fulfilment path.
Trust expires_at over status. Expiry is applied lazily, so a pending intent in your database may already be dead.

Use metadata for correlation, and keep the values strings

Metadata is returned on the intent and in every webhook, which makes it the cheapest way to get from an event back to your order. It takes up to 50 keys and every value must be a string. A number or a nested object is a 422.
If you need to filter on it later, use the top-level order_id field instead. That one is indexed and queryable; metadata is not.

Scope keys down, and never ship a secret key to a browser

A new secret key gets every scope. Restrict it when you create it. A key that only creates payments cannot issue a payout if it leaks. For anything client-side, use a publishable key. It can call exactly one endpoint, POST /api/v2/payments, and returns 403 publishable_key_forbidden everywhere else. That restriction is the entire reason it is safe to embed.

Rotate webhook secrets in the right order

Rotation is an immediate hard cutover with no overlap window. The old secret verifies nothing the moment the request returns, including retries of events already queued. Deploy so your running processes can pick up a new secret, rotate, then confirm. Rotating first guarantees a window of failed verifications, and those count as non-2xx, so they get retried for eighteen hours.

Test what test mode can actually test

Test mode has exactly one asset, Ethereum Sepolia. It exercises the lifecycle, webhooks, idempotency, scopes and errors well. It cannot exercise multi-asset checkout, stablecoin payments, Bitcoin or Solana deposits, or fiat off-ramp eligibility. Plan a small live-mode payment as part of your launch, and never point staging at a live key. See Environments.

Before you go live

Nothing carries over from test mode: not the key, not the endpoint, not the signing secret.
Confirm every line:
  • A live secret key exists, scoped to what the integration needs, stored in a secrets manager
  • A live webhook endpoint is registered separately from the test one
  • The handler verifies Southpay-Signature against the raw body with a timing-safe comparison
  • The handler returns 2xx for events it ignores and responds inside 30 seconds
  • The handler claims the event id before fulfilment
  • completed, failed, expired and refunded all have a code path, and state_reason is read on completion
  • Every money-moving POST sends an Idempotency-Key, persisted before the request and never shared between modes
  • Error handling reads all three response shapes
  • A payout destination is configured and selected
  • The assets you expect are enabled on the store, verified in live mode
Last modified on August 31, 2026