The create_order payload, types and time-in-force values, reduceOnly semantics, minimums, identifiers (client_order_index / order_index / order_id), what placement returns (tx_hash, not status), measuring an IoC fill as a position delta, reflection lag after a write, cancellations, retries, error codes, and write budget. Facts were verified on the robinhoodchain instance; writes go through a sidecar built on the official Python SDK.
TL;DR
- Placement returns only
tx_hash. It is not execution status: there is nofilled,resting, ororder_index. Measure an IoC fill as the position delta before and after the write; a resting order appears inaccountActiveOrdersafter a delay. Verified 2026-08-20. - There are three identifiers.
client_order_indexis yours and set at placement (account-level uniqueness is your responsibility).order_indexis assigned by the exchange and used for cancellation.order_idis the same value as a string. Values around 1e16 exceed 2^53, soJSON.parsesilently roundsorder_index; an order’s identity is theorder_idstring, and that string is sent to the exchange for cancellation. Verified 2026-08-21: canceling by the rounded number leaves the order in place, and distinct identifiers collapse into one number. - Resting reduceOnly is capped by the live position and does not reverse it: the exchange cancels the remainder when the position reaches zero. Therefore, a reduceOnly order larger than the remaining position is either not placed or is truncated. Verified with a live order 2026-08-20.
- Minimums: $10 (
min_quote_amount) and the lot-basedmin_base_amount(code 21706). An entry or partial reduceOnly below either minimum is skipped; a full reduceOnly close uses ceil and is raised above both. Verified 2026-08-20. →markets-and-numbers.md§4 - The order list lags behind your own writes (zk-rollup): place → the next read does not show it yet → a bot without memory places a duplicate; cancel → the next read still shows it → the bot cancels it repeatedly. Keep confirmed-write memory for ~45 seconds: do not send a duplicate, and filter out a confirmed cancellation from the read. Observed 2026-08-21.
- Timeout = unknown outcome, not rejection. Do not retry placement; reconcile against the book first. An exchange rejection (
errin the SDK response) is a known outcome: nothing was placed. A refusal by the local write limiter is also known: no request was sent, so retrying on the next tick is safe. These three states must be distinct in the result type. - Cancellation is confirmed only by
ok:truefrom the signer. Underunknown, treat the order as live and do not place another over it. Canceling with the rounded identifier is a valid transaction for a nonexistent order: the exchange answers OK while the order remains live. Verified 2026-08-21. - The write window is 40/60 s per L1 address (code 23000): replacing one order costs 2 writes (cancel + place). Verified 2026-08-20. →
rate-limits.md - Dispatch order: IoC → GTC reduceOnly (protection) → ordinary GTC. If more entry orders are queued than fit in the window while protection waits behind them, protection starves first. Recommendation: sort before sending.
- Code 21734, “too far from the mark,” is a structural rejection: the order cannot pass until the market reaches it. Remember the rejected price for ~5 minutes rather than retrying every cycle. Verified 2026-08-22.
1. Placement payload
Through the SDK (SignerClient.create_order); from TS, through the sidecar (POST /order, → signing-and-sdk.md §3).
| Field | Type | Meaning |
|---|---|---|
market_index | int | market_id from orderBookDetails |
client_order_index | int | your identifier; must be unique within the account |
base_amount | int | size × 10^supported_size_decimals |
price | int | price × 10^supported_price_decimals |
is_ask | bool | true = sell |
order_type | const | only ORDER_TYPE_LIMIT was verified |
time_in_force | const | ORDER_TIME_IN_FORCE_GOOD_TILL_TIME or ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL |
reduce_only | bool | see §3 |
order_expiry | int | DEFAULT_28_DAY_ORDER_EXPIRY for GTT, DEFAULT_IOC_EXPIRY for IoC |
api_key_index | int | signing key |
A convenient client_order_index is a monotonic counter seeded from process start time modulo 2·10⁹: seq = (seq + 1) % 2_000_000_000, with the initial value floor(Date.now()/1000) % 1e9. The exchange does not guarantee deduplication by this field (it has not been confirmed), so use it for matching, not as duplicate protection.
2. Types and time in force
| Need | Implementation | Notes |
|---|---|---|
| Limit order in the book | ORDER_TYPE_LIMIT + GTT + DEFAULT_28_DAY_ORDER_EXPIRY | “GTC” on Lighter is GTT with a 28-day expiry; what happens on expiry has not been verified |
| “Market” / market entry | ORDER_TYPE_LIMIT + IoC crossing from the mark | native ORDER_TYPE_MARKET exists in the SDK but was not used |
| reduceOnly | reduce_only flag on either type | resting is capped by the position (§3) |
| Post-only, stops, TP/SL | available in the SDK according to documentation | not used, not verified |
- IoC cross from
mark_price: 0.5% did not reach the ask on a thin market (spread 1.18%); 1.5% is enough on the instance. The fill occurs at the best price; the cross only widens the worst case. Verified 2026-08-20. - IoC on an empty book was not tested separately; by observation, it does not fill and the position does not move, which is read as REJECTED (§7).
3. reduceOnly
3.1 Resting reduceOnly is capped by the position — verified experimentally
Experiment on 2026-08-20 on a market with no other orders or positions of ours:
- opened a minimum-size long (slightly above $10);
- placed a resting GTT reduceOnly SELL twice the position size at a price guaranteed to execute;
- the position went exactly to 0 and remained there for 8 measurements (20 seconds), did not reverse short, and the exchange canceled the remainder itself—no order remained in the market’s book.
The experiment cost a few dollars and matched Lighter documentation (“executing partially if the order size exceeds the position, with any remaining portion canceled once the position reaches zero”). The documentation warrants an experiment because the flag is load-bearing: decisions about canceling protective orders during a close depend on it, and the cost of an error is a reversed position.
Consequence: a full close does not require canceling every order for the asset. Canceling N orders would cost N writes from the 40/60 s window, and the position would remain open throughout.
3.2 Consequences
- A reduceOnly order larger than the remaining position will not be placed (or will be placed and truncated).
- Partial reduceOnly is not protected from oversizing: partial sizes use floor only; otherwise, skip.
- A full reduceOnly close is not constrained by the minimum: use ceil and raise above both minimums; the exchange caps it at the position.
- Classify whether your own order is protective or an entry by the
reduce_onlyflag from the book, not by side.
4. Minimums
min_quote_amount= $10 on RH—read it from metadata, not memory. Check it after quantizing size, using the order price.- How it was verified (2026-08-20): an order slightly above $10 was placed, found in the book, and canceled; an order that passes the dollar gate can still be rejected by the lot minimum (21706). No binary search below $10 was performed; no separate “IoC minimum” was observed—both types used the same $10.
- Lot-based
min_base_amountis the second minimum, code 21706. →markets-and-numbers.md§4 - Full reduceOnly close: ceil + bump to both minimums; entries and partial closes: never bump.
5. Identifiers: client_order_index, order_index, order_id
5.1 What appears where
| Identifier | Assigned by | Where it appears | Purpose |
|---|---|---|---|
client_order_index | you, at placement | on every order in accountActiveOrders | match “what I placed” ↔ “what is in the book” |
order_index | exchange | accountActiveOrders (number, rounded by JSON.parse) | cancel_order parameter |
order_id | exchange | accountActiveOrders (string, exact) | order identity; this exact value is sent for cancellation |
tx_hash | exchange | create_order response | not an order identifier; only evidence that the transaction was accepted |
5.2 Identifiers above 2^53 (verified 2026-08-21)
The exchange numbers orders with values around 1e16, outside the exact range of a double (2^53 ≈ 9.007e15). JSON.parse silently rounds them; the response contains both fields, and a mismatch between order_id and order_index is the lost bit. This breaks two things at once:
- Cancellation fails. The signer signs a cancellation for a nonexistent identifier. The transaction is valid, the exchange answers OK, the order remains in the book, and repeated cancellations waste write budget.
- Two orders are treated as one. Pairs of identifiers round to the same number; code matching by the number sees one order instead of two, so the second is never canceled. This is how duplicates survive.
The check String(Number(x)) === String(x) proves nothing: x was already rounded during JSON parsing. Test precision loss against the string twin in the response. Treat any new identifier field on any exchange as a string when its values are around 1e16.
5.3 Exact identity from the response
Store and match orders by the order_id string, not the number. Cancel only an order present in the latest complete book read: without an exact identifier, do not send a cancellation, because a guess signs a nonexistent identifier (§8).
/** Exact identity from the response: order_id string; fallback is the rounded order_index. */
export function exactOrderId(raw: { order_id?: unknown; order_index?: unknown }): string | null {
const s = raw.order_id;
if (typeof s === 'string' && /^[0-9]+$/.test(s)) return s;
if (typeof s === 'number' && Number.isSafeInteger(s) && s >= 0) return String(s);
const n = Number(raw.order_index);
return Number.isFinite(n) && n >= 0 ? String(n) : null;
}
5.4 Matching client_order_index ↔ order_index
The placement response contains no order_index; it appears in accountActiveOrders next to your client_order_index. Match by polling the book:
/** Wait for the placed order to appear in the book (zk-rollup reflects the write with a lag). */
async function resolveOrderByClientIndex(
readActiveOrders: () => Promise<Array<{ order_id: string; client_order_index: number; market_index: number }>>,
marketIndex: number, clientOrderIndex: number,
opts = { tries: 8, gapMs: 700 },
): Promise<{ orderId: string } | null> {
for (let i = 0; i < opts.tries; i++) {
const hit = (await readActiveOrders()).find(
(o) => o.market_index === marketIndex && Number(o.client_order_index) === clientOrderIndex,
);
if (hit) return { orderId: hit.order_id };
await new Promise((r) => setTimeout(r, opts.gapMs));
}
return null; // absent after ~5 s: no-resting-remainder IoC, rejection, or longer lag—reconcile on the next tick
}
Exchange-side deduplication by client_order_index has not been confirmed.
6. Reading your orders
GET /api/v1/accountActiveOrders?account_index=<N> with an auth token → { orders: [...] }. Without market_id, it returns all markets. → instances-and-api.md §2.4
| Field | Type | Notes |
|---|---|---|
order_id | string | exact identity |
order_index | number | rounded; compare against order_id |
client_order_index | number | your index |
market_index | number | map to a symbol through metadata |
is_ask | bool | true = sell |
price | string | on the instance grid |
remaining_base_amount | string | live remainder—compare the required size against this, not initial_base_amount |
initial_base_amount | string | original size |
reduce_only | bool | classification as “protective / entry” |
status | string | not needed for reconciliation |
Read rules:
- Skip
remaining_base_amount ≤ 0. - An order whose
market_indexis absent from metadata is a warning, not a silent skip. - A malformed entry (missing
order_id/order_index, or nonnumericpriceorremaining) is an error for the entire read, not “the remaining orders”: a truncated read is indistinguishable from an empty account. - Filter out confirmed cancellations (from write memory, §7.2).
- When a placed order appears in the book, remove it from “placed” memory (§7.2).
- Snapshot sandwich: positions → orders → positions; only if the position map is byte-for-byte identical before and after are order sizes calculated from positions trustworthy.
7. Placement response, IoC fill, and reflection lag
7.1 Only tx_hash
A successful create_order means the sequencer accepted the transaction. The response contains neither execution nor order_index. Consequences:
- resting order: mark it “confirmed placed” and wait for it to appear in the book (§7.2);
- IoC: measure fill only as a position delta (§7.3);
- “SUBMITTED” ≠ “FILLED”; write the order journal before sending and update it from the book/position.
7.2 Memory of your own writes (zk-rollup lag)
The order list catches up with a write after a delay of seconds. Both directions were observed live (2026-08-21): duplicate placements (more orders in the book than intended; extra protective orders are harmless, while extra entry orders acquire more than intended during a sharp move) and repeated cancellations (the same order is canceled again and again while reads still show it, wasting write budget).
The remedy is temporary memory with a ~45-second TTL (write echo):
placed: placed-order key (for example,asset|side|price) → confirmation time. If the bot decides to place the same order again, do not send it (SKIPPED, “already confirmed; read is lagging”). When the order appears in the book, forget it. When that order is canceled, forget it as well (otherwise, your own memory blocks a legitimate replacement).- Exclude size from the key: if size is recalculated between cycles (from equity and price), a size-bearing key changes every time, memory does not match, and several same-side orders accumulate at one price. Observed 2026-08-21.
cancelled: exactorder_id→ cancellation-confirmation time. Such an order is filtered out of book reads; do not send another cancellation.- Do not cache IoC: it leaves no trace in the book, and repeating it is a deliberate bot decision.
Why not inject a synthetic order into reads: order_index is unknown until the order appears in the book; an invented identifier cannot be canceled.
const placed = new Map<string, number>(); // order key → confirmation time
const cancelled = new Map<string, number>(); // order_id → cancellation confirmation time
const ECHO_TTL_MS = 45_000;
export const orderKey = (coin: string, isBuy: boolean, pxStr: string) => `${coin}|${isBuy ? 'B' : 'A'}|${pxStr}`;
const sweep = (m: Map<string, number>, now: number) => { for (const [k, at] of m) if (at <= now - ECHO_TTL_MS) m.delete(k); };
export function rememberPlaced(key: string, now = Date.now()) { sweep(placed, now); placed.set(key, now); }
export function wasPlaced(key: string, now = Date.now()) { sweep(placed, now); return placed.has(key); }
export function releaseKey(key: string) { placed.delete(key); }
export function rememberCancel(orderId: string, now = Date.now()) { sweep(cancelled, now); cancelled.set(orderId, now); }
export function wasCancelled(orderId: string, now = Date.now()) { sweep(cancelled, now); return cancelled.has(orderId); }
/** Wait for a write to appear in the book with a timeout: order appeared → true; absent → false (not “no order”). */
export async function waitForOrderKeyInBook(
readKeys: () => Promise<Set<string>>, key: string, timeoutMs = 5_000, gapMs = 700,
): Promise<boolean> {
const until = Date.now() + timeoutMs;
while (Date.now() < until) {
if ((await readKeys()).has(key)) { releaseKey(key); return true; }
await new Promise((r) => setTimeout(r, gapMs));
}
return false;
}
7.3 IoC fill = position delta
/** IoC on an exchange with no status in the response: fill = position delta. No observed delta → REJECTED. */
async function placeIocMeasured(
readPosition: (coin: string) => Promise<number | null>, // signed position size; null = untrusted read
send: () => Promise<{ ok: boolean; unknown?: boolean; rateLimited?: boolean; error?: string }>,
coin: string, isBuy: boolean, pxUsed: number,
): Promise<{ status: 'FILLED' | 'REJECTED' | 'SKIPPED'; fillSize?: number; fillAvgPx?: number; rawError?: string }> {
const before = await readPosition(coin);
if (before === null) return { status: 'REJECTED', rawError: 'position before IoC was unavailable; fill cannot be observed' };
const res = await send();
if (res.rateLimited) return { status: 'SKIPPED', rawError: res.error }; // not sent
if (res.unknown) return { status: 'REJECTED', rawError: `unknown-outcome: ${res.error}` }; // reconcile on the next tick
if (!res.ok) return { status: 'REJECTED', rawError: res.error };
// zk-rollup: time passes between tx_hash and the state update—poll for up to ~4 s.
let after: number | null = null;
for (let i = 0; i < 6; i++) {
await new Promise((r) => setTimeout(r, 700));
after = await readPosition(coin);
if (after !== null && after !== before) break;
}
if (after === null) return { status: 'REJECTED', rawError: 'position after IoC was unavailable' };
const moved = isBuy ? after - before : before - after;
if (moved <= 0) return { status: 'REJECTED', rawError: 'position did not change; IoC was not filled or sequenced yet' };
return { status: 'FILLED', fillSize: Math.abs(moved), fillAvgPx: pxUsed };
}
Why the asymmetry “not observed → REJECTED”: on the next tick, an unrecorded fill is recalculated from a fresh position read and will not execute twice; recording a nonexistent fill corrupts position accounting and leaves the intended reduction unfinished. The response contains no average fill price: fillAvgPx is the limit price, while the actual price is known only from trade history (which was not used).
7.4 Code 21734, “too far from the mark”
An order rejected for distance from the mark cannot pass until the market approaches it. Retrying every cycle produces rejection after rejection and wastes writes from the 40/60 s window, while the stream of identical log entries hides real failures. The remedy is temporary rejected-price memory (5 minutes; because the mark moves, a permanent ban would lose the price forever). The first rejection is visible as REJECTED; repeats during the window are SKIPPED without sending. Match code 21734 or the text too far from the mark. Verified 2026-08-22.
8. Cancellations
- Cancel by the exact
order_idstring. Without an exactorder_idfrom the latest read (§5.3), return “order was absent from the latest book read” and do not send a cancellation. A guess signs an unrelated or nonexistent identifier. - Do not repeat a confirmed cancellation: the exchange said it was canceled and the book is lagging. A repeat is not harmless—it consumes a write-window slot.
- Cancellation is always a critical write (it may use reserved capacity): an uncanceled order remains live and can execute against you, while an unplaced entry simply does not exist.
- Outcomes:
ok:true—canceled; rememberorder_idincancelledand release the key of the removed order fromplaced(pass the removed order’s key);rateLimited—not sent, the order is definitely still live → “not canceled,” hold any replacement;unknown—unconfirmed → treat the order as live, do not place another;ok:false—exchange rejection, log it. - Order within one cycle: all cancellations first, then placements.
9. Retries and unknown outcome
| Write outcome | What is known | Action |
|---|---|---|
rateLimited (local limiter) | exchange did not see the request | SKIPPED, retry next cycle; do not advance state |
SDK err / ok:false | exchange rejected it (codes below) | REJECTED; for 21734, remember the rejected price; for 23000, the limiter was wrong—tighten it |
unknown (25-second SDK timeout, 35 seconds to sidecar, loopback disconnect) | the order may have been sent | do not retry; reconcile against book/position; for cancellation, treat the order as live; for update_leverage, reread the account |
ok:true + tx_hash | transaction accepted | resting → placed memory; IoC → position delta |
Idempotent and safe to retry: cancellation (using exact order_id), update_leverage (but each attempt is a write and takes a window slot; after a market rejection, do not retry every cycle—use a per-market cooldown, → account-and-leverage.md). Not idempotent: placement (deduplication by client_order_index is unconfirmed), especially an entry or position increase and a partial reduceOnly.
The Python SDK turns the exchange’s HTTP 429 (code 23000) into an exception → the sidecar returns 502 with text; to the caller this is an “exchange rejection” with text Too Many Requests!: L1Address ratelimit reached … 40 requests per 60 second is allowed. Recognize it by code 23000, not HTTP status.
10. Observed error codes
| Code | Text (fragment) | Meaning | Response |
|---|---|---|---|
| 23000 | Too Many Requests!: L1Address ratelimit reached … 40 requests per 60 second is allowed | 40/60 s write window per L1 address | should not occur with a working limiter; if it does, the limiter misses some writes (leverage? cancellations?) |
| 21706 | (minimum size) | base_amount < min_base_amount | skip entries/partials; bump full close |
| 21734 | too far from the mark | limit order farther from the mark than the exchange accepts | remember rejected price for 5 minutes |
No other codes were observed; text arrives as a string in SDK err.
11. Write budget
- Replacing one order costs 2 writes (cancel + place). Ordinary writes can use the explicitly configured
limit − reserve; critical writes (cancellations, reduceOnly) can use the entire configuredlimit. The exchange ceiling is 40/60 s. →rate-limits.md - A full close without reduceOnly capping (cancel every order for the asset) would cost one write per order, with the position remaining open throughout—this is why §3 matters.
- A rejection by the local write window is temporary (retry next cycle), while a minimum rejection is persistent (until size changes). Distinguish them in the result type.
12. Dispatch order and protection
- Sort before sending:
Ioc→Gtc && reduceOnly→ otherGtc. Without this, protection waits behind entry orders; with a 40/60 s write window, the wait grows with order count, and once the budget is exhausted, protection starves first. The limiter’s reduceOnly reserve partly compensates, but dispatch order is more reliable. - The exchange allows several live orders at the same price: duplicate protection is client-side (
placedmemory, §7.2), and cancellation must release the key.
Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| Order does not cancel even though the exchange returned OK | cancellation used rounded order_index | identity is the order_id string; cancel with the string (verified 2026-08-21) |
| Duplicate orders and the second cannot be canceled | two identifiers collapsed into one double | store and match by the order_id string |
| The same order is canceled repeatedly | book lags and confirmed cancellation is forgotten | cancelled memory for ~45 seconds; filter it from reads |
| More orders are in the book than intended | book lags and placement was repeated | placed memory for ~45 seconds |
| Several orders at the same price | placed memory key included size | key is “asset + side + price,” without size |
| Legitimate replacement is blocked by local memory | cancellation did not release the key in placed | cancel receives the removed order’s key and calls releaseKey |
| Rejection after rejection at one price, wasted writes | 21734, limit order too far from the mark | rejection memory for 5 minutes |
| Double position after timeout | unknown was interpreted as rejection | do not retry; reconcile |
| Fill recorded when none occurred | “success” = tx_hash | position delta; no delta → REJECTED |
| Position underfilled and blamed on “minimums” | spread wider than IoC cross | measure spread; use a 1.5% cross |
| Protection was not placed while entry orders were | dispatch order; more entries than fit in the window | IoC → GTC RO → GTC; window reserve |
| reduceOnly IoC orders do not go out for minutes | RO IoC was measured against the ordinary allowance | RO IoC is a critical write (window reserve) |
Open questions / not verified
- Exchange-side deduplication by
client_order_index; error text for a repeated index. - GTT behavior after 28 days (canceled? extended?) and valid
order_expirybounds. - IoC on an empty book, partial IoC fill, and its exact price (not in the response; trade history is required).
ORDER_TYPE_MARKET, post-only, stops/TP-SL,modify_order, andcancel_all_ordersexist in the SDK but were not verified.- The 21734 threshold as a percentage from the mark; complete rejection-code list (only 23000, 21706, and 21734 were observed).
- Exact write-reflection lag in
accountActiveOrders(45-second echo memory provided ample coverage; it was not measured) and position-update lag after IoC (polling for up to ~4.2 seconds was enough). - Dispatch order IoC → GTC RO → GTC was not verified live.
- Whether reduceOnly IoC larger than the position is capped like resting reduceOnly (documentation says yes; not tested separately).
Facts verified through 2026-09-16. The Lighter API changes—recheck error codes, limits, and response shapes; before the first live order on a new instance, run the micro-cycle from the skill checklist.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license.