SAHI DOCS

API reference

Market data

Last traded price, full quotes with depth, historical candles, and the live tick stream.

Last traded price#

GET /v1/marketdata/ltp

Scope marketdata.read

Single or bulk.

Request#

i integer Required
An instrument_id from the scrip master. Repeat the parameter once per instrument, up to 100 per request.

Response — 200 OK#

The data object is keyed by instrument_id (string form because JSON object keys are always strings; the value originated as an integer).

Each per-instrument value:

last_trade_price integer
Paise.
last_trade_time integer | null
Epoch milliseconds. null for non-tradable instruments (e.g. indices).

Instruments that don't exist are returned as "data": { "5073182": null }; the request as a whole still returns 200. Validation failures (non-integer i, > 100 entries) return 400 with VALIDATION_FAILED.


Full quote with market depth#

GET /v1/marketdata/quotes

Scope marketdata.read

Single or bulk. Returns the REST equivalent of the tick stream's depth_tick — price, volume, DPR band, open interest, and market depth.

exchange_segment / instrument_type (present on WS ticks) are not repeated here — both are static attributes of the instrument, available from the scrip master the instrument_id came from.

Request#

i integer Required
An instrument_id from the scrip master. Repeat the parameter once per instrument, up to 100 per request — the same shape as /v1/marketdata/ltp.

Response — 200 OK#

The data object is keyed by instrument_id (string form because JSON object keys are always strings; the value originated as an integer).

Each per-instrument value:

last_trade_price integer
Paise.
last_trade_time integer | null
Epoch milliseconds. null for non-tradable instruments (e.g. indices).
volume_today integer
Cumulative traded volume today. 0 for non-tradable instruments.
dpr_upper, dpr_lower integer
Daily price range (circuit limit) bounds, paise.
prev_open_interest, open_interest integer | null
Open interest. null for non-derivative instruments; always present on derivatives, where 0 is a real value meaning zero open interest — not "unknown".
best_buys, best_sells object[]
Order-book levels, best price first — only price levels holding orders are included, up to 5 or 20 per side depending on the instrument's feed type. Empty when no order book exists: before pre-market opens, immediately after the start-of-day reset, and on instruments with no resting orders. Levels are never zero-padded.
best_buys[].price integer
Bid/ask price at the level, paise.
best_buys[].qty integer
Total quantity resting at the level.
best_buys[].num_orders integer
Number of orders at the level.

Instruments that don't exist are returned as null (as 5073182 above); the request as a whole still returns 200. Validation failures (non-integer i, > 100 entries) return 400 with VALIDATION_FAILED.


Historical OHLC#

GET /v1/marketdata/candles

Scope marketdata.read

One instrument per request. Supported size values: 1m, 3m, 5m, 15m, 30m, 1h, 1d. Labels are case-sensitive — 1M is not one minute and is rejected.

Request#

instrument_id integer Required
Scrip-master ID.
size string Required
One of 1m, 3m, 5m, 15m, 30m, 1h, 1d.
from integer Required
Window start, epoch milliseconds, inclusive.
to integer Required
Window end, epoch milliseconds, inclusive. Must satisfy to >= from.
include_partial boolean Optional
Default false. When true and the market is open, the in-progress candle for the current window is appended as the last entry.
oi boolean Optional
Default false. When true, each candle carries open interest as a 7th array element (see below).

Response — 200 OK#

Each candle is a fixed-length array. By default it has 6 elements — [timestamp, open, high, low, close, volume]. When the request sets oi=true, a 7th element (open_interest) is appended to every candle. The width is determined solely by the request flag, so every candle in a given response has the same length.

Index Field Present Notes
0 timestamp always Candle open-time, integer epoch milliseconds, aligned to the window (e.g. for 1m: 09:15:00 IST, 09:16:00 IST, and so on).
1–4 open, high, low, close always Integer paise.
5 volume always Quantity traded in the window, in units. 0 for windows with no trades — in that case open, high, low and close all repeat the previous window's close.
6 open_interest only when oi=true Integer open interest; 0 for non-derivative instruments. May carry forward across zero-volume windows for derivatives.

With oi=true each candle is a 7-element array; the appended element is the integer open interest (0 for non-derivative instruments). For example, a futures instrument:

Candles are returned in ascending order of timestamp (index 0). The series covers every window in [from, to] — windows with no trades are included with a 0 volume (index 5) so you do not have to interpolate missing buckets.

A response holds at most 2500 candles per request. When the requested range covers more windows than that, the response carries the most recent 2500 and the older part of the range is omitted — the series is clipped at its start, not its end. has_more is true exactly when that happened.

To walk a longer history, page backward: set to to the first returned timestamp - 1 and re-fetch, repeating until has_more is false. Advancing from does not page — the returned series already ends at to.

An unknown instrument_id returns 200 with an empty candles array. Validation failures (unsupported size, inverted range, non-integer parameters) return 400 with VALIDATION_FAILED.


Option expiries#

GET /v1/marketdata/optionchain/expiries

Scope marketdata.read

The expiry dates an option chain can be requested for: the unexpired expiries the underlying has listed option contracts on within the scrip master's horizon — 93 days ahead on NSE, 63 on BSE, 123 on MCX and NCOM — nearest first. Longer-dated contracts the exchange lists are not in the scrip master and are not served here. An expiry stays listed through its own expiry day until the F&O session closes.

Request#

underlying_id integer Required
The underlying's instrument_id from the scrip master — the index or stock, not a derivative.

Response — 200 OK#

Expiries are calendar dates in IST, YYYY-MM-DD — the one place a date is not an epoch-millisecond timestamp, because an expiry is a trading day, not an instant. An underlying_id with no listed options returns 200 with an empty array. A missing or non-integer underlying_id returns 400 with VALIDATION_FAILED.


Option chain#

GET /v1/marketdata/optionchain

Scope marketdata.read

The full option chain for one underlying and expiry: every strike with its call and put leg — price, open interest, volume, top of book, implied volatility and greeks — in one request.

Quote fields are live feed snapshots. Implied volatility and greeks come from Sahi's market-analytics service — the same numbers the Sahi app's option chain shows — and are null for the chains it does not track.

Request#

underlying_id integer Required
The underlying's instrument_id from the scrip master.
expiry string Optional
YYYY-MM-DD, one of the expiries endpoint's values. Omitted, the nearest unexpired expiry is used.

Response — 200 OK#

Top level:

underlying_id integer
Echoes the request.
expiry string
The resolved expiry, YYYY-MM-DD (IST) — useful when the request omitted it.
last_price integer | null
The underlying's last-traded price, paise. null when no snapshot exists for it.
strikes object[]
One row per strike, ascending by strike price.

Each strike row:

strike integer
Strike price, paise.
ce, pe object | null
The call / put leg. null when no such contract is listed at this strike or the feed has no snapshot for it yet.

Each leg:

instrument_id integer
The option contract — tradable via the order APIs as-is.
last_trade_price integer
Paise.
last_trade_time integer | null
Epoch milliseconds. null before the contract's first trade.
average_price integer
Day's average traded price, paise.
volume_today integer
Cumulative traded volume today.
open_interest, prev_open_interest integer | null
Current / previous-day open interest.
prev_close_price integer
Previous session's close, paise.
top_bid_price, top_bid_qty integer | null
Best bid and the quantity resting at it, paise. null when the bid book is empty.
top_ask_price, top_ask_qty integer | null
Best ask and the quantity resting at it. null when the ask book is empty.
implied_volatility number | null
Percent (e.g. 12.34 = 12.34%). null when the analytics service does not track this chain, or has not solved this leg — a leg with no premium yet, for instance.
greeks object | null
Present when implied_volatility is.
greeks.delta number
Price change per 1.0 move of the underlying. Calls in [0, 1], puts in [-1, 0].
greeks.gamma number
Delta change per 1.0 move of the underlying.
greeks.theta number
Price change per day of time decay, in the premium's units (paise-denominated rupee terms match last_trade_price / 100).
greeks.vega number
Price change per 1% move in implied volatility.

There is no previous_volume field — the live feed does not carry the prior session's volume. Derive it from the candles endpoint (size=1d) if you need it.

An expiry that isn't a listed, unexpired expiry for the underlying — or an underlying_id with no listed options — returns 400 with VALIDATION_FAILED. Malformed parameters (non-integer underlying_id, an expiry not in YYYY-MM-DD form) return 400 with VALIDATION_FAILED. A chain the analytics service does not track still returns 200, with implied_volatility and greeks null on every leg; the feed or the analytics service being unreachable returns 500 with INTERNAL.


Tick stream#

WSS /v1/stream/marketdata

Scope marketdata.read

Three modes select the field subset sent on the wire.

Subscription state is connection-local and is not persisted across disconnects. After any reconnect (4401, 4408, or otherwise) the client must re-send its Subscribe messages — the server starts a new connection with an empty subscription set.

There is nothing to reconcile on this stream. Every tick is a complete snapshot for its instrument at the currently-effective mode, so the newest one you hold is the answer and a duplicate costs nothing — no REST snapshot, no per-key merge. After a reconnect the next tick per instrument brings the value back up to date; if you need one before that tick arrives, read it once from GET /v1/marketdata/ltp or GET /v1/marketdata/quotes.

Wire format#

Binary WebSocket frames. Each frame carries exactly one ClientMessage (client → server) or ServerMessage (server → client), serialised as protobuf wire format.

Schema#

Enums#

Proto enum Values (prefix stripped)
ExchangeSegment NSE, BSE, NFO, BFO
InstrumentType INDEX, STOCK, ETF, INDEX_FUT, INDEX_OPT_CE, INDEX_OPT_PE, STOCK_FUT, STOCK_OPT_CE, STOCK_OPT_PE
Mode LTP, QUOTE, DEPTH

Each value in the schema above is prefixed with its enum name (e.g. EXCHANGE_SEGMENT_NSE); the table strips the prefix for readability.

Client → server (ClientMessage oneof)#

Variant Fields Meaning
subscribe mode, instrument_ids Add instrument_ids at the given mode. Idempotent.
unsubscribe instrument_ids Remove instrument_ids from the connection.
mode_change mode, instrument_ids Change mode for already-subscribed instrument_ids.

Server → client (ServerMessage oneof)#

Variant Meaning
ack Server accepted a subscribe / unsubscribe / mode_change. Echoes the action and accepted instrument_ids.
ltp_tick / quote_tick / depth_tick A snapshot for one instrument at the currently-effective mode.
error Non-fatal error (e.g. unknown instrument_id, mode change on an unsubscribed id). Carries code and message; the connection stays open.

Every tick variant carries the same identity fields (instrument_id, exchange_segment, instrument_type, last_trade_price, last_trade_time). Higher modes add more.

Variant Mode Additional fields beyond the identity set
LtpTick LTP (none)
QuoteTick QUOTE volume_today, dpr_upper, dpr_lower, prev_open_interest, open_interest
DepthTick DEPTH QuoteTick fields plus repeated best_buys and best_sells. Each DepthLevel array carries only price levels holding orders, up to 5 or 20 depending on the instrument's feed type (L2 vs L3) — never zero-padded.

Field semantics#

Price fields (last_trade_price, dpr_upper, dpr_lower, DepthLevel.price) are integer paise. Quantities (volume_today, prev_open_interest, open_interest, DepthLevel.qty, DepthLevel.num_orders) are integer counts. last_trade_time is integer epoch milliseconds.

last_trade_time is absent (proto3 optional, no value) for non-tradable instruments (indices). prev_open_interest and open_interest are absent for non-derivative instruments. volume_today is 0 before the first trade of the day and after the start-of-day reset, then accumulates through the session.

dpr_upper and dpr_lower are the exchange-imposed Daily Price Range bounds (also called circuit limits) in paise — the upper and lower price band within which the exchange will accept orders for this instrument on the current trading day. Orders priced outside this band are rejected by the exchange.

Per-connection limits#

  • Maximum subscribed instrument_ids per connection: 1000 by default, configurable per account.
  • Maximum concurrent connections per token: 5. The next is refused with close code 4429; see Rate limits.
  • Mode changes are per-instrument_id; a connection can hold a mix of modes simultaneously.

Close codes#

Code Meaning
1000 Normal closure.
4401 Auth expired.
4403 Forbidden — token lacks marketdata.read. 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.
4429 Per-token concurrent-connection limit exceeded — 5 open connections.