Appearance
Errors & limits
Every error has the same shape:
json
{
"error": {
"code": "invalid_request",
"message": "sellAmount is below the RFQ minimum (1000000 atomic units)",
"request_id": "req_…"
}
}A schema-level rejection adds details:
json
{
"error": {
"code": "invalid_request",
"message": "sellAmount: Expected a value greater than 0",
"request_id": "req_…",
"details": { "issues": [ … ] }
}
}code is stable and machine-readable. Branch on it, not on message. request_id is also the X-Request-Id header; quote it when you report a problem.
details is optional — treat it as absent unless you've checked. When it's present it takes one of two shapes: a schema-level failure carries an issues array naming the offending fields, and a few route-level rejections carry a reason string (sometimes with extra fields). The route-level ones that do:
details.reason | HTTP | Where |
|---|---|---|
corridor_unavailable | 400 | No RFQ corridor for the pair, or a BOOK one |
insufficient_funds | 400 | Proof of funds, with atomic required / available and sometimes committed |
proof_of_control_* | 401 | Proof of control — four reasons, table below |
taker_control_unverifiable | 502 | The EIP-1271 read for a smart-account taker failed. Retry the same proof |
Everything else the route rejects for itself (a missing taker, a size under the minimum) is a 400 with just code and message.
Not an error: no_quote
The one that surprises people. POST /v2/rfq/request returns 200 with status: "no_quote" when nobody would price your trade. It's a normal outcome, not a failure, and it carries a reason: no_makers_online, no_restricted_liquidity, or no_valid_quote (details).
Branch on data.status, not on the HTTP code.
Codes
| HTTP | code | Means | What to do |
|---|---|---|---|
| 400 | invalid_request | No RFQ corridor for the pair, a corridor in BOOK mode, both or neither of sellAmount/buyAmount, a size under the minimum, a chain your key's environment can't use, or the named taker doesn't hold enough of the sell token (proof of funds, details.reason: insufficient_funds) | Fix the request; check details |
| 401 | unauthorized | You sent a key and it's missing, malformed, revoked or unknown — or your firm request's takerProof was missing, stale, from the wrong wallet, or replayed (proof of control, details.reason: proof_of_control_*) | Check the Authorization header, or sign a fresh challenge |
| 403 | forbidden | Your key lacks the scope, or you presented a maker credential on a taker path | See Authentication |
| 404 | not_found | No such RFQ for you. Ownership is scoped, so someone else's RFQ is a 404, never a 403 | Send the right X-Rfq-Claim, or check the id |
| 409 | conflict | The RFQ isn't in a state that allows this: a different tx hash reported for an RFQ already submitted, or a cancel on one that isn't quoted | Read the status; don't blind-retry |
| 429 | rate_limited | Your request rate, or the outstanding-RFQ cap | Rate: back off, see Retry-After. Cap: no header. Wait for a slot |
| 502 | upstream_error | A chain RPC or the subgraph was unreachable | Retry with backoff |
| 503 | venue_unavailable | Quotes are briefly not being served (deploy, failover) | Retry; it's transient |
| 500 | internal | Something broke on our side | Retry; if it persists send us the request_id |
Treat 429, 502, 503 as transient. Treat 400, 403, 404, 409 as your bug.
The outstanding-RFQ cap
You may hold 4 outstanding RFQs at once, counted per API key, or per client IP when you call without one. Over that, POST /v2/rfq/request returns 429 with a message naming the cap.
A slot is held for as long as the quote is a live option on a maker's inventory, not just for the duration of the request:
- A no-quote or an error releases immediately.
- A firm quote holds its slot until the swap fills or the signed order's deadline passes.
- Reporting a transaction does not release it. The hash is unverified at that point, so releasing on it would let a caller free slots at will while their orders stayed live.
- Cancelling doesn't release it either, for the same reason: the signature still exists.
The cap is small because a solicitation is a free option on real maker inventory. It's bounded by how much of it you can hold open, not by how fast you can ask.
Retrying a cap 429. It carries no Retry-After. It isn't a time window, so there's no delay to hand you. You already hold the answer: the earliest latestOrderDeadline among your live quotes is when the next slot frees. Wait for that, or free one sooner by executing a quote. Blind backoff works too, it's just slower than reading your own deadlines.
Proof of control
None of this applies when you call with a partner API key: the key replaces the signature, so takerProof is neither needed nor checked.
Before the funds check below even runs, a firm request without an API key has to prove it controls the taker it names, with a takerProof signature — otherwise the funds check could be satisfied by naming any public funded wallet. The Authentication page has the challenge format. A refused proof is a 401 unauthorized whose details.reason says exactly what to fix:
details.reason | Means | What to do |
|---|---|---|
proof_of_control_required | No takerProof in the body and no live grant for this caller | Sign the challenge and send it |
proof_of_control_stale | issuedAt more than 60 s from our clock | Sign a fresh challenge; check your clock |
proof_of_control_invalid | The signature doesn't verify as taker | Sign with the wallet the request names |
proof_of_control_replayed | The nonce was already bound to different contents, or to a different caller | Sign a fresh challenge with a new nonce |
A smart-account proof needs one on-chain isValidSignature read. If that chain is unreachable we answer 502 upstream_error with details.reason = taker_control_unverifiable — the proof is still good, so retry with the same one rather than re-signing.
One signature covers your requests for about 12 hours: a verified proof records a caller-bound grant and later firm requests may omit takerProof. Treat proof_of_control_required as the signal to sign again rather than tracking the window yourself — it's answered before anything is locked, so the signed resend costs only the rate-limit slot. Details on the Authentication page.
Proof of funds
Because a firm quote reserves maker inventory for the taker you name, that wallet has to actually hold the sell token before we solicit. A real swapper always does, so this only bites requests that never intended to settle — the throwaway-wallet quote spam the outstanding-RFQ cap alone can't price out. Paired with proof of control above, passing it costs the capital it was designed to demand: a wallet you hold, actually funded.
POST /v2/rfq/request reads the taker's on-chain sell-token balance:
- Exact-input (
sellAmount) is checked before any maker is woken, against the gross, fee-inclusive spend cap you sent. - Exact-output (
buyAmount) is checked once the quote is priced, against the debit (quote.takerPays); if it fails, the reserved inventory is released before the request is rejected.
The balance isn't checked in isolation. Each of your live firm quotes reserves its spend amount against the taker wallet, and a new request is admitted only when the unreserved balance covers it: balance ≥ committed + required, where committed is what your other live quotes on the same chain and sell token have already spoken for. So a wallet holding B backs at most B worth of live signed orders — it can't request several B-sized quotes at once.
The reservation lasts as long as any signed order in the quote does: until latestOrderDeadline plus a short clock-skew buffer, or until the fill settles (the spend then shows in the balance itself, so nothing is counted twice). For a single-maker quote latestOrderDeadline equals orderDeadline; in a multi-maker bundle each slice is independently signed, so the longest-lived one keeps your funds spoken for after the shortest expires. Cancelling a quote or letting it lapse does not free the reservation early — the signature you were handed stays executable until its deadline regardless, same reason the outstanding-quote cap keeps its slot. To re-quote the same funds sooner, execute the quote you have.
A shortfall is a 400 with details:
json
{
"error": {
"code": "invalid_request",
"message": "Your wallet does not hold enough of the sell token to settle this trade",
"details": {
"reason": "insufficient_funds",
"sellToken": "0x…",
"required": "1000000000",
"available": "1250000000",
"committed": "500000000"
}
}
}required, available, and committed are atomic sell-token units. available is the raw balance; when committed is present, the shortfall came from your other live quotes (available − committed < required), so either wait for one to expire or execute it. Only the balance is checked, not the Permit2 allowance — the approval is a separate step you send at execution, so a funded wallet that hasn't approved yet is fine. If the balance can't be read (an RPC blip), you get a transient 502, never a false insufficient_funds.
Rate limits
Per API key, on top of the cap above. The default tier is 60 requests per minute; higher-volume partners can be moved up. Ask us. Without a key the same 60/min is keyed to your client IP instead, so everyone behind a shared address shares one bucket. There's also a coarse per-IP ceiling in front of authentication as a brute-force backstop.
Responses the per-key limiter handled carry the current state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your ceiling for the window |
X-RateLimit-Remaining | Requests left in the window |
X-RateLimit-Reset | Seconds until the window resets |
Read them defensively. Anything rejected before the limiter runs has none of them. A bad or revoked key (401) and the coarse IP backstop (429) both answer earlier in the request. Treat a missing X-RateLimit-* as "unknown", not zero.
Retry-After is set on the two 429s that come from a time window: the per-key limiter and the IP backstop. The outstanding-RFQ cap sends no Retry-After, because there is no window to wait out: a slot frees when a quote fills or its order deadline passes, not on a clock. Don't block on the header; branch on which 429 you got, using the rule below.
The limit is enforced per running instance, so the effective ceiling can sit slightly above the configured number. Treat it as a floor you're guaranteed, not a target.
Timeouts
POST /v2/rfq/request blocks while makers answer, up to the corridor's reply budget (750 ms by default). Set your client timeout comfortably above that. A couple of seconds is enough. A client that times out at 500 ms abandons requests we are about to answer, and each abandoned one still holds a slot until its quote expires.