Skip to content
NOSTREL
Errors

The only column
that matters at 3am:
does retrying help?

Everything else on this page is context. When something has gone wrong in production, the question is whether to send it again or wake somebody, and most error documentation makes you infer the answer from prose.

Retrying is safe here in a way it usually is not, because every call that moves money carries an idempotency key. Sending the same request again cannot produce a second payment, which turns a frightening decision into a dull one.

one envelope, every errorhttp
HTTP/1.1 400 Bad RequestContent-Type: application/json {  "error": {    "type": "invalid_request_error",    "code": "insufficient_balance",    "message": "Insufficient available balance for this payout.",    "request_id": "0f2a7c14-9d3b-4e55-8a01-6b2fd0c9e71a"  }}

code is the contract and is what to branch on. type is the coarse family. message is for your logs and your eyes, and may be reworded at any time.request_id is what to quote at us.

01Codes

All 24, and what to do about each

Grouped by what you are deciding when you read one. The right-hand label is the decision; the paragraph is why.

Authentication

The credential is the problem. Every one of these fails identically on a retry, so fix the key or the session before sending anything again.

  • missing_api_key401 · authentication error

    No key on a request that needs one. Usually a proxy stripping Authorization, or a client that was configured in one environment and deployed in another.

    change the request

  • invalid_api_key401 · authentication error

    The key does not exist, has been revoked, or belongs to the other environment. One code for all three on purpose: an error that told them apart would answer "does this key exist" for anybody willing to ask repeatedly.

    change the request

  • invalid_session401 · authentication error

    A console session token that is absent, malformed or past its expiry. You will not see this from a server integration; it is what the dashboards get when a tab has been open too long.

    change the request

  • mfa_required401 · authentication error

    The password was right and the account has a second factor, so the sign-in is incomplete rather than refused. Collect a code and send it. Not a failure.

    change the request

  • mfa_invalid401 · authentication error

    The second factor was supplied and is wrong, or its window has passed. Separate from the one above so a client can tell "ask for a code" from "that code was not right", which is the difference between a prompt and an error.

    change the request

Permission

The credential is valid and is not allowed to do this. Nothing about the request is malformed, so there is nothing to fix in the body.

  • missing_scope403 · permission error

    A restricted key without the scope this route needs. The message names the missing scope, because that one is not a secret from the key's own holder and guessing it would otherwise be the whole of the debugging.

    change the request

  • publishable_key_not_permitted403 · permission error

    A publishable key on a route that requires a secret one. Publishable keys are for the browser and can do almost nothing; if one reached a server call, the wrong variable is wired in.

    change the request

  • capability_not_enabled403 · permission error

    The account is not verified for this yet. Not a bug and not something a retry fixes: finish verification, or ask whoever owns the account to.

    needs a person

  • insufficient_credit403 · permission error

    Platform credit is exhausted. The request was otherwise perfectly valid, which is why this is worth alerting on rather than logging: somebody has to top up before anything moves.

    needs a person

  • limit_exceeded403 · permission error

    A per-transaction or monthly ceiling for the current tier. Either split the amount, or move up a tier. The tier page explains which ceiling you are against.

    needs a person

  • approver_must_differ403 · permission error

    Maker and checker. The person approving a payout cannot be the person who created it, whatever permissions they hold. Find a second pair of eyes; this one is deliberate.

    needs a person

  • permission_denied403 · permission error

    The signed-in user lacks the role for this action. The generic one, for console routes where the specific answer is a question for whoever administers the team.

    needs a person

The request

Something about what you sent. Every one of these fails the same way on a retry of the same bytes, so change the bytes.

  • validation_failed400 · invalid request error

    The body failed validation. The envelope carries a fields array saying which and why. Unknown fields are rejected rather than ignored, so a typo in a field name lands here instead of being silently dropped on the floor.

    change the request

  • missing_header400 · invalid request error

    A required header is absent. Today that means Idempotency-Key, which every money-moving call must carry. It is required rather than optional because an optional safety belt is one nobody wears.

    change the request

  • not_found404 · invalid request error

    No such record, or it belongs to another merchant. Those two are deliberately indistinguishable: an API that answers 403 for somebody else's record has just confirmed the record exists.

    change the request

  • insufficient_balance400 · invalid request error

    The balance will not cover this payout, counting what earlier payouts have already reserved. Available is not the same number as the total, and this is the one place the difference bites.

    needs a person

  • invalid_state409 · invalid request error

    The record is not in a state where this makes sense: approving a payout that is not awaiting approval, cancelling an invoice that is already paid. Read the record and decide again rather than retrying.

    change the request

  • already_exists409 · invalid request error

    A uniqueness rule: an account number, a link code, an email already in use. Worth distinguishing from invalid_state in your logs, because this one usually means two of your own processes raced.

    change the request

  • payload_too_large413 · invalid request error

    The body exceeded the cap at the edge. You will meet this with a very large bulk payout file long before you meet it with a payment. Split the file.

    change the request

Idempotency

Both of these are about a key you have sent before. Read the code before retrying, because one of them means your own two calls disagreed with each other.

  • idempotency_key_reuse409 · idempotency error

    The same Idempotency-Key with a different body. We refuse rather than guess which of two intentions was meant. Use a new key, or send the original body. This one is almost always a bug worth finding.

    change the request

  • request_in_progress409 · idempotency error

    The first request with this key has not finished. Wait and read the record rather than starting a second payment alongside the first. The lock is what makes the retry safe in the first place.

    wait, then read

Ours, or the rail behind us

The only three where sending the same request again is the right move. Send it with the same idempotency key and it cannot produce a second payment.

  • rate_limited429 · rate limit error

    Rate limited at the edge. Back off and try again. If you meet this in normal trading rather than in a load test, tell us and we will raise it.

    retry with backoff

  • internal_error500 · api error

    Ours. The message is deliberately generic: no stack trace, no class name, no hint about which provider sits behind which rail, because an error response is an information-disclosure surface as much as it is a debugging aid. Quote the request_id at us.

    retry with backoff

  • service_unavailable503 · api error

    A dependency is refusing, usually upstream of us. Retry with backoff and the same key. Our reliability page has what we do about this on our side.

    retry with backoff

02In practice

A validation failure, and the code you write once

Field errors arrive as structured data rather than an array of English sentences, so a form can put each message next to the input that caused it.

fields names the inputs

One entry per field that failed, each with the field path and why. No parsing a sentence to work out which input to highlight.

message summarises

A sentence for the log line. Never the raw array, which is what a default validation pipe would have given you.

request_id matches our logs

Quote it in a support thread and we can find the exact request. It is the same id that appears on our side, which is the point.

No second copy of anything

There is no top-level message or statusCode in the body. Two sources for one string is how the two drift apart.

a validation failurehttp
HTTP/1.1 400 Bad RequestContent-Type: application/json {  "error": {    "type": "invalid_request_error",    "code": "validation_failed",    "message": "2 fields failed validation.",    "request_id": "c81e0b6a-5f44-4a9e-9c27-18d6b3a4e0f2",    "fields": [      { "field": "amount", "message": "amount must be a positive integer" },      { "field": "phone",  "message": "phone must be in 2547XXXXXXXX form" }    ]  }}
the handler you write oncets
// Branch on code. The message is prose and// may be reworded, so never branch on that.const { error } = await res.json(); switch (error?.code) {  // No retry fixes these. Someone has to act.  case 'insufficient_credit':  case 'capability_not_enabled':  case 'limit_exceeded':    return page(opsTeam, error.message, error.request_id);   // Our first call is still in flight. Read  // the record; do not start a second payment.  case 'request_in_progress':    return waitAndPoll(idempotencyKey);   // Same key, different body. Our bug, always.  case 'idempotency_key_reuse':    throw new BugInOurCode(error.message);   // Safe to send again: the key makes it safe.  case 'rate_limited':  case 'internal_error':  case 'service_unavailable':    return retryWithBackoff(() => send(sameBody, sameKey));} // type is the coarse family: one branch per// category, rather than one per code.if (error?.type === 'invalid_request_error') throw new RequestProblem(error);

03Statuses

Every one the public API returns

Still worth a table, because a status is what your load balancer, your dashboard and your on-call alert see. Read the right-hand column first.

Every HTTP status the NOSTREL public API returns, what it means and whether retrying the request helps.
StatusWhat it meansRetry?
202

Accepted

We took the request and are raising a prompt or queueing a payout. It is not a payment. Nothing has happened to anybody's money yet.fix first
400

Bad request

The body failed validation, or a required header is missing. Unknown fields are rejected rather than ignored, so a typo in a field name lands here rather than being silently dropped. Fix the request; retrying it unchanged will fail identically.fix first
401

Unauthorized

No key, a revoked key, or a test key against the live host. The code tells you whether a key was missing or merely invalid, and never which of the three reasons made it invalid, because an error that distinguishes them is an oracle for guessing keys.fix first
403

Forbidden

The key is valid and lacks the scope for this route, or your account is not yet verified for this capability. The message names the missing scope, because that one is not a secret from you.fix first
404

Not found

No such record, or it belongs to another merchant. Those two are deliberately indistinguishable: an API that returns 403 for somebody else's record confirms the record exists.fix first
409

Conflict

A record in the wrong state, a uniqueness rule, or an idempotency conflict. Three different codes, and they want three different reactions, which is exactly why reading the code beats reading the status here.read first
413

Payload too large

The body exceeded the cap at the edge. You will meet this with a very large CSV long before you meet it with a payment.fix first
429

Too many requests

Rate limited at the edge. Back off and try again. If you see this during normal use rather than during a load test, tell us and we will raise it.retry
500

Server error

Ours. No stack trace, no internal identifier, no hint about what sits behind which rail, because an error page is an information-disclosure surface as much as it is a debugging aid. Retry with the same idempotency key: it is safe, by construction.retry
503

Service unavailable

A dependency is refusing, usually the provider. Retry with the same idempotency key, with backoff.retry

04Not errors

Three things that look like failures and are not

Each of these is a 2xx or a perfectly healthy record, and each of them has been mistaken for a problem by somebody integrating for the first time.

A collection that says failed

The API call worked. The payment did not, because a person cancelled it or got their PIN wrong. That is a business outcome, not an integration fault, and roughly one prompt in six ends this way in normal trading.

A payout sitting in queued

Working as designed. It is waiting for a second person, and it will sit there until somebody approves or rejects it. If nothing is moving, the question is who has the approval permission, not what broke.

A collection in awaiting_confirmation

A success callback arrived and we have not yet independently confirmed it. It is in the middle of the check that protects you. Give it a moment; do not credit on it.

Meet them on purpose, first

Every code on this page can be triggered deliberately in the sandbox. An error path you have never executed is an error path you have only imagined handling.