API Error Reference
Error shapes for the orchestrator's public API (https://api.sweepster.xyz). Every status/body pair below is sourced directly from packages/orchestrator/src/app.ts and the @sweepster/http-mw safe-error mapper it uses (toErrorResponse / toErrorMessage) — spot-checked against the source, not the theoretical spec.
Why some bodies say "internal error" for what sounds like a specific problem: the orchestrator's safe-error mapper (
toErrorResponse) only passes a thrown error's message through to the client when the error is aDomainError(from@sweepster/http-mw). The orchestrator does not currently throw anyDomainErrors — every error it throws (unknown quote, sanctioned address, wrong 7702 chain, token declined by risk, etc.) is a plainError, which the mapper treats as unsafe and masks to the literal string"internal error"before returning it, while logging the real message + stack server-side (visible viawrangler tail). The HTTP status code still reflects what happened (422 vs. 400 vs. 403); the message body frequently does not. Do not pattern-match on theerrorstring to distinguish causes on these masked paths — use the status code, and treat the body as informational only.
422 — Invalid JSON body
Malformed request body (not valid JSON) on POST /quote or POST /intents.
{ "error": "invalid JSON body" }When it happens: the request body couldn't be parsed as JSON at all. What to do: fix the client's JSON serialization; this is a client bug, not worth retrying as-is.
422 — Schema validation failure
POST /quote or POST /intents body parsed as JSON but failed Zod schema validation. The error string is every Zod issue's message, joined with "; " (parsed.error.issues.map(i => i.message).join("; ")) — this is the one 422 case that is NOT masked, because it's built directly in app.ts, not routed through toErrorResponse.
{ "error": "must be a 40-hex-digit 0x address; amountIn must be a non-negative integer decimal string" }When it happens: a field is missing, wrong type, or fails a regex/range check — e.g. owner isn't a 0x+40-hex address, amountIn isn't a non-negative integer decimal string, tier isn't one of tier1/tier2/tier3, or (on /intents) the required EIP-7702 authorization object is absent or malformed. /intents REQUIRES authorization — there is no Permit2 fallback; omitting it is a 422, not a 400. What to do: read every joined message, fix each field, and re-request. This is the only error body that reliably tells you what was wrong.
422 — Quote build failure (masked)
POST /quote's handler wraps buildQuote(...) in a try/catch. Any thrown error that does not match the sanctions pattern below falls through to toErrorResponse(c, e, 422). Since the orchestrator throws only plain Errors (never a DomainError), the message is always masked:
{ "error": "internal error" }When it happens (real causes seen in quote.ts, none distinguishable from the body alone):
- the token failed the Token Risk Engine screen (
token declined: <reason>) - computed fees meet or exceed
amountIn(fees exceed amount: ...) - a native-token sweep was requested on a chain with no wrapped-native target, or on a chain without EIP-7702 support
- any other unexpected internal failure
What to do: treat a 422 here as "this quote request cannot be built right now" — for a token/chain combination, check the Token Risk Engine and Supported Chains docs before retrying; don't retry blindly since some of these (fees ≥ amount, token declined) won't succeed on retry with the same inputs.
403 — Sanctions screening block
POST /quote special-cases this one: if the caught error's message matches /OFAC screening|sanction/i (i.e. the owner or destination address is sanctions-screened), the handler returns 403 with a fixed, non-address-echoing message before falling through to the generic masked 422 above:
{ "error": "request blocked by sanctions screening" }When it happens: owner or destination matched the sanctions screener (OFAC SDN list via the Foreseer sanctions API, address-only, fail-closed). What to do: this is a deliberate compliance decision, not a bug — do not retry with the same addresses. Note this 403 carve-out exists only on /quote; see the 400 section below for what happens if a request re-screens as sanctioned at submit time on /intents.
400 — Submit failure (masked)
POST /intents's handler wraps submitSigned(...) in a try/catch and always calls toErrorResponse(c, e, 400) — there is no sanctions carve-out here, unlike /quote. Since every error submitSigned throws is a plain Error, the body is always masked:
{ "error": "internal error" }When it happens (real causes seen in submit.ts, none distinguishable from the body alone):
quoteIddoesn't exist, or the quote already expired- the quote was already submitted (double-submit guard)
- the
owner/destinationre-screens as sanctioned at submit time (TOCTOU re-check — this path returns 400, not 403, because it isn't caught by/quote's special-case regex) authorization.chainIddoesn't match the order's source chainauthorization.addressdoesn't match the chain's configuredSweepDelegate(rejected before any broadcast — refuses to delegate the EOA to an unexpected contract)- the chain's submitter doesn't support EIP-7702 (type-4) broadcast
What to do: re-fetch a fresh quote via POST /quote and re-sign rather than retrying the same quoteId — most of these causes are not transient. If you suspect a chain/delegate misconfiguration rather than an expired quote, check Supported Chains.
404 — Unknown intent
GET /intents/:id when no intent exists for that ID (built directly, not through the mapper):
{ "error": "not found" }When it happens: the id path param doesn't match any intent the orchestrator knows about (typo, wrong environment, or an ID from a different deployment). What to do: double-check the ID came from a real POST /intents response in the same environment.
401 — Admin routes
POST /watch/tick, GET /admin/health, and GET /admin/alerts are gated by a constant-time Authorization: Bearer <ORCH_ADMIN_TOKEN> check:
{ "error": "unauthorized" }When it happens: the Authorization header is missing or doesn't match the configured admin token. What to do: these routes are operator-only; builders integrating the public API should never hit them. If you're an operator, verify the Authorization: Bearer <token> header is set correctly.
429 — Rate limit exceeded
@sweepster/http-mw's rateLimit middleware, applied to /quote, /intents, /intents/*, and /portfolio/*. Default: 100 requests per 60 seconds, keyed per-IP (cf-connecting-ip, falling back to a shared "anon" bucket off-Cloudflare). Exceeding the window returns:
{ "error": "rate limit exceeded" }with a Retry-After response header (seconds until the window resets, e.g. Retry-After: 60).
When it happens: more than the allowed request count from one IP within the window. What to do: back off and retry after the Retry-After duration; don't hot-loop. Builders with higher-volume needs should batch/dedupe client-side rather than expect a higher default limit.
502 — Upstream/provider failure (portfolio routes)
GET /portfolio/:owner and GET /portfolio/:owner/sweepable also route unexpected errors through toErrorResponse(c, e, 502) (e.g. an upstream balance/pricing provider failing). Same masking rule applies — expect { "error": "internal error" } with status 502 for anything not caught by the routes' own Zod/param validation (which returns 422 with the real validation message, same pattern as /quote//intents).
Related
- Orchestrator API Reference — full request/response shapes per endpoint
- Supported Chains — which chains/tokens are eligible before you hit a 422
- Token Risk Engine — why a token quote might be declined