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.
2. Submit responses
POST /api/v1/trading/actions always returns a SubmitResponse — the same 9 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,
"nextUsableNonce": null, "nonceFloor": null, "nonceWindow": null
}
ok mirrors accepted. derivedOrderId is set only for order-placing actions, null otherwise.
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",
"nextUsableNonce": 42, "nonceFloor": null, "nonceWindow": 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 |
post_only_would_cross | No | Post-only order would have crossed |
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",
"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.rejectCodeis a coarse engine label:NONCE_REJECTED,INSUFFICIENT_BALANCE,ORDER_NOT_FOUND,POST_ONLY_WOULD_CROSS,SELF_TRADE_PREVENTED,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. 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.