INDstocks API (Skopaq reference)
Skopaq's notes on the INDstocks REST endpoints it calls: the request and response shapes it relies on, how it reads order statuses and fills, where the official documentation contradicts itself, and what still has to be checked against the live API.
The canonical documentation is https://api-docs.indstocks.com/ (a single-file export
is at https://api-docs.indstocks.com/llms-full.md, the OpenAPI spec at
https://api-docs.indstocks.com/openapi-spec.yaml). If this page disagrees with it, the
official docs win: fix the code and this page. This page was written from the
September 2026 export ("Docs last updated: unknown").
The client is skopaq/broker/client.py (INDstocksClient); status and row parsing live in
skopaq/broker/order_status.py; live order handling in skopaq/execution/live_orders.py.
Basics
- Base URL:
https://api.indstocks.com(SKOPAQ_INDSTOCKS_BASE_URL). - Auth header:
Authorization: <token>, with noBearerprefix. A token lasts 24 hours (skopaq token set <TOKEN>every trading day). - Instruments are addressed by scrip code,
{EXCH}_{SECURITY_ID}(for exampleNSE_2885for RELIANCE). Market data takesscrip-codes=NSE_2885, neversymbols=NSE:RELIANCE. Orders take the baresecurity_id(2885). - Static IP: placing, modifying and cancelling orders needs a whitelisted IPv4 (NSE circular NSE/INVG/67858). Read-only calls (quotes, historical data, order book, profile, funds) do not, so confirming a fill works from anywhere; placing does not.
- Timestamps: historical data takes epoch milliseconds and returns candle
tsin epoch seconds. Order rows carry ISO-8601 times with a+05:30offset.
Rate limits
| Category | Limit | Covers |
|---|---|---|
| Order APIs | 10/s; at most 25 modifications per order | place, modify, cancel |
| Non-Trading APIs | 15/s, 100,000/day | profile, funds, order history (order book, GET /order, trades) |
| Quote APIs | 5/s, 100,000/day | quotes, LTP |
| Data APIs | 5/s, 100,000/day | instruments, historical data, option chain |
| Token generation | 1/min |
Going over returns HTTP 429. Each client keeps its Non-Trading reads (order book,
GET /order, trades, trade book, positions, holdings, funds, profile) to 12 in any rolling
second, so a burst of live SELLs (each reads the book, positions, holdings and funds
before it is placed) is not refused. Its limiter for other calls still allows 100 a
second (the older "10 orders/s, 100 calls/s" figure), which is above the current quote
and data limits. The live order worker stays well inside them: by default it polls each
order once a second, and all the orders it is watching share one order-book read (0.5 s
cache).
Response envelope
- Success:
{"status": "success", "data": ...}. Historical data uses{"success": true, "data": ...}. - Errors normally come with HTTP 4xx/5xx and
{"status": "error", "message": ..., "error_type": ...}(the conventions page showserror_codeinstead). Other shapes occur:{"message": ..., "success": false},{"message": "Bad Request", "debug_info": ...}and{"error": "Rate limit exceeded", "success": false}. - A 2xx can carry a failure body, for example
{"status": "failure", "error": {"msg": ...}}. OnPOST /orderthat has been seen for an order that was placed.
Skopaq reads market data leniently (_request: unwrap data if present) and every order
and portfolio call strictly (_request_envelope):
- a 2xx body with
statuserror/failure,success: false, or a non-emptyerror,error_typeorerror_coderaisesBrokerError(kind="error_body"), so a failure is never read as "no orders" or "no positions"; data: nullis accepted (as empty) only understatus: successorsuccess: true;- a 2xx body that is not JSON, or a list endpoint answering something that is not a
list, raises
BrokerError(kind="bad_payload").
BrokerError.kind says how far a request got:
| Kind | Meaning | An order sent this way… |
|---|---|---|
not_sent |
never left the host (client not open, expired token, connect error or timeout) | was not placed |
http |
the broker answered HTTP ≥ 400 | 4xx: was rejected; 5xx: may exist |
transport |
read/write error or timeout after sending, connection dropped | may exist |
bad_payload |
2xx that is not JSON or not the expected shape | may exist |
error_body |
2xx whose body reports a failure | may exist |
place_order raises OrderPlacementUncertain whenever the order may exist (and when a 2xx
answer has no order id). The worker then looks for it in the order book by order ids that
were not there before the placement, and never sends it again blind.
Endpoints Skopaq calls
Market data
| Endpoint | Client method | Request | Response (data) |
|---|---|---|---|
GET /market/quotes/full |
get_quote, get_quotes |
scrip-codes=NSE_2885,NSE_11536 |
dict keyed by scrip code: live_price, day_open, day_high, day_low, prev_close, day_change, day_change_percentage, volume, best_bid_price, best_ask_price |
GET /market/quotes/ltp |
get_ltp |
scrip-codes=NSE_2885 |
{"NSE_2885": {"live_price": 1362}} |
GET /market/historical/{interval} |
get_historical |
scrip-codes, start_time and end_time in epoch ms; interval such as 1minute, 5minute, 1day |
{"NSE_2885": {"candles": [{"ts", "o", "h", "l", "c", "v"}]}}, ts in epoch seconds |
GET /market/instruments |
get_instruments |
source=equity |
CSV (not JSON): SECURITY_ID, TRADING_SYMBOL, CUSTOM_SYMBOL, EXCH, SEGMENT, INSTRUMENT_NAME, LOT_UNITS, EXPIRY_DATE, STRIKE_PRICE, OPTION_TYPE, TICK_SIZE, SYMBOL_NAME |
GET /option-chain |
get_option_chain |
symbol |
calls, puts |
skopaq/broker/scrip_resolver.py caches the instruments CSV for an hour:
resolve_scrip_code gives the scrip code, resolve_tick_size the TICK_SIZE.
Orders
| Endpoint | Client method | Request | Response (data) |
|---|---|---|---|
POST /order |
place_order |
JSON: txn_type (BUY/SELL), exchange, segment (EQUITY), product (CNC/INTRADAY/MARGIN), order_type (LIMIT/MARKET), validity (DAY), security_id, qty (int), limit_price (not for MARKET), is_amo, algo_id; remarks only with SKOPAQ_INDSTOCKS_ORDER_REMARKS_ENABLED=true |
{"order_id": "EQ-93586788", "order_status": "INITIATED"} |
POST /order/modify |
modify_order |
JSON: order_id, segment, qty, limit_price (all four mandatory) |
{"order_id", "order_status": "MODIFIED"} |
POST /order/cancel |
cancel_order |
JSON: order_id, segment |
{"order_id", "order_status": "CANCELLED"} |
GET /order |
get_order |
order_id, segment as a JSON body on the GET; Skopaq sends the same as query params too |
one order row |
GET /order-book |
get_order_book |
— | list of order rows; null (under status: success) when there are none |
GET /order/trades |
get_trades |
order_id, segment as a JSON body on the GET (and query params) |
fills: fill_id, exch_order_id, quantity, price, trade_date |
GET /trades/{order_id} |
get_trades (fallback) |
— | fills (older docs; different field names, quantity and price in both) |
GET /trade-book |
get_trade_book |
segment=EQUITY (required) |
today's fills, one row each: fill_id, exch_order_id, quantity, price, trade_date, trade_serial_no, scrip_code, remarks (if sent); no order id and no side: join on exch_order_id |
Notes:
POST /orderonly acknowledges the order. The status key isorder_status, notstatus. Validation and RMS rejections come back at once as HTTP 400 (below); whether the order fills is decided at the exchange afterwards.skopaq/execution/live_orders.pyconfirms every live fill by reading the order back.- Order ids:
EQ-…(equity),DRV-…(derivatives),GTT-…(smart orders). The idPOST /orderreturns is the order row'sidand whatGET /order, cancel and trades take. - A cancel races the order filling: always re-read the order afterwards; the final status
can be
SUCCESSorPARTIALLY FILLED - CANCELLED.Position could not be found.means the order does not exist or has already completed;The order is already pending…means try again shortly. Stop and GTT orders are cancelled on/smart/order/cancel, which Skopaq does not use. - Skopaq never modifies an order: a resting exit is cancelled and the rest re-placed.
- Skopaq also sends
trigger_priceonPOST /orderwhen the order has one. It is not a documented field there (stops exist only on/smart/order); whether it is ignored or refused is unverified.
Portfolio and account
| Endpoint | Client method | Request | Response (data) |
|---|---|---|---|
GET /portfolio/positions |
get_positions |
segment=equity and product=cnc / product=intraday, both required, lowercase |
flat list: position_id, security_id, symbol, segment, product, exchange, isin, net_qty, avg_price, buy_qty, buy_avg, sell_qty, sell_avg, realized_profit, day_buy_qty, day_buy_val, day_sell_qty, day_sell_val, cf_* (the day_* and cf_* values can be null) |
GET /portfolio/holdings |
get_holdings |
— | security_id, symbol, isin, total_qty (T1 + DP), used_qty, avg_price, t1_qty, t1_avg_price, dp_qty, dp_avg_price; no product, LTP or P&L |
GET /funds |
get_funds |
— | detailed_avl_balance.eq_cnc (CNC buying power), pledge_received, … |
GET /user/profile |
get_profile |
— | profile |
- Positions: seeing CNC and intraday rows takes two calls. Each row's
productechoes the query, so Skopaq sets it from the query rather than trusting the row. If both queries are refused (400/404/422) it retries without parameters; a failed read always raises, it never becomes "no positions". A{"net_positions": [...]}wrapper is accepted. Null numbers read as 0. - Holdings:
Holding.quantityistotal_qtyandHolding.average_priceisavg_price.used_qty("pledged, sold, or otherwise blocked") is kept asused_quantitybut not subtracted, because a sale today is taken to show already as a negative CNCnet_qtyin positions (still to be verified, below). - Read the order book before positions and holdings (
read_broker_snapshotinskopaq/broker/book_snapshot.py). An order that fills between the reads is then counted twice (still open in the book, already sold in positions), which understates what can be sold; the other order would count it zero times and allow a double sell.
Order rows (GET /order, GET /order-book)
| Field | Notes |
|---|---|
id |
the order id (not order_id) |
exch_order_id |
exchange id; "" until the order reaches the exchange |
txn_type |
BUY / SELL |
security_id |
the instrument on that exchange: the same stock has another security id on BSE, so compare it only within one exchange |
isin |
the instrument across exchanges ("" for derivatives); positions and holdings carry it too, and Skopaq matches the same shares on NSE and BSE by it |
name |
display name (for example NIFTY 3 JUL 25700 CE): not a trading symbol, and rows have no trading symbol |
exchange, segment, product, validity, mkt_type, off_mkt_flag |
as sent |
order_type |
a MARKET order keeps MARKET after INDstocks turns it into a LIMIT |
requested_qty, traded_qty |
integers; traded_qty is the quantity filled so far |
requested_price |
string; for a MARKET order the limit it was converted to |
traded_price |
string, "" until something fills; read as the average fill price (the docs do not say) |
sl_trigger_price, sl_limit_price, tgt_trigger_price, tgt_limit_price |
strings, "" on regular orders |
status |
see the table below |
extra_info |
rejection reason or exchange message; empty for pending or successful orders |
remarks |
only if sent at placement |
created_at, updated_at |
ISO-8601 with +05:30 |
There is no remaining-quantity or average-price field: remaining is
requested_qty − traded_qty. Parse rows with parse_order_row / parse_order_book from
skopaq.broker.order_status (they accept a few alternate key names, never use name as
the symbol, and drop only rows without an id); never compare raw status strings.
Order statuses
The 15 documented statuses, and how skopaq.broker.order_status.classify reads each when
the quantities say nothing more:
| Status | Skopaq state | Documented meaning |
|---|---|---|
QUEUED |
working |
queued for processing |
O-PENDING |
working |
after-market order pending (also returned for ordinary placements) |
SL-PENDING |
working |
stop-loss pending trigger |
PROCESSING |
working |
being processed |
INITIATED |
working |
initiated and sent to the exchange |
MODIFIED |
working |
successfully modified |
PENDING |
working |
pending execution at the exchange |
PARTIALLY FILLED |
working |
partly executed, rest still working |
SUCCESS |
filled |
fully executed |
PARTIALLY FILLED - CANCELLED |
partial |
partly executed, rest cancelled (with traded_qty 0 or missing: fill unknown, never "nothing") |
PARTIALLY FILLED - EXPIRED |
partial |
partly executed, rest expired (the same) |
CANCELLED |
cancelled |
cancelled by the user or the system |
EXPIRED |
cancelled |
expired without execution |
FAILED |
rejected |
failed (technical or other reasons) |
ABORTED |
rejected |
aborted (system or validation issues) |
- There is no REST status called REJECTED, COMPLETE, OPEN or TRIGGER PENDING: rejections
show as
FAILEDorABORTED. Those names,PARTIALLY_EXECUTED,PF-CANCELLED/PFC,PF-EXPIREDandRJare still accepted as aliases, and case, underscores and dash spacing are normalised. - Quantities overrule the status:
traded_qty ≥ requested_qty > 0is filled whatever the status says, and a no-fill final status withtraded_qty > 0is partial. - A status in none of these lists is
unrecognised: treated as still working until the timeout (then cancelled and re-read) and logged once per order.SKOPAQ_ORDER_EXTRA_TERMINAL_STATUSESdeclares extra statuses final without a deploy. - The filled quantity is unknown, not 0, when a partial or working status comes without
traded_qty. A protective exit then stops with a CRITICAL alert instead of re-selling.
The order-updates WebSocket (wss://ws-order-updates.indstocks.com/...) uses short codes
(R, P, S, F, C, RJ, PF, PFC) and a disputed payload shape. Skopaq does not
use it; it polls REST.
Fills
- Filled quantity: the larger of
traded_qtyand the sum of the order's trade quantities, capped atrequested_qty. - Average price, in this order: the volume-weighted average of the order's trades
(
Σ quantity × price / Σ quantity) when they add up to the filled quantity;traded_price; the trade book's fills joined onexch_order_id; the average of whatever trades were found; else Skopaq's reference price (fill_price_source="estimate", with a WARNING). - Skopaq books ₹20 brokerage per order that filled (INDstocks' flat fee).
MARKET orders and tick size
"API trading does not support pure MARKET orders": INDstocks converts a MARKET order to a LIMIT at the live price before it reaches the exchange, so it can rest unfilled in a falling market. RMS can also refuse it outright ("Market orders are blocked for this instrument."). Skopaq therefore cancels a resting protective exit and re-places the rest as a LIMIT a little below the LTP, rounded down to the tick:
- the tick is the instruments CSV
TICK_SIZEwhen it is plausible (above 0 and at most 0.2 % of the price; its unit is not documented); - otherwise a coarse tick valid in every NSE price band: 0.05 below ₹1,000, 1.00 below ₹20,000, 5.00 above.
A LIMIT refused for its price (tick, circuit, price band) is retried as MARKET in the same attempt; "Market orders are blocked" makes the next attempt a LIMIT.
Synchronous rejections
POST /order answers HTTP 400 for validation failures (DayValidityAllowed,
QtyWithinFreezeQty, PriceWithinRange, …) and RMS rejections, with a free-text message:
RMS: Margin exceeds …, Market orders are blocked for this instrument., tick size,
circuit. classify_rejection sorts them into rate_limited (429), price,
market_blocked, auth (401/403, token), not_sent and other. A protective exit backs
off on rate_limited and not_sent (1, 2, 4, 8 s, at most 15 s per exit) without using an
attempt, and stops with a CRITICAL alert on auth and other.
Where the official docs disagree
| Topic | The docs | What Skopaq does |
|---|---|---|
GET /order request |
a JSON body on a GET; no example with query params | sends both; falls back to the order-book row; sticks to whichever source answered |
| Per-order fills | Orders page and OpenAPI: GET /order/trades with a JSON body; API overview and older docs: GET /trades/{order_id} |
tries /order/trades, falls back on 404/405 for that call; a path becomes the preference only after it returned a fill |
| Positions | segment and product required and lowercase, flat array; older pages show a net_positions / day_positions wrapper and no parameters |
two queries (cnc, intraday); the call without parameters if both are refused; the wrapper accepted |
validity |
the enum is DAY, IOC, but the validations say "Order should be placed with DAY validity" |
always DAY; resting re-placements are cancelled by Skopaq instead |
O-PENDING |
described as an after-market order pending | also returned for ordinary placements: treated as working |
traded_price |
not defined | read as the average fill price; the trades' VWAP is preferred |
remarks |
listed under "[Unreleased]" in the changelog | sent only when SKOPAQ_INDSTOCKS_ORDER_REMARKS_ENABLED=true |
| Rate limits | the conventions table (above) vs "10 orders/s, 100 API calls/s" in the v1.0.0 changelog | follows the table for polling |
| Holdings | total_qty / avg_price now; an older page showed quantity / average_price |
accepts both |
To verify live
None of this has been checked against the live API yet
(tests/integration/test_indstocks.py covers the token and market data only). Check it by
hand with 1 share from the whitelisted host, or as new -m integration tests, and record
the answers here:
- A 1-share LIMIT BUY far below the market: the status while it rests, then cancel it and
read
CANCELLED; the real status strings and row keys (id,updated_at,product). - Which
GET /orderform works (JSON body or query), which trades path works, and the trades fields. /portfolio/positionswith and without parameters; the equity CNC row after a same-day BUY and SELL (sell_qty,day_sell_qty,net_qty).- Holdings vs positions for T1 shares (no double counting); whether selling earlier
holdings today shows a negative CNC
net_qty;total_qty/used_qtybefore and after. - How soon positions reflect a filled SELL (this sets
SKOPAQ_ORDER_SELL_FILL_LAG_WINDOW_SECONDS). - Whether
validity=IOConPOST /orderis accepted. - The unit of
TICK_SIZEin the instruments CSV (rupees or paise) for a ₹200, ₹2,000 and ₹6,000 stock. - A MARKET SELL's row: does
order_typestayMARKET, and isrequested_pricethe converted price? - The answer to cancelling a completed order (
Position could not be found.) and to a cancel while "already pending". - Whether
remarksis accepted and echoed in the order book.