Parse errors before you parse anything else
The API returns three different error bodies, anderror 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 ofIdempotency-Key is surviving the case where you do not know whether the request landed. That only works if the key survives the same event.
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.
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.
===. 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 envelopeid, 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.
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 a422.
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-Signatureagainst the raw body with a timing-safe comparison - The handler returns
2xxfor events it ignores and responds inside 30 seconds - The handler claims the event
idbefore fulfilment -
completed,failed,expiredandrefundedall have a code path, andstate_reasonis read on completion - Every money-moving
POSTsends anIdempotency-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

