Appearance
Limit orders
A limit order rests in the book until a taker fills it or it expires. Placing one is a two-step, non-custodial flow: we build the exact EIP-712 payload, you sign it with your wallet, and you send the signed order back.
A limit order fills whole or not at all, so a filled order pays you exactly the amounts it named. That makes it the exact-amount execution path — see the exact-amount swaps guide for how to price it so it actually fills.
Price it — quote
GET /v1/limit-orders/quote?chainId=8453&sellToken=0xCNGN&buyToken=0xUSDT&sellAmount=1550000000Requires quotes:read. Returns the buyAmount your order should name, computed from the live book, at two reference prices:
json
{
"data": {
"sellAmount": "1550000000",
"minBuyAmount": "2000",
"fastFill": {
"buyAmount": "1089913",
"rateRay": "703169677419354838709677",
"marginBps": "10",
"depth": { "makers": 2, "totalCapacity": "48000000000", "maxMakerCapacity": "31000000000" }
},
"fastFillUnavailableReason": null,
"queue": { "buyAmount": "1122980", "rateRay": "724503225806451612903225" }
}
}Rates here are in the maker frame — buyToken-per-sellToken, decimal-normalized, RAY-scaled: what you receive per unit sold.
fastFillcrosses the best market-maker bid that can afford your whole order, with enough headroom (the reactor fee plusmarginBps, default 10) that their taker bot profits by filling you. Fills typically land within seconds. Null when no single maker can take the order (fastFillUnavailableReasonsays why — one taker fills one order from one balance, sodepth.maxMakerCapacityis the whole-order ceiling).queuerests your order at the top of its own side of the book. Usually a better price than fastFill, but not guaranteed — compare the two returnedbuyAmounts (why) — and it only fills when organic flow takes it.
Depth figures are advisory: the book re-quotes every few seconds, and other orders compete for the same maker balances.
Tuning marginBps
marginBps is a request parameter (0–1000), not a market value — it's the profit you concede to the operator who fills you, on top of the reactor fee. Their bot's fill profit is roughly notional × marginBps, and it fills only when that clears its private per-order minimum (limit_taker_min_profit_debt, a gas/dust guard that defaults to 0):
GET /v1/limit-orders/quote?chainId=8453&sellToken=0xCNGN&buyToken=0xUSDT&sellAmount=1550000000&marginBps=2The same value goes in prepare's pricing block: { "mode": "fastFill", "marginBps": 2 }.
The default of 10 is deliberately conservative — the book reveals every operator's bid, but not their profit minimums, so the default has to clear typical thresholds blind. If a well-priced order keeps expiring, the operators' minimums are probably above notional × margin: raise marginBps (small orders especially). If you run the corridor's own bot and know its limit_taker_min_profit_debt, you can pass your true floor: with the default of 0, marginBps=1 is the minimum that reliably fills (0 is accepted but integer flooring can round the bot's profit to exactly zero, which its evaluator skips); otherwise choose the smallest margin with notional × marginBps / 10000 ≥ min_profit_debt.
How queue is priced
The short version: queue has no margin parameter because no bot is being paid to act — the order rests just past the best competing order on its own side, and only organic instant-swap flow going the other way consumes it. Usually a better price than fastFill, never a latency bound — and since "usually" isn't "always" (see below), pick a side by comparing the two returned buyAmounts, not by rule of thumb.
queue comes back null in two distinct cases — don't misread the second as missing liquidity: your side of the book is empty (nothing to price against), or your order is too small — the computed buyAmount would fall below the response's minBuyAmount (an output that can't carry the reactor fee). For a small order, compare against minBuyAmount and increase the size rather than concluding the corridor is dead.
The math
Its price comes from one rule: rest just past the best competing order on your side (the operators' rungs plus other users' resting orders), so opposing flow consumes you first.
Careful with orientations here. The computation runs in the book rate — sellToken-per-buyToken, the orientation resting orders are stored in — which is the inverse of the maker-frame rate the response reports:
bestCompetingRate = highest resting rateRay on your side (book rate: sellToken-per-buyToken)
queueRate = bestCompetingRate × (1 + 2/10000) (book rate — hence the division below)
buyAmount = ⌊ sellAmount × 10^buyDecimals × RAY / (queueRate × 10^sellDecimals) ⌋The response's queue.rateRay (and prepare's assumedRateRay) is the maker frame, ≈ RAY² / queueRate. To verify buyAmount from the reported rate, multiply instead of dividing:
buyAmount ≈ ⌊ sellAmount × queue.rateRay × 10^buyDecimals / (RAY × 10^sellDecimals) ⌋The 2 bps edge isn't arbitrary: the instant matcher treats rates within 1 bp as one price level and ranks by size inside a level, so beating the best by anything less would drop you into a size-ordered cluster instead of strictly first. Two basis points clears the tolerance.
What this costs you follows directly when the top competing order is an operator rung at spread s: your effective distance from mid is 2 bps − s — outbidding a bid that sits s below mid by 2 bps lands you 2 − s bps past it. Against a tight 1 bps book you pay ~1 bp over mid; against a 5 bps book you'd rest 3 bps inside mid. Compare fastFill's all-in cost of s + fee + marginBps: in that case queue prices better by 2s + fee + margin − 2 bps, and the tighter the book, the smaller the advantage.
That identity is not universal, because the competing side includes other users' resting orders, not just operator rungs. Each queue-guided order tops the current best by 2 bps, so when several users have queued, the top of the book compounds away from mid — and a fresh queue quote can come back worse than fastFill (worse price and no latency bound: strictly dominated). The response hands you both sides so one comparison settles it: take queue only when its buyAmount beats fastFill's.
What you normally give up is the latency bound — but be careful where the bot/organic line actually sits, because fastFill.buyAmount is not the bots' break-even: it's the break-even minus the fee-and-margin haircut. The taker legs (which are served every resting order) accept anything with positive profit above their private minimums, which puts your order in one of three bands:
queue.buyAmount ≤ fastFill.buyAmount— inside the guided price: a bot will take it, fast, and you're paying more than fastFill for it (strictly dominated).- up to
fastFill.buyAmount × (1 + marginBps/10⁴)— the margin band. This spans fastFill's haircut below the true break-even at the best bid, so a bot with a low enough private profit floor can still fill it. Bot-fast latency is possible, not guaranteed. - above that — beyond every bot's break-even at the current best bid: organic flow only, no latency bound. A healthy, unstacked book prices
queuehere.
Which filler audience your order targets, and what latency to expect, therefore follows from the same numbers as the price: compare queue.buyAmount against fastFill.buyAmount and the margin band. Both buyAmounts are what you'd name as the order's outputAmount, and both rateRays are reported in the same maker frame (buyToken-per-sellToken, RAY-scaled), so everything compares directly.
One-time setup: approve Permit2
A limit order is gasless because your funds never leave your wallet: you sign a message, and if the order fills, the reactor pulls the sell token from your wallet through Permit2 inside the fill transaction. That only works if Permit2 has an ERC-20 allowance on your token — a one-time approval per sell token, per chain, from the maker wallet.
Skipping it doesn't just block the fill — it blocks submission. The book only accepts an order the maker can deliver, and "can deliver" means min(wallet balance, Permit2 allowance). With no approval that's zero, and POST /v1/limit-orders rejects with Order batch is not funded.
Permit2 is canonical at the same address on every chain — 0x000000000022D473030F116dDEE9F6B43aC78BA3 (see the address book). Check-and-approve with viem:
ts
import { createPublicClient, createWalletClient, erc20Abi, http, maxUint256, type Hex } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { bsc } from 'viem/chains'
const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
const SELL_TOKEN = '0x55d398326f99059fF775485246999027B3197955' // the token your orders sell
const account = privateKeyToAccount(process.env.MAKER_PRIVATE_KEY as Hex)
const pub = createPublicClient({ chain: bsc, transport: http() })
const wallet = createWalletClient({ account, chain: bsc, transport: http() })
const allowance = await pub.readContract({
address: SELL_TOKEN,
abi: erc20Abi,
functionName: 'allowance',
args: [account.address, PERMIT2],
})
if (allowance === 0n) {
const hash = await wallet.writeContract({
address: SELL_TOKEN,
abi: erc20Abi,
functionName: 'approve',
args: [PERMIT2, maxUint256],
})
await pub.waitForTransactionReceipt({ hash })
}Approving Permit2 for maxUint256 is the ecosystem-standard pattern, and the approval alone moves nothing: every actual transfer additionally requires an order signature from you, each with a single-use nonce, so your exposure is bounded by the orders you've signed. If your security policy prefers tight allowances, approve per-order amounts instead — just know the allowance depletes as orders fill, and submissions start failing the funded check again until you re-approve.
After this, everything in the flow below is signature-only: placing costs no gas, reposting after expiry costs no gas, and letting an order expire costs nothing. The only other on-chain transaction you'd ever send is an early cancel (invalidateUnorderedNonces on Permit2, which burns the order's nonce) — and letting a short TTL lapse is the free alternative.
Step 1 — prepare
POST /v1/limit-orders/prepareRequires trades:write.
json
{
"chainId": 8453,
"maker": "0xYourWallet",
"sellToken": "0xUSDT",
"sellAmount": "1000000",
"buyToken": "0xCNGN",
"buyAmount": "1550000000",
"deadline": 1900000000
}deadline is the order's expiry in unix seconds (capped server-side at ~1 year). For orders you expect a bot to fill, 10 minutes is the sweet spot and ~5 minutes the practical floor — see choosing a deadline.
Returns the typed data to sign, the digest (so you can verify locally before signing), and the order fields to echo back:
json
{
"data": {
"typedData": {
"domain": { "name": "Permit2", "chainId": 8453, "verifyingContract": "0x…" },
"types": { "PermitWitnessTransferFrom": [ … ] },
"primaryType": "PermitWitnessTransferFrom",
"message": { … }
},
"digest": "0x…",
"encodedOrder": "0x…",
"order": {
"chainId": 8453,
"reactor": "0x…",
"maker": "0x…",
"inputToken": "0xUSDT",
"inputAmount": "1000000",
"outputToken": "0xCNGN",
"outputAmount": "1550000000",
"nonce": "…",
"deadline": "1900000000"
}
}
}Sign typedData with eth_signTypedData_v4. Proceeds always pay to the maker.
Instead of naming buyAmount yourself, you can pass pricing and we compute it from the live book — the same math as the quote endpoint:
json
{
"chainId": 8453,
"maker": "0xYourWallet",
"sellToken": "0xCNGN",
"sellAmount": "1550000000",
"buyToken": "0xUSDT",
"pricing": { "mode": "fastFill", "marginBps": 10 },
"deadline": 1900000000
}The response then carries a pricing block with the assumedRateRay and reference depth. Verify the rate before signing — never sign a price you haven't checked. Exactly one of buyAmount or pricing is required.
Step 2 — submit
POST /v1/limit-ordersSend the order fields plus your signature:
json
{
"chainId": 8453,
"reactor": "0x…",
"maker": "0x…",
"inputToken": "0xUSDT",
"inputAmount": "1000000",
"outputToken": "0xCNGN",
"outputAmount": "1550000000",
"nonce": "…",
"deadline": "1900000000",
"signature": "0x…"
}We recover the signer, verify it against the reactor, check funding (min(balance, Permit2 allowance) must cover the order — see the one-time setup) and the Permit2 nonce, and — if it all holds — rest the order in the book:
json
{ "data": { "id": "…", "rateRay": "…", "status": "active" } }Re-submitting the same signed order is safe and idempotent: you get the same id back, even if the order already filled in the meantime (fast fills can settle within seconds) — the response's status tells you which. Requires trades:write.
List your orders
GET /v1/limit-orders?chainId=8453&wallet=0xYourWallet&limit=50Your resting and recently-terminal orders on a chain, newest first. Status is derived live — an order past its deadline reads EXPIRED, one whose nonce the chain shows spent reads CANCELLED. Requires trades:read.
Track one order
GET /v1/limit-orders/{id}The lifecycle of an order you submitted through the API, by the id the submit returned. Once the fill reconciles, txHash and filledAt point at the on-chain settlement:
json
{
"data": {
"id": "…",
"chainId": 8453,
"sellToken": "0xCNGN",
"sellAmount": "1550000000",
"buyToken": "0xUSDT",
"buyAmount": "1089913",
"rateRay": "…",
"executionRateRay": "…",
"nonce": "…",
"deadline": "2026-07-14T13:00:00.000Z",
"status": "FILLED",
"statusVerified": true,
"txHash": "0x…",
"filledAt": "2026-07-14T12:34:56.000Z",
"createdAt": "2026-07-14T12:30:00.000Z"
}
}Two rate fields, two orientations: rateRay is the book rate the order rests at (sellToken-per-buyToken, what the wallet list also reports), executionRateRay is the maker frame (buyToken-per-sellToken) — the same orientation /quote and prepare's assumedRateRay use, so you can reconcile without inverting.
statusVerified matters when you act on a derived status. FILLED from the database is a reconciled fact. CANCELLED is stored but provisional: the book retires an order the moment its Permit2 nonce shows spent on-chain, and a spent nonce can also be a fill still working through reconciliation — a later read can upgrade the same order to FILLED. So statusVerified: true on a CANCELLED means the stored state was read, not that it's final; never treat it as "unfilled" until the webhook resolves it (filled supersedes). An open order's status is computed at read time against the on-chain nonce — and if the chain can't be reached, that check fails open and a past-deadline order reads EXPIRED without proof that a last-second fill isn't still reconciling. statusVerified: false marks exactly that case: treat the status as unknown and poll again, never repost on it. The repost pattern spells out both rules.
Requires trades:read. Rather than polling, subscribe to the limit_order.filled / limit_order.expired / limit_order.cancelled webhooks.