API reference
Orders
Place, modify and cancel orders, read the order book and the trades behind each order, and follow live order updates.
Place order#
Scope orders.write
To learn what an order would block before you send it, post the same body to Order margin.
Request#
NSE | NFO | BSE | BFO.symbol column (e.g. RELIANCE, NIFTY29DEC26C24500, SENSEX26NOVFUT). Look it up rather than building it: NFO and BFO contracts follow different exchange formats.B | S.LMT | MKT | SL-LMT | SL-MKT.C | I | M | F.DAY | IOC.0 for MKT / SL-MKT. Must be a multiple of tick size for LMT / SL-LMT.SL-LMT / SL-MKT.MKT / SL-MKT only: -1 applies auto protection, or a custom percentage (0.01–100, e.g. 2 = 2%). Omitted means auto protection. 0 is rejected. Ignored for LMT / SL-LMT. See Market protection.REGULAR (default) | AMO. AMO queues an after-market order for the next trading session — LMT / SL-LMT only. See After-market orders.Response — 201 Created#
The body is an acknowledgement; the full order state is observed via GET /v1/orders and live on the order-updates stream. Synchronous rejection returns a non-2xx error with code set per the error enum.
Market protection#
Every MKT / SL-MKT order is placed with market protection: it is converted, once, into a limit order before it reaches the exchange (a SEBI retail-algo requirement — no unprotected market order is accepted).
MKTbecomesLMTpriced at LTP plus or minus protection% (buy above, sell below), clamped to the day's price band and snapped to tick size.SL-MKTbecomesSL-LMTpriced attrigger_priceplus or minus protection% (no market-data dependency).market_protection: -1(or omitting the field) applies auto protection, tiered by reference price — equity & futures: below ₹100 → 2%, ₹100–500 → 1%, above ₹500 → 0.5%; options: below ₹10 → 5%, ₹10–100 → 3%, ₹100–500 → 2%, above ₹500 → 1%.market_protection: 0is rejected; custom values must be between0.01and100.- A
MKTorder for an instrument with no traded price (no LTP) is rejected with422.
The conversion is one-shot: the resulting order is a plain LMT / SL-LMT — GET /v1/orders shows the converted order type and the computed price, and later modifications follow plain limit-order semantics (modifying only trigger_price on a converted stop order keeps the existing limit price). To re-anchor protection at the current price, send order_type: "MKT" / "SL-MKT" (optionally with market_protection) in a modify.
After-market orders#
A REGULAR order is refused while the market is closed (422 VALIDATION_FAILED; the message carries the next opening time). To place an order for the next session, send it with variant: "AMO": Sahi accepts it, holds it, and releases it to the exchange when the next trading session opens. Use it while the market is closed.
LMT/SL-LMTonly. An AMO withorder_typeMKT/SL-MKTis rejected with400: the only price it could be protected at is the previous session's, not the one it will trade at. Price the order yourself.- Not for expired contracts. An AMO on a contract past its expiry is rejected with
422. - It shows as
Openwithplaced_at: nullinGET /v1/ordersand on the order-updates stream until the exchange takes it at open; from then on it is an ordinary order andplaced_atis set. Read anOpenorder withplaced_atnullas one that has not reached the exchange yet. - Modify and cancel work while it waits, through the usual endpoints. A cancelled AMO shows as
Cancelled. It cannot be modified toMKT/SL-MKT(400), andvariantitself is not modifiable. - Everything else is as for a regular order:
product,validity,disclosed_qtyand the rate-limit budget are unchanged, andPOST /v1/margins/ordertakes the same body.
Freeze quantity#
Exchanges cap how much quantity one order may carry — the contract's freeze quantity (the freeze_quantity column in the scrip master, in units). A qty above it is rejected with 400 VALIDATION_FAILED:
- One request is one order. Sahi does not slice an oversized order into freeze-sized child orders for you, because each slice would be its own order with its own
order_idand a single request cannot return a manageable order. Split the quantity yourself and send one request per slice. - The cap is the freeze quantity exactly. A
qtyequal to it is accepted; only a larger one is refused. - Instruments with no cap are not restricted. The scrip master's
freeze_quantityis a per-order regulatory ceiling and carries0where none applies;0(or an absent value) means the quantity is unrestricted here. - Modify is capped the same way, whenever the request carries a
qty. A modify that leavesqtyalone is never refused by this rule, even on an order that is somehow already above the cap. - Margin is not capped.
POST /v1/margins/orderandPOST /v1/margins/basketwill quote a quantity larger than the freeze quantity, so you can size a position before deciding how to slice it.
Modify order#
Scope orders.write
Only orders in modifiable states (Open, OpenPartial) can be modified — including an after-market order still waiting for the next session (Open, placed_at null).
Request#
Provide only the fields being changed; at least one is required.
LMT | MKT | SL-LMT | SL-MKT. An after-market order cannot be switched to MKT / SL-MKT (400).DAY | IOC.MKT / SL-MKT (which re-runs the one-shot conversion — see Market protection); ignored otherwise, and not a modification by itself.Response — 200 OK#
INVALID_ORDER_STATE if the order is no longer modifiable. ORDER_NOT_FOUND if the order_id doesn't belong to the authenticated sub.
Order book (today)#
Scope orders.read
Returns every order on your account for the current trading day, in any status. Single-shot; no pagination.
One exception, and it is a fault on our side rather than a state you can reach: an order whose symbol is
missing from the day's scrip master is left out of orders. The scrip master always carries the instrument
for an order accepted today, so a gap means our instrument table is stale, and we log it. Reconcile against
the order-updates stream, which reports the order either way.
Response — 200 OK#
orders holds one object per order. Its fields:
NSE | NFO | BSE | BFO.symbol column — the same value POST /v1/orders takes.B | S.LMT | MKT | SL-LMT | SL-MKT.C | I | M | F.DAY | IOC.0 when the order carries no trigger.status is Rejected; null otherwise.Order status#
| Value | Meaning |
|---|---|
Open |
Working, no fills yet: at the exchange (placed_at set), or an after-market order waiting for the next session (placed_at null). |
OpenPartial |
Partially filled, remainder open at exchange. |
PartialFilled |
Partially filled, remainder cancelled / IOC unfilled. |
Completed |
Fully filled. |
Cancelled |
Cancelled by you, EOD expiry, or system. |
Rejected |
Rejected by Sahi risk or the exchange (rejection_reason set). |
Timestamps#
All timestamps are integer epoch milliseconds. Each terminal-state timestamp is null until the order reaches that state.
Completed.Cancelled.Rejected.Order details#
Scope orders.read
Returns one order of yours by order_id, for the current trading day. Use it
to follow an order you have just placed or modified without pulling the whole
book and filtering it.
Response — 200 OK#
The body is the order object itself, not a wrapper: the same fields, with the
same meanings, as an element of the orders array in
GET /v1/orders — including
Order status and Timestamps, so whatever type
you already parse the order book's entries with parses this response too.
Polling this endpoint is not a substitute for
the order-updates stream. Reach for it
when you hold a handful of order_ids and want the current state of one of
them; anything you follow continuously belongs on the socket.
ORDER_NOT_FOUND if the order isn't on today's book, or doesn't belong to the
caller. VALIDATION_FAILED if order_id isn't a well-formed Sahi order id.
INTERNAL if the order's symbol is missing from the day's scrip master — the
same stale-instrument fault that drops a row from
the order book.
Trades for an order#
Scope orders.read
Returns the individual fills that make up one of your orders, for the current
trading day. A single order fills in one or more trades, each at its own price —
this is where you see them separately rather than collapsed into the order
book's filled_qty and avg_price.
Single-shot; no pagination. Fills are ordered oldest first.
Response — 200 OK#
trades holds one object per fill. Its fields:
NSE | NFO | BSE | BFO.symbol column — the same value POST /v1/orders takes.B | S.C | I | M | F.The fills above are the same order shown under
GET /v1/orders: 2 + 2 units make its
filled_qty of 4, and the quantity-weighted average of 249950 and 250000
is its avg_price of 249975.
Reconcile that way rather than assuming the two are written together. The order book's totals and this list reach Sahi as separate messages from the exchange, so a request made in the middle of a fill can briefly see one ahead of the other. They converge once the order stops filling.
An order that never reached the exchange — one still AwaitingTrigger, or
rejected by Sahi risk before placement — has no fills, and returns
{"trades": []} rather than an error.
ORDER_NOT_FOUND if the order isn't on today's book, or doesn't belong to the
caller.
Order updates#
Scope orders.read
Scoped to your account: each connection only receives updates for the JWT's sub. No subscribe message — the server starts pushing as soon as the connection is open.
Server → client messages#
Each frame is a JSON order object identical in shape to a single element of GET /v1/orders — the fields are tabulated there.
Stream + Snapshot#
Delivery favours freshness over exactly-once. An order update may arrive more than once — the stream can replay recent events after a restart on our side — and nothing is replayed for the time the socket was down. Bootstrap in this order rather than trusting the socket alone:
- Open the WebSocket and wait for the connection to be accepted.
- Then fetch
GET /v1/orders. - Keep the stream frames that arrive before the snapshot does. Do not discard them.
- Reconcile per
order_id, keeping the higherlast_modified_at.
Every reconnect repeats the sequence, 4408 and 4409 included. Duplicates are absorbed by step 4 rather than needing to be prevented.
Close codes#
| Code | Meaning |
|---|---|
1000 |
Normal closure (client-initiated). |
4401 |
Auth expired — token reached exp. Mint a fresh token and reconnect. |
4403 |
Forbidden — token lacks required scope. In practice an under-scoped (or otherwise invalid) token is rejected at the HTTP upgrade handshake with 403, before the WebSocket is established; this close code is reserved for scope failures detected after the upgrade. |
4408 |
Ping timeout — no pong received within 15s of the server ping. |
4409 |
Dropped — the client fell too far behind (slow consumer); reconnect and re-sync via GET /v1/orders. |
4429 |
Per-account concurrent-connection limit exceeded — 5 open connections. |