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:
- The HTTP error envelope — for request-level failures (bad shape, auth, not found, rate
limit). A
{ "ok": false, "error": { … } }object. - The
SubmitResponse— forPOST /api/v1/trading/actions(and MM/BSL order entry). Even a rejected order returns a flatSubmitResponsewith a top-levelerrorstring and a derivedcode.
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 — notretryable) istrueonly for409,429,503,500.requestIdis always present — quote it in support requests.detailsis omitted when there's nothing structured to add;details.causecarries 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 status | error.code | Meaning |
|---|---|---|
| 400 / 422 | BAD_REQUEST | Malformed request or strict schema/shape rejection |
| 401 | UNAUTHORIZED | Authentication required or signature invalid |
| 403 | FORBIDDEN | Authenticated but not authorized for this action |
| 404 | NOT_FOUND | Resource/route unknown |
| 409 | CONFLICT | Idempotency or state conflict (retriable) |
| 429 | RATE_LIMITED | Rate limit exceeded (retriable) |
| 406 | NOT_ACCEPTABLE | Content negotiation failed |
| 503 | SERVICE_UNAVAILABLE | Temporarily unavailable (retriable) |
| 500 | INTERNAL_ERROR / SERVER_ERROR | Internal 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).
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:
| Code | HTTP | Meaning |
|---|---|---|
AGENT_NOT_FOUND | 404 | Agent id does not exist |
AGENT_REVOKED / AGENT_EXPIRED | 403 | Agent lifecycle no longer permits use |
INVALID_SCOPE | 403 | Required independent scope is missing |
AGENT_POLICY_REJECTED | 403 | Account, market, IP, or notional policy failed |
AGENT_POLICY_RATE_LIMITED | 429 | Per-agent policy budget exhausted |
API_CREDENTIAL_REQUIRED | 401 | Machine agent request omitted its credential |
API_CREDENTIAL_INVALID | 401 | Credential/signature material is invalid |
API_CREDENTIAL_REVOKED | 403 | Credential was revoked or its overlap ended |
ACCOUNT_MISMATCH | 403 | Agent/credential is bound to another trading account |
INVALID_SIGNATURE | 400 | Delegated payload signature is invalid |
DUPLICATE_NONCE | 409 | Delegated nonce was already consumed |
NONCE_EXHAUSTED | 429 | Delegated nonce allocation or policy budget is exhausted |
TRADING_ACCOUNT_CLOSING | 403 | Closing/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.
| Code | Retriable | Meaning |
|---|---|---|
nonce_below_floor | No (pick fresh) | Below the window floor — permanently consumed/stale |
nonce_outside_window | After floor advances | Too far ahead; fill lower nonces first |
nonce_replayed | No (pick fresh) | In-window but already used/in-flight |
nonce_mismatch | After resync | Uncategorized nonce reject; use nextUsableNonce |
insufficient_balance | No | Balance below order requirement |
risk_would_exceed_available_usdc | No | Projected aggregate USDC requirement exceeds available free USDC |
insufficient_usdc | No | USDC balance is insufficient for the order |
insufficient_spot_asset | No | Spot ask exceeds the configured base-asset balance |
insufficient_shares | No | Outcome ask exceeds available YES/NO inventory; on a spot market this indicates a wrong client envelope |
cross_shard_credit_kill_switch | After operator recovery | Cross-shard credit admission is disabled by the safety switch |
cross_shard_account_cap_exceeded | After exposure changes | Account locked-credit cap would be exceeded |
cross_shard_market_cap_exceeded | After exposure changes | Market locked-credit cap would be exceeded |
cross_shard_credit_rejected | After state/policy changes | Other cross-shard credit admission rejection |
post_only_requires_passive_limit_order | No | Post-only requires a non-market passive limit order |
post_only_would_cross | No | Post-only order would have crossed |
fok_not_filled | No | Full requested quantity was not immediately available |
reduce_only_qty_exceeds_position | No | Reduce-only quantity exceeds the current same-outcome position |
reduce_only_invalid_side | No | Reduce-only is invalid for that side/action |
invalid_stp_mode | No | Unknown STP value; use cancel_maker, cancel_taker, reject, or skip_self |
spot_notional_below_minimum | No | Spot order is below the market minimum notional |
price_must_be_positive · qty_must_be_positive | No | Zero or negative price / quantity on a place or quote leg |
price_not_aligned_to_tick | No | Price is not aligned to the market tick size |
qty_not_aligned_to_lot | No | Quantity is not aligned to the market lot size |
qty_exceeds_max | No | Quantity exceeds the market maximum |
fee_exceeds_notional | No | Calculated fee exceeds order notional |
duplicate_order_id | No | Deterministic order id already exists |
idempotency_key_conflict | No | The same idempotency key was reused with a different payload |
trading_halted | After readiness recovers | Global trading safety gate is halted; stop placement and poll readiness |
unauthorized_cancel | No | Caller does not own or control the target order |
order_not_open | No | Target order is already terminal |
invalid_amend | No | Amend request is invalid for the target order |
account_market_open_order_cap_exceeded | After cancellation | Per-account, per-market open-order cap reached |
account_open_order_cap_exceeded | After cancellation | Per-account open-order cap reached |
price_level_order_cap_exceeded | After cancellation | Per-price-level order cap reached |
book_depth_exceeded | After book changes | Configured book-depth cap reached |
self_trade_prevented | No | Self-trade-prevention triggered |
order_not_found | No | Cancel/amend target unknown or already terminal |
invalid_signature | No | Signature verification failed |
market_not_found | No | Market id not recognized |
expired | No | Action or order expiry passed |
unknown_variant · unknown_field · missing_field · schema_violation | No | Strict schema/shape rejection |
rate_limited | Yes | Back off per Retry-After |
backpressure | Yes | Ingress backlog limit hit; back off and retry |
runtime_unavailable | Yes | Write 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
}
]
}
status∈filled,partially_filled,resting,canceled,applied,rejected. Onlyrejectedentries carryrejectCode/rejectReason.nonceConsumedis definitive in terminalactionResults. Missing/null is unknown and must not be interpreted as reusable.rejectCodeis 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.rejectReasonpreserves 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.
| Code | Name | Meaning | Client action |
|---|---|---|---|
| 1 | InvalidMagic | Frame magic / handshake bytes wrong | Fix the codec |
| 2 | UnsupportedVersion | Protocol version not accepted | Negotiate the advertised version |
| 100 | BackpressureFull | Shard or ingress queue saturated | Back off with jitter; keep the session |
| 101 | RateLimitExceeded | Session / account / key budget exhausted | Pace against the budget view; keep the session |
| 102 | IdempotencyReplay | Same idempotency key inside the replay window | Treat as already answered; fetch state |
| 103 | TradingHalted | Venue or market halted | Stop placing; poll readiness; not load |
| 104 | LedgerDegraded | Write admission closed pending ledger recovery | Pause; wait for recovery; not load |
| 105 | WriteLeaseFenced | Writer lease changed under the socket; the socket is closed | Reconnect; never auto-replay the rejected frame |
| 106 | DurabilityUnknown | Command may be durable but the boundary could not be proven | Reconcile by idempotency key / gateway seq; never blind-retry |
| 200 | AuthFailed | Credential, signature or session identity invalid | Fix credentials; do not retry |
| 201 | NonceReuse | Legacy catch-all nonce reject; the detail text carries nonceFloor and nonceWindow | Resync the nonce cursor |
| 202 | SessionViolation | Identity switch, non-monotonic gateway sequence, stale mapping — or, on production at the 2026-09-04 review, a wallet-signed frame the gateway cannot verify | Reconnect with a fresh session; refresh the connectivity bundle |
| 203 | NonceBelowFloor | Nonce below the replay-window floor | Re-sign with an unused in-window nonce |
| 204 | NonceSpent | Nonce inside the window but consumed | Re-sign with a different unused nonce |
| 205 | NonceAboveWindow | Nonce at or above the window edge | Fill lower nonces or fence, then re-sign |
| 300 | MarketUnknown | Market id or compact market index unknown | Refresh exchange-info / connectivity bundle |
| 301 | AccountUnknown | Account or compact account index unknown | Onboard 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-RemainingX-RateLimit-Reset-Ms— milliseconds until the window resets, a small number like1000. It is not an absolute epoch timestamp.Retry-After— whole seconds, emitted only on429/503.
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-Afteron429/503. - Use idempotency keys so retries are safe (Idempotency).
- Surface non-retriable errors to the strategy without automatic retry; never retry a
nonce_below_floorornonce_replayed— pick a fresh in-window nonce instead.