Docs/Ledger APIv0.0.1-preview

Errors and rejections

The engine status codes and their classes, the reject reason codes, which codes to retry, and why declined transactions are durable records.

On this page
  1. How to read a refusal
  2. Rejections are records
  3. Status codes
  4. Durable and non-durable refusals
  5. Retry discipline
  6. Transport errors vs. ledger status
  7. Refused before the ledger saw it

Every write returns a status. 0 is accepted, 1 is an idempotent replay, and everything else tells you precisely why it didn’t apply — a business decline, a refused request, or a transient condition to retry. The classification contract is identical in every SDK: accepted iff 0, duplicate iff 1, everything else handled by code.

This page covers the engine’s status codes, the reason codes on rejected records, and how to retry. Error reference by surface lists everything else a caller or operator can receive: REST gateway responses, SDK error types, and the exit codes of tulactl, tuladbd, the operator tools and the Iceberg exporter.

How to read a refusal

First work out which layer answered. Each one has its own namespace, and they never overlap.

Layer What you see Was anything recorded?
Engine A status number from the table below: status on a result, status_code in an SDK error, ledgerd_error.status_code from a REST gateway A business decline on a write is a durable rejected record. A retryable status never is
REST gateway HTTP 400 (or 422 for a batch envelope) with error: "invalid_request" and a string code such as REQ_FIELD_REQUIRED; or an access refusal such as 401, 403, 413 or 429 No — the engine never saw the request. See Refused before the ledger saw it
Transport No status at all: a connection failure, timeout or framing error (REST: 503 with error: "ledgerd_connect_error") Unknown. Reconnect and resubmit the same identity; see Transport errors

A status number always comes from the engine; a gateway code is always a string that never starts with ERR_. If the answer has neither, the request never got an answer, and a write’s outcome is unknown until you resubmit it.

Rejections are records

When the engine declines a transfer for a business reason — insufficient available balance, a limit breach, an inactive account, an unknown currency — the decline is written to the ledger as a rejected transaction. It appears in list_rejected_transfers, it’s searchable, and its identity is remembered: retrying a rejected operation replays the original rejection instead of re-deciding it against fresh state. (An unknown currency is a durable decline only when the engine decides it, with the currency registry enforced. Through a REST gateway, a currency code the gateway doesn’t recognise is refused with 400 before it reaches the engine, so it leaves no record.)

Only verdicts are recorded. A retryable status (ERR_CAPACITY, ERR_QUORUM_TIMEOUT and the rest of that class) describes the node’s momentary state, not the request, so the engine answers it and forgets it: no record, no idempotency entry, and a retry under the same identity is decided fresh once the condition clears. A request refused before validation — a malformed frame (ERR_WIRE_PARSE), a role that isn’t allowed (ERR_PERMISSION), a write to the read-only port — leaves no record either. The class of a status doesn’t settle this on its own: several other-class declines (ERR_NOT_FOUND on a transfer, an inactive account, an unregistered source) are durable, and some final ones (ERR_INVALID_TXN_ID, ERR_PERMISSION) are never recorded. Durable and non-durable refusals gives the answer for every code.

This is deliberate. During an incident, “what was declined, when, and why” is usually the question — and in most stacks the answer evaporated into a log line. In TulaDB it’s a query:

rejected = q.list_rejected_transfers(limit=256)

Status codes

The class column is the client contract in the SDK’s contracts/status-codes.env. All five SDKs name every status from 0 to 48, and each SDK’s tests check its name table, final set and retryable set against that file. The classes are: success (the write happened, or had already happened), final (the same answer on every node and every retry — fix the request), retryable (nothing was written; back off and retry with the same identity), and other (depends on state that can change — a lagging node, an entity not created yet; look at the status before retrying).

Code Name Class Meaning
0 OK success Accepted and applied
1 OK_DUPLICATE success (final) Idempotent replay — carries the original result
2 ERR_LIMIT final Balance limit breach, or invalid amount (must be > 0)
3 ERR_TIER final Account tier doesn’t match the operation
4 ERR_CAPACITY retryable Load shed: a disk, memory, segment-table or dedup budget is full, or too few voters have disk room. Clears by itself as purge, compaction or memory pressure catches up
5 ERR_CURRENCY_MISMATCH final Account and transfer currencies disagree
6 ERR_CONTROL_FORBIDDEN final Operation not allowed on a control account
7 ERR_CONTROL_NOT_REG final Control account not registered. Defined, but not returned by the current engine †
8 ERR_INVALID_TXN_ID final Over-length or non-printable txn_id
9 ERR_NOT_FOUND other This node has never seen the account or transfer — a lagging node may not have it yet
10 ERR_WRONG_PHASE retryable A duplicate of a write still in flight (a retry resolves to the original’s outcome), or an invalid lifecycle transition such as commit after rollback, including the loser of a commit-vs-rollback race (a retry gets the same answer; read the record to see POSTED or VOIDED)
11 ERR_ACCOUNT_CLOSED final Terminal account
12 ERR_QUORUM_TIMEOUT retryable The cluster couldn’t confirm durability in time, the node isn’t ready to lead yet, or a read-your-writes wait was refused — retry
13 ERR_WAL_TIMEOUT retryable Local durability timed out — retry
14 ERR_PARTITION_FULL retryable An account partition is at capacity. Not returned by the current engine, which sheds with ERR_CAPACITY †
15 ERR_UNKNOWN_LEDGER other No such ledger (yet)
16 / 17 ERR_INVARIANT_VIOLATED / ERR_CORRUPTION other An engine fault, not a decline. ERR_INVARIANT_VIOLATED is also the answer to registering a ledger, source or currency again with attributes that differ from the existing entry (an identical re-registration is OK_DUPLICATE). ERR_CORRUPTION is also the answer for a record that failed its integrity check — the node never serves an older match past it
18 ERR_DISK_WRITE retryable Transient disk error — retry
19 ERR_CONFIG other Server-side configuration refusal; also the answer to a retired message type (the single-operation write messages — every write is now a batch), to a reserved internal ledger id, to a tiered transfer whose position and settlement legs name the same account, and — from the default event-driven listener — to a write sent to the read-only query port
20 ERR_INSUFFICIENT_CTRL_BAL other Control account can’t cover the funding. Not returned by the current engine †
21 ERR_QUERY_UNAVAILABLE retryable This node can’t vouch for the answer right now: its query index is rebuilding, or it holds the identity but not a readable record (for example history from before a snapshot install). Retry, or read from another node — never a partial answer
22 ERR_CONTROL_DUPLICATE final Duplicate control-account registration
23 ERR_UNKNOWN_CURRENCY other Currency not in the registry
24 / 25 ERR_SOURCE_UNKNOWN / ERR_SOURCE_INACTIVE other Submitting source not registered (or source_id 0 under a strict registry) / disabled
26 / 27 ERR_DEBIT_ACCOUNT_INACTIVE / ERR_CREDIT_ACCOUNT_INACTIVE other Deactivated account on that side
28–31 ERR_DR_POSITION_INACTIVE, ERR_CR_POSITION_INACTIVE, ERR_DR_SETTLEMENT_INACTIVE, ERR_CR_SETTLEMENT_INACTIVE other Tiered-leg account inactive. Not returned by the current engine: an inactive account on any leg answers 26 or 27 †
32 / 33 ERR_DEBIT_NOT_ALLOWED / ERR_CREDIT_NOT_ALLOWED other NO_DEBIT / NO_CREDIT account
34 ERR_CURRENCY_INACTIVE other Currency disabled in the registry
35 ERR_PERMISSION final Caller’s role doesn’t allow this operation
36 ERR_WIRE_PARSE final Malformed request — do not resend the same bytes
37 ERR_WRONG_MODE other Wrong ledger mode, or a write sent to the read-only port. In this release the default event-driven listener answers a write on the query port with 19 instead; handle both
38 ERR_DEADLINE_EXCEEDED retryable Write shed under the latency SLO guard, or a proxied write that waited past its deadline and was never sent — retry
39 ERR_RESERVE_EXPIRED other The reservation lapsed under its ledger’s expiry TTL — a business outcome
40 ERR_EXPORT_RANGE_RECLAIMED other Export cursor below the WAL reclaim floor — reseed the exporter, don’t retry
41 ERR_UPSTREAM_REJECTED final An upstream system explicitly recorded this rejection — the engine fixes this status; the caller’s own classification is on the record as reject_reason_code
42 ERR_AMOUNT_EXCEEDS_MAX final The amount exceeds the ledger’s max_txn_amount cap — a durable business decline; split the payment or raise the cap and retry under a new identity
43 ERR_HISTORY_PURGED other The record is below this cluster’s history-retention floor and is no longer retained — look it up in your export destination
44 ERR_ACCOUNT_CONFLICT final An account with this key already exists with different creation attributes (or, in this release, the id exists under another ledger/currency). An identical re-create is OK_DUPLICATE
45 / 46 RESERVED_45 / RESERVED_46 other Reserved; never returned in this release
47 ERR_EXPORT_GROUP_CONFLICT other A partition-group export page was refused; the sub-code is in the export response. Retry only for the “not the leader” sub-code
48 ERR_RECONFIG_NO_QUORUM retryable A membership change whose proposed voters have no live quorum yet — the same change succeeds once they are up

48 is the highest code in this release. † Codes marked this way are in every SDK’s name table and keep their class, but no path in the current engine returns them. Handle them by class like any other code; they are reserved, not retired.

Who wrote the reason code

reject_reason_code on a durable record has two possible authors, and they can never be confused:

  • Yours — any value below 0xFFFF0000, recorded verbatim when you set TXN_FLAG_REJECT on an explicit reject.
  • The engine’s — the top of the range, 0xFFFF0000 | status, is reserved for the engine. The engine refuses (ERR_WIRE_PARSE) any caller who supplies a reserved value, so a reason at or above 0xFFFF0000 is a sound test for “the engine declined this, not an upstream system”. TXN_FLAG_REJECT on the record tells the two apart at a glance too.

Which engine declines carry a reason of their own, in this release:

Decline result_code reject_reason_code
Amount over the ledger’s max_txn_amount on a transfer or reserve 42 ERR_AMOUNT_EXCEEDS_MAX 0xFFFF002A
Amount over the cap on a tiered or multi-leg transfer 42 0
Every other engine decline (ERR_LIMIT, an inactive account, an unknown ledger …) the status 0
Your explicit reject 41 ERR_UPSTREAM_REJECTED your value, verbatim

So for an engine decline, read the status from result_code; the reason adds nothing beyond it today. The encoding decodes back to the status (reason & 0xFFFF), so a future engine decline that gains a reason needs no new registry.

A reason without TXN_FLAG_REJECT, or a reject on a reserve or tiered operation, is refused with ERR_WIRE_PARSE.

ISO 20022 reason codes

The engine has no built-in ISO 20022 mapping: reject_reason_code is a 32-bit number in your own taxonomy. If you want to carry an ISO 20022 status reason (AM04, AC04 …), choose an encoding and keep it in your integration. Packing the four ASCII characters into the four bytes (AM04 is 0x414D3034) always lands in the caller range, because an ASCII byte is below 0x80 and so the value stays below 0xFFFF0000. A numeric table of your own works just as well. Whatever you choose, the engine records it verbatim and returns it on every lookup.

Durable and non-durable refusals

Every refusal is one of two kinds, and the difference decides what a retry does.

  • Durable. The engine claimed the operation identity and wrote a rejected transaction. The record replicates to every node, survives a restart, appears in list_rejected_transfers and search_transactions, and is returned by get_transaction_by_key. A retry under the same identity replays the same status, even after the cause is fixed: lift a limit, create the missing account or register the source, and the old identity still answers with its original decline.
  • Not recorded. The refusal is answered once and forgotten. No identity is claimed, nothing is written and nothing replicates. A retry under the same identity is decided afresh, so once the cause is fixed it can post.

The rule is short. A refusal is durable when it’s a verdict on a transfer, reserve or tiered transfer (or an explicit reject) that the engine reached after its duplicate check, and the status isn’t retryable. Everything else is not recorded:

  • statuses that describe the node rather than the request — every retryable status, including a decline whose own record failed to reach the log
  • requests refused before they have an identity: a malformed frame, an unparseable txn_id, a reserved ledger id, a wrong role or port, a spent deadline
  • commit and rollback refusals, which are answered from the reservation’s existing record and add nothing to it
  • refusals of account creation and registry writes (ledgers, sources, currencies, limits, caps, expiry), which have no rejected-record concept

A status’s class doesn’t tell you which kind it is. Several other-class codes are durable on a transfer, and some final codes are never recorded. Use the table.

Code Durable? Why A retry of the same identity Verified
1 OK_DUPLICATE Not a refusal The identity already has an outcome. A retry of a declined identity returns that decline’s own status, never 1. An explicit reject of an identity that already posted answers 1 and records nothing; the transfer stands — Live
2 ERR_LIMIT Durable Verdict on the request: balance floor or ceiling, an amount of 0 or less, a posted total that would overflow Replays 2, even after the limit is lifted Live (transfer, reserve)
3 ERR_TIER Depends Durable on a tiered transfer or reserve (a leg on the wrong tier). Not recorded on create_account (a tier the ledger’s mode doesn’t define) Tiered: replays 3. Account create: decided against current state Live
4 ERR_CAPACITY Not recorded Load shed: the disk reserve, memory budget, segment table or dedup budget is full, or no room for the claim. Checked before validation, so even a request that would be declined is shed Decided afresh once the gate clears; a request that would be declined is then declined and recorded Live (disk reserve and memory budget, by fault injection)
5 ERR_CURRENCY_MISMATCH Depends Durable on a transfer, reserve or tiered transfer. Not recorded when refusing an account or ledger registration Transfer: replays 5 Live
6 ERR_CONTROL_FORBIDDEN Durable A control account in a transfer without FUNDING / DEFUNDING Replays 6 Live
7 ERR_CONTROL_NOT_REG — Not returned by the current engine — From code
8 ERR_INVALID_TXN_ID Not recorded The txn_id is the identity; one that fails to parse can’t be claimed, so there’s nothing to record it under The same bytes are refused again; a corrected txn_id is a new identity Live
9 ERR_NOT_FOUND Depends Durable when a transfer, reserve or tiered transfer names an account that doesn’t exist. Not recorded on a commit or rollback of an identity never reserved, or on a read Transfer: replays 9 even after the account is created. Commit: decided afresh — the identity can still be reserved Live
10 ERR_WRONG_PHASE Not recorded An in-flight duplicate; the same identity twice in one batch (the first item’s verdict counts); or a lifecycle refusal answered from the existing record — commit after rollback, commit of a reserve that was declined In flight: resolves to the original’s outcome. Lifecycle: 10 again, because the record it’s answered from doesn’t change Live
11 ERR_ACCOUNT_CLOSED Durable Verdict on the request Replays 11 Live
12 ERR_QUORUM_TIMEOUT Not recorded as a decline Durability wasn’t confirmed in time, the leader isn’t ready, or a read-your-writes wait was refused If the write’s frame had already been appended, its outcome can still commit, and a retry returns it (1, or the recorded decline); otherwise decided afresh Live for a registry write on a failing log (fault injection); otherwise from code
13 ERR_WAL_TIMEOUT Not recorded as a decline Local durability timed out As 12 From code
14 ERR_PARTITION_FULL — Not returned by the current engine — From code
15 ERR_UNKNOWN_LEDGER Depends Durable on a transfer, reserve or tiered transfer (an unregistered ledger under registry_strict, or a deactivated one). Not recorded on create_account Transfer: replays 15 after the ledger is registered. Account create: decided afresh Live
16 ERR_INVARIANT_VIOLATED Not recorded An engine fault, or a conflicting registry re-registration — no rejected record either way Decided against current state Live (registry), from code (fault)
17 ERR_CORRUPTION Not recorded A halted node refuses every write before claiming an identity; a read that failed its integrity check Decided afresh on a healthy node From code
18 ERR_DISK_WRITE Not recorded The log write failed. A decline whose record didn’t land is answered 18, not its business reason, because nothing recorded it Decided afresh — after the fault cleared, the same identity was declined again and only then recorded Live (fault injection)
19 ERR_CONFIG Depends Durable only for a tiered transfer or reserve whose position and settlement legs name the same account. Not recorded for a reserved internal ledger id, tiered transfers disabled, a retired message type, or a write on the query port Tiered overlap: replays 19. Otherwise decided afresh Live
20 ERR_INSUFFICIENT_CTRL_BAL — Not returned by the current engine — From code
21 ERR_QUERY_UNAVAILABLE Not recorded A read the node can’t vouch for, or a commit or rollback of a hold it can’t read yet Decided afresh From code
22 ERR_CONTROL_DUPLICATE Not recorded An account registration refusal Decided against current state From code
23 ERR_UNKNOWN_CURRENCY Depends Durable on a transfer when the engine enforces the currency registry (registry_strict, or enforcement enabled). Not recorded on a registry or account write Transfer: replays 23 after the currency is registered Live
24 ERR_SOURCE_UNKNOWN Durable An unregistered source, source_id 0 under registry_strict, or a source not on the ledger’s allowlist Replays 24 after the source is registered Live (allowlist from code)
25 ERR_SOURCE_INACTIVE Durable Verdict on the request Replays 25 after reactivation Live
26 / 27 ERR_DEBIT_ / ERR_CREDIT_ACCOUNT_INACTIVE Durable Verdict on the request Replays after the account is reactivated Live
28–31 (tiered-leg inactive) — Not returned: an inactive leg answers 26 or 27 — From code
32 / 33 ERR_DEBIT_ / ERR_CREDIT_NOT_ALLOWED Durable NO_DEBIT / NO_CREDIT account Replays Live
34 ERR_CURRENCY_INACTIVE Durable Deactivated currency, when the engine enforces the currency registry Replays 34 after reactivation Live
35 ERR_PERMISSION Not recorded Refused by the server before the engine sees the request Decided afresh under a role that allows it Live
36 ERR_WIRE_PARSE Not recorded Refused on shape, above the duplicate check: an engine-reserved reason code, a reason without TXN_FLAG_REJECT, a reject flag on a reserve or tiered operation A corrected request under the same identity is decided afresh Live
37 ERR_WRONG_MODE Not recorded The server’s port or mode gate, before the engine Decided afresh on the right port Live (answered as 19 by the default listener)
38 ERR_DEADLINE_EXCEEDED Not recorded Shed before staging: the latency SLO guard, or a deadline already spent Decided afresh Live
39 ERR_RESERVE_EXPIRED Not recorded as a decline Answered from the reservation’s own record, whose lifecycle ended EXPIRED — the expiry is the durable fact Every commit or rollback answers 39 Live
40 ERR_EXPORT_RANGE_RECLAIMED Not a write An export read — From code
41 ERR_UPSTREAM_REJECTED Durable by design Your explicit reject, with your reject_reason_code verbatim Replays 41; a transfer later sent under the same identity also gets 41 Live
42 ERR_AMOUNT_EXCEEDS_MAX Durable Over the ledger’s max_txn_amount, on a transfer or reserve (reason 0xFFFF002A) or a tiered transfer (reason 0) Replays 42 after the cap is lifted Live
43 ERR_HISTORY_PURGED Not a write A read below the retention floor — From code
44 ERR_ACCOUNT_CONFLICT Not recorded An account create; the conflicting account exists The same create gets 44 while it does Live
45 / 46 — Reserved; never returned — —
47 ERR_EXPORT_GROUP_CONFLICT Not a write An export page — From code
48 ERR_RECONFIG_NO_QUORUM Not recorded A membership change, not a transaction Decided afresh once the voters are up From code

“Live” means the row was reproduced against a running single-node tuladbd built from the current release: the refusal triggered, the record looked up, the cause fixed, the same identity retried, and the node restarted to confirm the durable records survived and still replayed. “From code” means the status needs a cluster, a fault or a state a single node can’t reach, and the row follows the engine source. What each write records has the same answer per write type.

Nothing a gateway refuses on its own is durable. A REST gateway’s 400, 401, 403, 404 for an unknown route, 405, 413, 422 batch envelope, 429, 500 and its own 503s (auth_not_configured, no_route) are decided before the request reaches the engine, so nothing is recorded and no identity is claimed; see Refused by the gateway. 503 ledgerd_connect_error is different: it’s a transport failure, so a write may or may not have reached the engine. Resubmit it under the same identity and the engine answers with whatever it decided, if it decided anything. A gateway response that carries ledgerd_error.status_code is the engine’s answer, and that code’s row above applies.

Retry discipline

The engine’s design makes retry policy short:

  1. Always retry with the same idempotency tuple. That is the entire safety argument — see Idempotency.
  2. Retryable — ERR_CAPACITY, ERR_WRONG_PHASE, ERR_QUORUM_TIMEOUT, ERR_WAL_TIMEOUT, ERR_PARTITION_FULL, ERR_DISK_WRITE, ERR_QUERY_UNAVAILABLE, ERR_DEADLINE_EXCEEDED, ERR_RECONFIG_NO_QUORUM. Nothing was written; back off and retry. ERR_WRONG_PHASE is retryable because it is also the in-flight-duplicate answer; when it refuses a lifecycle transition (commit after rollback, or the losing side of a commit-vs-rollback race), retrying is safe but won’t change a completed lifecycle — read the record (get_transaction_by_key) to see whether it ended POSTED or VOIDED.
  3. Final — ERR_LIMIT, ERR_TIER, ERR_CURRENCY_MISMATCH, the control-account codes, ERR_INVALID_TXN_ID, ERR_ACCOUNT_CLOSED, ERR_PERMISSION, ERR_WIRE_PARSE, ERR_UPSTREAM_REJECTED, ERR_AMOUNT_EXCEEDS_MAX, ERR_ACCOUNT_CONFLICT. Every node and every retry give the same answer: a recorded decline is replayed, and a refusal that isn’t recorded (ERR_INVALID_TXN_ID, ERR_PERMISSION, ERR_WIRE_PARSE, ERR_ACCOUNT_CONFLICT) is reached again by the same request. Fix the request; a recorded decline needs a new identity, because the old one keeps its verdict. The SDKs’ leader-aware clients return these at once instead of re-routing.
  4. Other — everything else, including unknown codes from a newer engine. These depend on state that can change: a lagging node that hasn’t seen an account yet, a ledger or currency not created or not active. The C, Java and Python leader-aware clients retry these after a leader refresh. The Go and Rust retry helpers can’t refresh the leader, so they return these at once. If you retry by hand, look at the status first: on a transfer, reserve or tiered transfer most of these are durable declines, so a retry under the same identity replays them even after you fix the state — fixing it and resubmitting needs a new identity.
  5. Batches are not retried for you. A batch reply carries a status per item; resubmit the retryable items under their same identities.
  6. Operations with no identity are not replayed blindly. create_ledger with ledger id 0 (the server assigns the id) has no identity to deduplicate on. The C, Java and Python leader-aware clients retry it only when the failure proves it never ran: after a retryable status, for example. After a transport failure or an “other” status its outcome is unknown, so they return the error rather than risk a second ledger. Don’t wrap such a call in the Go or Rust retry helper, because the helper retries transport errors.
  7. OK_DUPLICATE means “already applied” — one concept, two surfaces. On the binary protocol, treat it like OK in the happy path: the operation’s effect exists, and the result you’re holding describes it. The REST gateway answers a duplicate submit with HTTP 409 and "error": "duplicate_txn", carrying status_code: 1 and the original transfer’s id and position — because this submission didn’t apply (the original did). Either way, the decision rule is identical: effect exists → proceed; log duplicates if you want visibility into how often your delivery layer redelivers.

What the SDKs do for you

All five SDKs follow the retry policy in contracts/status-codes.env:

  • Budget. One setting gives the number of retries allowed after the first attempt. It defaults to RETRY_ATTEMPTS = 1, so a call makes at most two attempts. The default is exported as LEDGERD_SMART_DEFAULT_RETRY_ATTEMPTS in C, SmartLedgerClient.DEFAULT_RETRY_ATTEMPTS in Java, tuladb.RETRY_ATTEMPTS in Python, tuladb.DefaultRetryAttempts in Go and tuladb::retry::RETRY_ATTEMPTS in Rust. 0 means never retry.
  • Backoff. The delay is full jitter: backed-off retry n (0 for the first) waits a random time in [0, min(2000 ms, 50 ms × 2ⁿ)]. No retry starts more than 15,000 ms after the first attempt, or past the caller’s own deadline. Instead, the last answer comes back.
  • Transport failures. The first retry after a transport failure is immediate and isn’t counted in n. Later ones back off like a status.
  • Final statuses are never retried.
  • Who retries what. The C, Java and Python leader-aware clients retry every status that isn’t final, refreshing the leader first. Go and Rust have no leader-aware client. Their helpers (tuladb.Retry in Go, RetryPolicy::retry in Rust) wrap one call and retry only retryable statuses and transport errors.

Each SDK page shows how to change the budget and the delays.

Over the REST gateway

The REST gateway maps these classes to HTTP:

  • A retryable status is 503 with Retry-After: 1: retry with the same txn_id.
  • No engine answer at all (connect failed, connection dropped) is 503 with error: "ledgerd_connect_error" and no Retry-After: the outcome is unknown, so retry with the same txn_id.
  • 16, 17, 19 and unknown codes are 502.
  • 44 is 409 account_conflict and 43 is 410 history_purged.
  • 1 on a submit is 409 duplicate_txn, and 41 on an explicit reject is 200 with ok: true.
  • Any other non-success status is a decline: 200 with ok: false on a submit. On a read it is 404 for ERR_NOT_FOUND and 422 otherwise.

The full list of gateway responses, including the ones the gateway makes on its own, is in Error reference by surface.

When a request breaks two rules

A request that violates more than one rule reports the consistency reason, not the account-eligibility one: a transfer naming a closed account and the wrong ledger answers ERR_UNKNOWN_LEDGER; a closed account and a control-account violation answers ERR_CONTROL_FORBIDDEN. The durable rejection record carries the same reason. Requests that break only one rule are unaffected.

Transport errors vs. ledger status

A decline is data — the connection stays healthy and your next request proceeds. A protocol error (bad framing, CRC mismatch) is different: the SDK resets the connection because the stream can no longer be trusted. After any transport failure where the outcome is unknown, the recovery is always the same: reconnect and resubmit the same operations. The parts that landed answer OK_DUPLICATE; the parts that didn’t, apply.

Refused before the ledger saw it

Everything above is the engine’s verdict: the ledger considered the operation and declined it, and that decline is a durable record you can query. A request can also be refused earlier — by the REST gateway, on shape rather than substance. A missing ledger_id, an amount above the int64 the wire carries, a state filter that isn’t one of the permitted values. Nothing is recorded, because nothing reached the ledger.

The REST gateway answers these with HTTP 400, whichever check caught the request, an unknown currency or a body that isn’t JSON included. The one exception is a batch whose envelope is unusable, which is 422.

These are a separate namespace, deliberately. Gateway codes are strings and never begin with ERR_, so code: "REQ_FIELD_REQUIRED" can never be mistaken for status 42:

{
  "ok": false,
  "error": "invalid_request",
  "code": "REQ_FIELD_REQUIRED",
  "field": "ledger_id",
  "message": "ledger_id is required for reject"
}
Code Means Carries
REQ_FIELD_REQUIRED A required field was absent, empty, or zero where zero isn’t a value field
REQ_FIELD_OUT_OF_RANGE Present and well-formed, but outside the accepted range field, min, max
REQ_FIELD_INVALID_VALUE Outside a fixed set of permitted values, or malformed field, allowed
REQ_FIELD_CONFLICT Individually valid fields that can’t be combined fields
REQ_REASON_CODE_RESERVED A reject reason_code in the engine-reserved range field
REQ_ROUTE_AMBIGUOUS Several ledgers share the currency and the request didn’t name one currency
REQ_INVALID A refusal with no specific code assigned yet —
invalid_amount Decimal amounts (feat/decimal-amounts). amount_decimal isn’t a decimal string, has more decimal places than the currency’s scale, or overflows reason (invalid, too_many_decimals, overflow), field, scale
currency_not_registered Decimal amounts (feat/decimal-amounts). amount_decimal names a currency with no registered scale field, currency

The two lower-case codes come with decimal amounts, on the feat/decimal-amounts branches and not yet released. Sending both amount and amount_decimal is REQ_FIELD_CONFLICT. Gateway codes still never begin with ERR_.

Three things are worth knowing about this table.

The code names the cause, not the field. REQ_FIELD_REQUIRED with field: "ledger_id" rather than a REQ_LEDGER_ID_REQUIRED — so a new required field never adds a new code, and your handling of the code keeps working as the API grows.

message is prose and is not a contract. It is written for a person reading a log. Branch on code; the wording may change in any release.

REQ_INVALID is a floor, not a failure. Any request refused on its shape carries a code from the start, so you never have to handle a validation error that has none. Refusals move out of REQ_INVALID into specific codes over time, and that move is not a breaking change for anyone matching the specific ones.

One scope note: code accompanies validation refusals — the ones answering error: "invalid_request". Refusals for other reasons (unauthorized, forbidden, rate_limited, payload_too_large, not_found, method_not_allowed) are already unambiguous from their HTTP status, so they carry no code rather than a second name for the same thing. Read its absence as “not a field problem — check the status”. The gateways don’t yet agree on the code for every refusal; the reference lists where they differ.

error keeps its coarser value (invalid_request) alongside code, so integrations written before this catalog existed are unaffected.