Every code this API can return, what caused it and what to do about it. The doc_url in every error body points at an anchor on this page, so you can go straight from a failing request to the paragraph about it.
The shape of an error
Every error body, without exception
{
"error": {
"type": "insufficient_balance",
"code": "balance.insufficient",
"message": "Balance is 3504 cents; this order costs 5256 cents.",
"doc_url": "https://simway.pro/partners/docs/errors#balance-insufficient",
"request_id": "req_01JAX7QW3M8K2N4P6R8T0V2X4Z",
"param": null,
"balance_cents": 3504,
"required_cents": 5256
}
}
Field
Stability
What it is for
code
Stable forever
The thing to branch on. Adding a code is a compatible change; changing or removing one is not, and would appear in the changelog.
type
Stable
The coarse family, for a client that wants one branch per kind rather than one per code.
message
May change without notice
Prose, for a human reading a log. Never parse it and never match on it. Saying so here is the only thing that makes improving these messages safe.
doc_url
Stable
An anchor on this page, derived from the code.
request_id
Always present
Quote it to support. Also on the X-Request-Id header of every response, including 401s.
param
Stable
Which field was wrong, where one field was. Null otherwise.
How to handle them
The branch that covers almost everything
if (res.ok) return res.body;
const { code, type } = res.body.error;
switch (code) {
case 'balance.insufficient':
// Bound to this idempotency key forever. Top up, then use a NEW key.
return outOfFunds(res.body.error);
case 'rate_limit.exceeded':
// Retry-After present -> pace yourself. Absent -> your credential is wrong.
return res.headers['retry-after']
? backOff(Number(res.headers['retry-after']))
: alertOperator('partner API credential rejected');
case 'internal_error':
return retry({ sameIdempotencyKey: true }); // safe, by design
default:
if (type === 'invalid_request_error') return bug(res.body.error); // do not retry
if (type === 'not_found_error') return resync();
return escalate(res.body.error.request_id);
}
Retry a 5xx with the same idempotency key. That is what it is for.
Never retry a 4xx unchanged. Nothing about the second attempt is different.
A 402 is the one error with a permanent side effect.Read the binding rule before you write the top-up path.
The two different 429s
One code, two mechanisms, and telling them apart matters because backing off does not fix the second one.
Rate limit
Failed-authentication budget
Cause
Too many requests in the window for that route’s class.
Too many rejected credentials from your source address.
Any validation failure: a missing or wrong-length Idempotency-Key, a malformed or wrong-kind id, an unparseable cursor, metadata that is not an object or is over 4 KB — and, importantly, an unknown query parameter or body field. Validation runs with forbidNonWhitelisted, so ?contry=JP fails loudly instead of being ignored.
What to do.param names the offending field; on a validation failure details carries every message. Fix the request. Do not retry it unchanged.
Extra fields: param, details
Also the fallback: a bare HTTP 400 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
You ordered a plan that still resolves but is no longer on sale.
What to do.Delist it on your side. GET /plans/{id} reports the same condition as available: false.
401 — not authenticated
The credential did not resolve.
auth.missing401 · authentication_error
No Authorization: Bearer header.
What to do.Send the key.
auth.invalid_key401 · authentication_error
The key is malformed, unknown, or revoked. One code for all three, deliberately: three distinguishable answers would let somebody working through a list of stolen keys find out which ones exist.
What to do.Check the credential. Retrying will not help.
Also the fallback: a bare HTTP 401 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
auth.key_expired401 · authentication_error
The key's expires_at has passed. The one authentication failure that is distinguishable, because the fix is unguessable from “invalid”.
What to do.Switch to the replacement, which was issued at the moment rotation began — you already have it.
402 — no funds
The balance would have gone negative. The one status with a permanent side effect on your idempotency key.
balance.insufficient402 · insufficient_balancebinds the idempotency key
The conditional debit matched no row for want of funds. Also raised by the sandbox fixture plan pl_sandbox_insufficient_balance, whatever the sandbox balance is.
What to do.Top up, then retry with a NEW Idempotency-Key — the old one is bound to this refusal permanently. Prevent it with GET /plans/{id}/price, which reports sufficient_balance and spends nothing.
Extra fields: balance_cents, required_cents, mode (sandbox only)
Also the fallback: a bare HTTP 402 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
403 — not permitted
The key resolved; the account or the source address may not act right now.
auth.ip_not_allowed403 · authentication_error
The key carries a CIDR allowlist and the request's trusted source address was outside it — or could not be established at all. Both produce the same message on purpose: the fix is identical, and a distinguishable answer would tell a caller which header to experiment with.
What to do.Add your egress address to the key, or clear the allowlist.
partner.not_live403 · permission_errorbinds the idempotency key
A live key on an account that has not been approved for live yet.
What to do.Wait for approval. Your sandbox key keeps working throughout.
partner.suspended403 · permission_errorbinds the idempotency key
Live key, account suspended, balance frozen.
What to do.Contact support. Sandbox keys keep working while an account is suspended — a commercial dispute must not also break your build.
partner.closed403 · permission_errorbinds the idempotency key
The account is closed. Both modes refuse.
What to do.The relationship is over.
forbidden403 · permission_error
A SANDBOX key on POST /deposits. Deposits are a live-mode operation — sandbox spending comes off the play-money float, which is refilled on request, so there is no invoice for a sandbox key to open. The body carries mode: "sandbox".
What to do.Send the live key. There is nothing to test here: a sandbox deposit would be real cryptocurrency against imaginary float. If you see this WITHOUT mode in the body, or on any other route, report it with the request id — that form does mean a throw site forgot to name itself.
Extra fields: mode (on the sandbox deposit refusal)
Also the fallback: a bare HTTP 403 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
404 — not found
Scoped to your account and your key’s mode, so a 404 can mean “wrong mode”.
No plan with that id is visible to this key: it never existed, it is a sandbox fixture seen by a live key, or it sits in a withdrawn destination.
What to do.Re-sync your catalogue mirror.
order.not_found404 · not_found_error
No order with that id for this partner AND this mode. A live key addressing a sandbox order gets this rather than a 403 — a distinguishable answer would be an existence oracle.
What to do.Check the id, and check which mode's key you sent.
esim.not_found404 · not_found_error
No profile with that ICCID in this key's scope.
What to do.Check the ICCID and the mode.
not_found404 · not_found_error
No deposit with that id on this account, from GET /deposits/{id} — which is also what a SANDBOX key receives for every id, because the sandbox writes no deposits.
What to do.Check the id, and check which mode’s key you sent. If the path itself is wrong you will get this too, so check the URL before assuming the deposit is missing.
Also the fallback: a bare HTTP 404 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
409 — conflict
Two requests disagreed about the same thing.
idempotency.key_reused409 · idempotency_error
The same Idempotency-Key arrived with a different request fingerprint, or was already used by a key in the other mode. Silently replaying would hand back a profile for a plan you did not ask for the second time.
What to do.Use a new key, or resend the original body. Keys are scoped per partner and span both modes, so sandbox and live must not share one.
Extra fields: param
order.in_progress409 · conflict_error
Reserved for order cancellation.
What to do.Nothing — no route raises it.
Nothing raises this today. Reserved for an endpoint that is not built. Listed because the code is published.
order.not_cancellable409 · conflict_error
Reserved for order cancellation.
What to do.Nothing — no route raises it.
Nothing raises this today. Reserved for an endpoint that is not built. Listed because the code is published.
conflict409 · conflict_error
A concurrent re-drive lost a uniqueness race while recording a movement against an order: “A <kind> has already been recorded against that order. Nothing was moved.”
What to do.Treat it as benign and poll the order. Nothing moved.
Also the fallback: a bare HTTP 409 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
422 — cannot be priced
The plan exists and we will not sell it to you at a price that makes sense.
The plan cannot be priced for your account. Either the floor pushed your price above our own retail price — which happens when a plan’s supplier cost has risen since it was last repriced — or the catalogue row has no usable price at all. GET /plans/{id}/price gives the vague form because it holds only the boolean; POST /orders names which.
What to do.Not your fault and not retryable on a timer. Skip the plan. If the order path says the row is not priced, quote the plan id to support.
Also the fallback: a bare HTTP 422 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
429 — rate limited
Two different limits share this status. Only one of them sends Retry-After.
rate_limit.exceeded429 · rate_limit_error
Either the per-key window for the route’s rate class, or the per-address failed-authentication budget. They are the same code and they are not the same thing — see the note below.
What to do.Back off. Honour Retry-After if it is present; if it is not, you hit the authentication budget, so wait a minute and fix the credential rather than retrying it.
Extra fields: rate_class, retry_after_seconds (window limit only)
Also the fallback: a bare HTTP 429 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
5xx — our fault
Retry with the same idempotency key.
internal_error500 · api_errorkey stays usable
Our fault. The body is deliberately bare. It is also written to failure_code on an order that could never be opened after five attempts.
What to do.Retry the SAME Idempotency-Key — that is what it is for. Quote the request_id if you contact support.
Also the fallback: a bare HTTP 500 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
supplier_error502 · supplier_error
Almost always the failure_code on an order that failed or partially failed at the supplier, after that order was already accepted with a 202 — no route raises it deliberately. It can still arrive as an HTTP body, because it is what the filter maps a bare 502 to: an upstream failure nothing on the partner path named.
What to do.Read it off the order object. If one arrives as an HTTP 502 instead, treat it as a transient upstream failure — retry with the SAME Idempotency-Key, and quote the request id if it persists.
Also the fallback: a bare HTTP 502 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
service_unavailable503 · api_error
POST /deposits could not open an invoice — the payment provider is unconfigured or it refused the request. Nothing was charged, no invoice exists, and your float is untouched.
What to do.Retry with backoff. retryable: true marks the form where the provider refused a request we expect to succeed later; without it, the rail is down rather than busy, so ask us for bank transfer details instead of looping. This is the reason a deposit client needs retry logic at all — do not skip it.
Extra fields: retryable (on the provider refusal)
Also the fallback: a bare HTTP 503 raised anywhere under /partner/v1 arrives as this code, so a client switching on code never meets a value this page does not list.
Failures after acceptance
An order that was accepted with a 202 and then went wrong does not produce an HTTP error — it produces an order with a failure_code. That code is drawn from the same vocabulary as the list above, so a client already switching on error.code needs no second vocabulary.
supplier_error — the supplier could not provision some or all of it. The money for the undelivered units is already back on your balance.
internal_error — the order could never be opened. Nothing was charged.