Skip to content

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 a DomainError (from @sweepster/http-mw). The orchestrator does not currently throw any DomainErrors — every error it throws (unknown quote, sanctioned address, wrong 7702 chain, token declined by risk, etc.) is a plain Error, 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 via wrangler 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 the error string 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.

json
{ "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.

json
{ "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:

json
{ "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:

json
{ "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:

json
{ "error": "internal error" }

When it happens (real causes seen in submit.ts, none distinguishable from the body alone):

  • quoteId doesn't exist, or the quote already expired
  • the quote was already submitted (double-submit guard)
  • the owner/destination re-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.chainId doesn't match the order's source chain
  • authorization.address doesn't match the chain's configured SweepDelegate (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):

json
{ "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:

json
{ "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:

json
{ "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).

Non-custodial · Atomic · Audit + bug bounty planned before mainnet value.