Skip to main content

Error Model

Senticore returns errors in two distinct shapes depending on where a request fails. Knowing which one you're looking at is the whole game:

  1. The HTTP error envelope — for request-level failures (bad shape, auth, not found, rate limit). A { "ok": false, "error": { … } } object.
  2. The SubmitResponse — for POST /api/v1/trading/actions (and MM/BSL order entry). Even a rejected order returns a flat SubmitResponse with a top-level error string and a derived code.

1. HTTP error envelope

{
"ok": false,
"error": {
"code": "BAD_REQUEST",
"message": "bad request: unknown field `foo`",
"retriable": false,
"requestId": "01J9Z6X7Q2K8N4M0",
"details": { "cause": "unknown_field" }
}
}
  • The wrapper is { ok, error }. Inner fields are camelCase.
  • retriable (note the spelling — not retryable) is true only for 409, 429, 503, 500.
  • requestId is always present — quote it in support requests.
  • details is omitted when there's nothing structured to add; details.cause carries the machine reason for shape/validation failures.

error.code is the status class, not a business reason

The envelope code is derived from the HTTP status — it is not a per-rule business code:

HTTP statuserror.codeMeaning
400 / 422BAD_REQUESTMalformed request or strict schema/shape rejection
401UNAUTHORIZEDAuthentication required or signature invalid
403FORBIDDENAuthenticated but not authorized for this action
404NOT_FOUNDResource/route unknown
409CONFLICTIdempotency or state conflict (retriable)
429RATE_LIMITEDRate limit exceeded (retriable)
406NOT_ACCEPTABLEContent negotiation failed
503SERVICE_UNAVAILABLETemporarily unavailable (retriable)
500INTERNAL_ERROR / SERVER_ERRORInternal error (retriable)

Business reasons (insufficient balance, post-only-would-cross, nonce rejects, …) are not in the envelope — they come back on the submit path as a SubmitResponse.code (§2).

400 vs 422 on the submit path

Strict schema/shape failures (unknown field, missing field, bad enum) map to BAD_REQUEST and typically surface the cause in details.cause. Business rejections on the submit endpoint (insufficient balance, stale nonce) come back as HTTP 400 with a SubmitResponse, not the envelope — see below.

Delegated agent/control-plane errors

Delegated trading and /api/agents/* return the legacy flat control-plane shape { "ok": false, "code": "...", "error": "..." }. The following codes are the documented internal-beta contract and are checked against the server source plus endpoint regression tests:

CodeHTTPMeaning
AGENT_NOT_FOUND404Agent id does not exist
AGENT_REVOKED / AGENT_EXPIRED403Agent lifecycle no longer permits use
INVALID_SCOPE403Required independent scope is missing
AGENT_POLICY_REJECTED403Account, market, IP, or notional policy failed
AGENT_POLICY_RATE_LIMITED429Per-agent policy budget exhausted
API_CREDENTIAL_REQUIRED401Machine agent request omitted its credential
API_CREDENTIAL_INVALID401Credential/signature material is invalid
API_CREDENTIAL_REVOKED403Credential was revoked or its overlap ended
ACCOUNT_MISMATCH403Agent/credential is bound to another trading account
INVALID_SIGNATURE400Delegated payload signature is invalid
DUPLICATE_NONCE409Delegated nonce was already consumed
NONCE_EXHAUSTED429Delegated nonce allocation or policy budget is exhausted
TRADING_ACCOUNT_CLOSING403Closing/archived account attempted a non-reducing action

For /api/v1/trading/orders/batch, an item that lacks trade, cancel, or mint_redeem appears as a failed item in the HTTP 200 batch response; it does not gain fallback authority through quote.

2. Submit responses

POST /api/v1/trading/actions always returns a SubmitResponse — the same 11 keys every time, with optionals set to null rather than omitted.

Accepted (HTTP 200, not 202):

{
"accepted": true, "ok": true, "seq": 84213377,
"derivedOrderId": "0x7d3a1f90…", "error": null, "code": null,
"nonceConsumed": null, "nextUsableNonce": null,
"nonceFloor": null, "nonceWindow": null, "nonceHoles": null
}

ok mirrors accepted. derivedOrderId is set only for order-placing actions, null otherwise. accepted: true is an ingress acknowledgement, so nonceConsumed remains null until a terminal engine result is available.

Rejected (e.g. HTTP 400, stale nonce):

{
"accepted": false, "ok": false, "seq": null, "derivedOrderId": null,
"error": "stale nonce: account=0x1111… nonce=41 next_nonce=42",
"code": "nonce_mismatch", "nonceConsumed": false,
"nextUsableNonce": 42, "nonceFloor": null, "nonceWindow": null,
"nonceHoles": null
}

code, nextUsableNonce, nonceFloor, and nonceWindow are derived from the rejection — every nonce reject carries the window fields so you can resynchronize without guessing. See Order Concurrency & Nonces.

Submit code values

These are the actual codes the submit path emits. Treat anything not listed (or null) as non-retriable unless the HTTP status says otherwise, and keep the raw error text in your logs.

CodeRetriableMeaning
nonce_below_floorNo (pick fresh)Below the window floor — permanently consumed/stale
nonce_outside_windowAfter floor advancesToo far ahead; fill lower nonces first
nonce_replayedNo (pick fresh)In-window but already used/in-flight
nonce_mismatchAfter resyncUncategorized nonce reject; use nextUsableNonce
insufficient_balanceNoBalance below order requirement
risk_would_exceed_available_usdcNoProjected aggregate USDC requirement exceeds available free USDC
insufficient_usdcNoUSDC balance is insufficient for the order
insufficient_spot_assetNoSpot ask exceeds the configured base-asset balance
insufficient_sharesNoOutcome ask exceeds available YES/NO inventory; on a spot market this indicates a wrong client envelope
cross_shard_credit_kill_switchAfter operator recoveryCross-shard credit admission is disabled by the safety switch
cross_shard_account_cap_exceededAfter exposure changesAccount locked-credit cap would be exceeded
cross_shard_market_cap_exceededAfter exposure changesMarket locked-credit cap would be exceeded
cross_shard_credit_rejectedAfter state/policy changesOther cross-shard credit admission rejection
post_only_requires_passive_limit_orderNoPost-only requires a non-market passive limit order
post_only_would_crossNoPost-only order would have crossed
fok_not_filledNoFull requested quantity was not immediately available
reduce_only_qty_exceeds_positionNoReduce-only quantity exceeds the current same-outcome position
reduce_only_invalid_sideNoReduce-only is invalid for that side/action
invalid_stp_modeNoUnknown STP value; use cancel_maker, cancel_taker, reject, or skip_self
spot_notional_below_minimumNoSpot order is below the market minimum notional
price_must_be_positive · qty_must_be_positiveNoZero or negative price / quantity on a place or quote leg
price_not_aligned_to_tickNoPrice is not aligned to the market tick size
qty_not_aligned_to_lotNoQuantity is not aligned to the market lot size
qty_exceeds_maxNoQuantity exceeds the market maximum
fee_exceeds_notionalNoCalculated fee exceeds order notional
duplicate_order_idNoDeterministic order id already exists
idempotency_key_conflictNoThe same idempotency key was reused with a different payload
trading_haltedAfter readiness recoversGlobal trading safety gate is halted; stop placement and poll readiness
unauthorized_cancelNoCaller does not own or control the target order
order_not_openNoTarget order is already terminal
invalid_amendNoAmend request is invalid for the target order
account_market_open_order_cap_exceededAfter cancellationPer-account, per-market open-order cap reached
account_open_order_cap_exceededAfter cancellationPer-account open-order cap reached
price_level_order_cap_exceededAfter cancellationPer-price-level order cap reached
book_depth_exceededAfter book changesConfigured book-depth cap reached
self_trade_preventedNoSelf-trade-prevention triggered
order_not_foundNoCancel/amend target unknown or already terminal
invalid_signatureNoSignature verification failed
market_not_foundNoMarket id not recognized
expiredNoAction or order expiry passed
unknown_variant · unknown_field · missing_field · schema_violationNoStrict schema/shape rejection
rate_limitedYesBack off per Retry-After
backpressureYesIngress backlog limit hit; back off and retry
runtime_unavailableYesWrite runtime in recovery/unavailable

3. MM/BSL per-action rejections

The order-entry batch surface reports per-action outcomes when you request the full result mode. In private beta, treat full as a provisioned/verified contract rather than the default happy path — the tested beta submit mode is x-bsl-result-mode: ack with x-senticore-response-mode: detailed.

full responses carry an actionResults array of HotpathOrderResult:

{
"ok": true,
"responseMode": "full",
"actionResults": [
{
"seq": 4811, "clientOrderId": "mm-quote-1", "status": "rejected",
"nonceConsumed": false,
"rejectCode": "POST_ONLY_WOULD_CROSS",
"rejectReason": "post-only order would cross the spread",
"filledQty": 0, "leavesQty": 0, "feeMicro": 0, "fills": [],
"engineTsMs": 1781190000123, "serverTsMs": 1781190000125
}
]
}
  • statusfilled, partially_filled, resting, canceled, applied, rejected. Only rejected entries carry rejectCode / rejectReason.
  • nonceConsumed is definitive in terminal actionResults. Missing/null is unknown and must not be interpreted as reusable.
  • rejectCode is a coarse engine label: NONCE_REJECTED, INSUFFICIENT_BALANCE, ORDER_NOT_FOUND, POST_ONLY_WOULD_CROSS, SELF_TRADE_PREVENTED, INVALID_STP_MODE, QUEUE_LIMIT, KILL_SWITCH, ENGINE_REJECTED. rejectReason preserves the raw detail.

Request-level status: a dropped action returns HTTP 409; if apply does not complete within the durable-ack timeout the request returns HTTP 503 with actionResults possibly null or partial.

See Raw Signed Actions for the full HotpathOrderResult schema.

4. Numeric reject reasons (BSL Direct TCP, FIX tag 9101, private WebSocket)

The binary lanes and the private WebSocket carry one stable numeric taxonomy (senticore_protocol_internal::RejectReason). FIX 4.4 exposes the same number in SenticoreRejectReason(9101) next to the standard OrdRejReason(103)=99; BSL Direct TCP puts it in GatewayReject.reason; the private WebSocket reuses it in error.data.code where an order-entry reason applies.

CodeNameMeaningClient action
1InvalidMagicFrame magic / handshake bytes wrongFix the codec
2UnsupportedVersionProtocol version not acceptedNegotiate the advertised version
100BackpressureFullShard or ingress queue saturatedBack off with jitter; keep the session
101RateLimitExceededSession / account / key budget exhaustedPace against the budget view; keep the session
102IdempotencyReplaySame idempotency key inside the replay windowTreat as already answered; fetch state
103TradingHaltedVenue or market haltedStop placing; poll readiness; not load
104LedgerDegradedWrite admission closed pending ledger recoveryPause; wait for recovery; not load
105WriteLeaseFencedWriter lease changed under the socket; the socket is closedReconnect; never auto-replay the rejected frame
106DurabilityUnknownCommand may be durable but the boundary could not be provenReconcile by idempotency key / gateway seq; never blind-retry
200AuthFailedCredential, signature or session identity invalidFix credentials; do not retry
201NonceReuseLegacy catch-all nonce reject; the detail text carries nonceFloor and nonceWindowResync the nonce cursor
202SessionViolationIdentity switch, non-monotonic gateway sequence, stale mapping — or, on production at the 2026-09-04 review, a wallet-signed frame the gateway cannot verifyReconnect with a fresh session; refresh the connectivity bundle
203NonceBelowFloorNonce below the replay-window floorRe-sign with an unused in-window nonce
204NonceSpentNonce inside the window but consumedRe-sign with a different unused nonce
205NonceAboveWindowNonce at or above the window edgeFill lower nonces or fence, then re-sign
300MarketUnknownMarket id or compact market index unknownRefresh exchange-info / connectivity bundle
301AccountUnknownAccount or compact account index unknownOnboard the account through BSL HTTP first

5. Rate-limit headers

Every submit response carries rate-limit headers (see Rate Limits for the actual budgets):

  • X-RateLimit-Limit, X-RateLimit-Remaining
  • X-RateLimit-Reset-Msmilliseconds until the window resets, a small number like 1000. It is not an absolute epoch timestamp.
  • Retry-After — whole seconds, emitted only on 429 / 503.
The edge limiter returns plain text

The per-account/MM limiters return a JSON SubmitResponse on 429. The separate edge IP limiter returns a plain-text 429 body (Too Many Requests! Wait for {n}s) with only an x-ratelimit-after header. Handle a non-JSON 429.

Retry semantics

  • Retry only retriable: true (envelope) / retriable submit codes.
  • Exponential backoff with jitter, starting ~250 ms, capping ~5 s.
  • Always respect Retry-After on 429 / 503.
  • Use idempotency keys so retries are safe (Idempotency).
  • Surface non-retriable errors to the strategy without automatic retry; never retry a nonce_below_floor or nonce_replayed — pick a fresh in-window nonce instead.