Skip to main content
The API returns errors in three different JSON shapes. Which one you get depends on the endpoint and on how the failure was raised, not on the status code. Write one parser that handles all three before you write anything else.

The three error bodies

Two things follow from this that surprise people:
  • error is sometimes a string and sometimes an object. error.message throws on most 4xx responses. Check the type first.
  • code is frequently null. Every generically handled exception renders {"error": {"message": "…", "code": null}}. Only hand-written failures set a code. Never key your control flow on code alone.

Normalizing all three

southpay/errors.ts
The OAuth endpoints (/oauth/authorize, /oauth/token, /oauth/revoke) use the RFC 6749 shape, which looks identical to the plain-string shape but means something different: {"error": "invalid_request", "error_description": "code_challenge is required"}. There, the error string is the machine code and the human text is in error_description. Branch on the endpoint, not on the body.

Status codes

There is no 409 for idempotency. SouthPay does not detect “same key, different body”. See Idempotency.

403 is not an authentication problem

A 403 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 as null.

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
Last modified on August 21, 2026