The three error bodies
Two things follow from this that surprise people:
erroris sometimes a string and sometimes an object.error.messagethrows on most 4xx responses. Check the type first.codeis frequentlynull. Every generically handled exception renders{"error": {"message": "…", "code": null}}. Only hand-written failures set a code. Never key your control flow oncodealone.
Normalizing all three
southpay/errors.ts
Status codes
There is no
409 for idempotency. SouthPay does not detect “same key, different body”. See Idempotency.
403 is not an authentication problem
A403 means your credential is valid and the request was refused. Six distinct causes produce one status code, and only the code tells them apart. This is the single most useful thing to log.
mfa_required, otp_required, otp_invalid, mfa_check_unavailable and otp_check_unavailable also return 403 (or 503), but only for logged-in dashboard sessions. API keys, OAuth tokens and agent credentials never trigger the step-up check, so an integration will not see them.
Error codes by status
Every code below appears in the API source. If you see a code that is not here, treat it asnull.
401
404 and 410
Most 404s carry no code at all. A record that does not resolve renders
{"error": {"message": "Couldn't find …", "code": null}}.
409
Duplicate billing customers return
409 with the plain-string body {"error": "A customer with this email already exists"} and no code.
422
Most 422s carry no code. They are plain strings such as
"amount is required and must be positive" or "metadata values must be strings", or a {"errors": [...]} list from a model validation.
The report endpoints are a special case: they return 422 with a bare string in error plus a sibling field. {"error": "date_range_too_large", "max_days": 366} and {"error": "date_range_unavailable", "available_through": "2026-08-05"}.
429, 502 and 503
502 from a payment or payout provider is always {"error": {"message": "Payment provider error", "code": null}}. The real cause is logged on our side; open a support ticket with the request timestamp rather than parsing the message.
Rate limits
Most of the API is not throttled today.POST /api/v2/payments, the checkout and onramp reads, the OAuth endpoints and the agentic surface are. Each is a token bucket, every 429 carries Retry-After in seconds, and authenticated responses carry your remaining budget.
See Rate limits for the full table, how each limit is keyed, and the two 429 body shapes.
What to retry
Reusing the
Idempotency-Key is what makes a retry safe. A replay returns the record that the first attempt created rather than creating a second one. See Idempotency for what that does and does not guarantee.
southpay/request.ts

