SAHI DOCS

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#

POST /v1/orders

Scope orders.write

To learn what an order would block before you send it, post the same body to Order margin.

Request#

exchange_segment string Required
NSE | NFO | BSE | BFO.
symbol string Required
The scrip master's symbol column (e.g. RELIANCE, NIFTY29DEC26C24500, SENSEX26NOVFUT). Look it up rather than building it: NFO and BFO contracts follow different exchange formats.
txn_type string Required
B | S.
qty integer Required
Total quantity in units. Must not exceed the instrument's freeze quantity — see Freeze quantity.
order_type string Required
LMT | MKT | SL-LMT | SL-MKT.
product string Required
C | I | M | F.
validity string Required
DAY | IOC.
price integer Required
Paise. Use 0 for MKT / SL-MKT. Must be a multiple of tick size for LMT / SL-LMT.
trigger_price integer conditional
Paise. Required for SL-LMT / SL-MKT.
disclosed_qty integer Optional
Disclosed quantity.
market_protection number Optional
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.
variant string Optional
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#

order_ids string[]
Always a list, and it may hold more than one entry — read it as a list rather than taking a single id out of it.

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).

  • MKT becomes LMT priced at LTP plus or minus protection% (buy above, sell below), clamped to the day's price band and snapped to tick size.
  • SL-MKT becomes SL-LMT priced at trigger_price plus 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: 0 is rejected; custom values must be between 0.01 and 100.
  • A MKT order for an instrument with no traded price (no LTP) is rejected with 422.

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-LMT only. An AMO with order_type MKT / SL-MKT is rejected with 400: 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 Open with placed_at: null in GET /v1/orders and on the order-updates stream until the exchange takes it at open; from then on it is an ordinary order and placed_at is set. Read an Open order with placed_at null as 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 to MKT / SL-MKT (400), and variant itself is not modifiable.
  • Everything else is as for a regular order: product, validity, disclosed_qty and the rate-limit budget are unchanged, and POST /v1/margins/order takes 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_id and 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 qty equal to it is accepted; only a larger one is refused.
  • Instruments with no cap are not restricted. The scrip master's freeze_quantity is a per-order regulatory ceiling and carries 0 where 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 leaves qty alone is never refused by this rule, even on an order that is somehow already above the cap.
  • Margin is not capped. POST /v1/margins/order and POST /v1/margins/basket will quote a quantity larger than the freeze quantity, so you can size a position before deciding how to slice it.

Modify order#

PUT /v1/orders/{order_id}

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.

qty integer Optional
New total quantity. Must not exceed the instrument's freeze quantity — see Freeze quantity.
price integer Optional
Paise.
order_type string Optional
LMT | MKT | SL-LMT | SL-MKT. An after-market order cannot be switched to MKT / SL-MKT (400).
validity string Optional
DAY | IOC.
trigger_price integer Optional
Paise.
disclosed_qty integer Optional
market_protection number Optional
Applies when the merged modify targets MKT / SL-MKT (which re-runs the one-shot conversion — see Market protection); ignored otherwise, and not a modification by itself.

Response — 200 OK#

order_id string
The modified order, echoed back.

INVALID_ORDER_STATE if the order is no longer modifiable. ORDER_NOT_FOUND if the order_id doesn't belong to the authenticated sub.


Cancel order#

DELETE /v1/orders/{order_id}

Scope orders.write

Cancellable states: Open, OpenPartial.

Response — 204 No Content#

INVALID_ORDER_STATE if the order is already terminal. ORDER_NOT_FOUND if the order doesn't belong to the caller.


Order book (today)#

GET /v1/orders

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:

order_id string
Sahi's id for the order — the key you modify, cancel and reconcile by.
exchange_order_id string
The exchange's id for the same order.
exchange_segment string
NSE | NFO | BSE | BFO.
symbol string
The scrip master's symbol column — the same value POST /v1/orders takes.
txn_type string
B | S.
order_type string
LMT | MKT | SL-LMT | SL-MKT.
product string
C | I | M | F.
validity string
DAY | IOC.
qty integer
Total quantity, units.
disclosed_qty integer
Disclosed quantity.
filled_qty integer
Units filled so far.
price integer
Limit price, paise.
trigger_price integer
Paise. 0 when the order carries no trigger.
avg_price integer
Average fill price so far, paise.
status string
rejection_reason string | null
Set when status is Rejected; null otherwise.
created_at through last_modified_at integer | null
Epoch ms. Each of the six timestamps is defined under Timestamps.

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.

created_at
Time Sahi accepted the order request and passed risk checks. Always set.
placed_at
Time the order was placed on the exchange.
completed_at
Set when the order transitions to Completed.
cancelled_at
Set when the order transitions to Cancelled.
rejected_at
Set when the order transitions to Rejected.
last_modified_at
Wall-clock of the most recent change to any field on the order. Use this for ordering when reconciling the REST snapshot against the order-updates stream.

Order details#

GET /v1/orders/{order_id}

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#

GET /v1/orders/{order_id}/trades

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:

exchange_trade_id string
The exchange's id for this fill; unique within the order.
order_id string
The order the fill belongs to — the id you asked for.
exchange_order_id string | null
The exchange's id for that order, as in the order book.
exchange_segment string
NSE | NFO | BSE | BFO.
symbol string
The scrip master's symbol column — the same value POST /v1/orders takes.
txn_type string
B | S.
product string
C | I | M | F.
qty integer
Units filled by this trade — not a running total.
price integer
Price this trade filled at, paise.
traded_at integer
Epoch ms. The exchange's match time for the fill.

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#

WSS /v1/stream/orders

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:

  1. Open the WebSocket and wait for the connection to be accepted.
  2. Then fetch GET /v1/orders.
  3. Keep the stream frames that arrive before the snapshot does. Do not discard them.
  4. Reconcile per order_id, keeping the higher last_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.