One envelope, seven types, and codes you can branch on.
Every non-2xx response on /v1 has the same shape:
{
"error": {
"type": "invalid_request_error",
"code": "amount_too_small",
"message": "amount must be at least 1.000000 USDT",
"param": "amount_decimal"
},
"request_id": "req_8f2c…"
}
type, message on codetype groups failures by what you should do about them — which is the only grouping worth putting on the wire.
| Type | HTTP | Do | | --- | --- | --- | | invalid_request_error | 400, 404, 409 | Fix the request. Retrying it unchanged will fail again. | | authentication_error | 401 | Fix the credential. | | permission_error | 403 | The credential is valid and lacks the scope. | | idempotency_error | 409 | Same key, different body. See Idempotency. | | rate_limit_error | 429 | Back off and retry. | | chain_error | 502, 503 | A chain or its RPC failed us, not you. Usually transient. | | api_error | 500 | Our fault. Retryable, and it pages somebody. |
chain_error is separated from api_error because the remedy differs: a payment involved in one is *stalled*, not lost.
| Code | Means | | --- | --- | | missing_api_key / invalid_api_key | No usable credential. | | api_key_revoked | Somebody revoked it deliberately. | | live_mode_disabled | A live key against a deployment with no live mode. | | kyc_required | The account has not completed identity verification, so it may not use live mode. Nothing you can fix in code — the account owner does it in the console, under Settings. Subscribe to kyc.approved if you want to know when it clears. | | two_factor_required | The account has no second factor enrolled. Its own code, beside kyc_required, because the remedies share nothing: one is a form and somebody else's decision, the other is thirty seconds with a phone. | | terms_acceptance_required | The account has never accepted the terms of service, so it may not change anything. 403 on writes only — reads keep working, which is what lets a console load and a monitor keep reading while it is outstanding. Nothing you can send fixes it: the account owner signs in to the console and accepts once, and every credential they already hold starts working again immediately. An account that accepted an OLDER version is not affected here; it is asked in the console. | | insufficient_scope | Valid key, wrong scopes — or a pk_ key on a secret route. | | missing_idempotency_key | A mutating call without the header. | | idempotency_key_reused | Same key, different body. | | idempotency_request_in_flight | The first attempt is still running. | | resource_missing | No such object, or not yours. | | amount_too_small / amount_too_large | Outside the configured bounds. | | chain_not_enabled | You have not turned that chain on. | | no_amount_available | Every distinguishing amount on that address is taken. Retry. | | invalid_state_transition | e.g. cancelling a payment that is already paid. | | duplicate_reference | Your reference is already used on this account. | | mandate_cap_exceeded | The charge is above what the customer authorised. | | unknown_api_version | A Paygate-Version this build does not serve. | | malformed_request_body | The body could not be decoded; no handler ran. |
The list is not exhaustive, and it is not meant to be. A code is warranted when an integration must branch on the reason; inventing one for every message produces a vocabulary nobody maintains.
paramPresent on validation failures, and it names the offending field — so you can put the message next to the input that caused it, instead of at the top of a form.
request_idOn every error. Quote it in a support request; it is what makes a log searchable.
Retry 429, chain_error and api_error. Do not retry a 400 — nothing about it will be different.
Every retry of a mutating call must reuse the original Idempotency-Key. That is what makes retrying safe rather than a way to create two payments.