Skip to content

Orders

The order routes read today's orders and trades across every broker, place one order at a time, and change or cancel one order or a whole list of them. You name an instrument when you place an order, and the API decides which broker receives it. When you change or cancel an order, the API finds the broker that holds it by looking the order id up in every broker's order book.

These routes place, change and cancel real orders

POST /api/orders/place, PUT /api/orders/modify and DELETE /api/orders/cancel send real requests to real broker accounts, and nothing is retried or undone for you. Every one of them accepts "dry_run": true, which builds the exact broker request and returns it without sending it. Send a dry run first whenever you are unsure. A list sent to modify or cancel sends a request for every order in it that passes its checks, and its dry_run covers the whole list.

The table below lists the five routes on this page. The emergency route that cancels and closes everything has a page of its own, Flatten everything.

Method Endpoint Description
GET /api/orders/details Today's orders at every broker, from a document kept in Redis
GET /api/orders/trades Today's trades at every broker, from a document kept in Redis
POST /api/orders/place Places one order at a broker the API chooses, or each order of a list
GET /api/orders/intents/<intent_id> The order engine's answer for one order, after the place request stopped waiting
GET /api/orders/parents One of the order engine's parents, or every one still open
DELETE /api/orders/parents Cancels one of the engine's parents with its resting legs, or each of a list
PUT /api/orders/modify Changes one open order at the broker that holds it, or each order of a list
DELETE /api/orders/cancel Cancels one open order at the broker that holds it, or each order of a list

Glossary of constants

The order routes accept a small shared vocabulary. The words are upper-cased before they are checked, so buy and BUY are both accepted. The table below lists every enumerated value the routes take.

Parameter Values Meaning
transaction_type BUY, SELL The side of the order
product CNC Delivery: shares are bought into, or sold out of, the demat account
MIS Intraday: the position is closed the same day
NRML Normal: a derivatives position carried overnight
order_type MARKET Trade at the best available price; takes no price and no trigger_price
LIMIT Trade at price or better; needs price
SL A stop-loss limit order; needs both price and trigger_price
SL-M A stop-loss market order; needs trigger_price and takes no price
validity DAY The order lives until the end of the day (the default)
IOC Immediate or cancel: whatever does not fill at once is cancelled
exchange nse, bse, mcx, ncdex The exchange, when you name an instrument by its fields rather than by instrument_id
option_type CE, PE Call or put, for an option named by its fields
outcome (answer) accepted The broker took the request; answered with 200
rejected The broker refused it, or the connection could not even be opened; answered with 422
unknown The broker may or may not have acted; answered with 504
order status (book) PENDING, OPEN, COMPLETE, CANCELLED, REJECTED, EXPIRED The normalized status every broker's order is converted to

An order whose status is COMPLETE, CANCELLED, REJECTED or EXPIRED is finished. The modify and cancel routes refuse a finished order with 409 and never call the broker.

The segment names below are the ones POST /api/orders/place accepts in segment. Each one has a shape, which decides the identity fields you must give with it.

Shape Segments Identity fields
security equities, fixed_income, currencies, commodities, mutual_funds, exchange_traded_funds, investment_trusts symbol
future equity_futures, equity_index_futures, fixed_income_futures, fixed_income_index_futures, currency_futures, currency_index_futures, commodity_futures, commodity_index_futures underlying_symbol, expiry_date
option equity_options, equity_index_options, fixed_income_options, fixed_income_index_options, currency_options, currency_index_options, commodity_options, commodity_index_options underlying_symbol, expiry_date, strike_price, option_type

Index segments and the uncategorised segment are deliberately missing, because an index cannot be traded and uncategorised does not say whether an instrument is a cash instrument or a derivative.

Order book

GET /api/orders/detailsaccess-token

This route answers with today's orders at every broker. It never asks a broker. It reads the document that bin/unified/orders/api_order_details rewrites in the Redis key unified:orders:orders every half second, so the answer is at most about half a second behind the brokers' own books.

Request parameters

With only the token header, the route answers with the whole day. The query parameters below narrow it; several together must all match. A filtered answer keeps the day's summary and brokers and adds page.

Name In Type Required Description
access-token header string yes The token from POST /api/session/connect
order_id query string no Keep only these orders: give it more than once, or as a comma-separated list
parent_id query string no Keep only the orders of this order engine parent
intent_id query string no Keep only the orders placed for this intent, as a place answer names it
broker query string no Keep only this broker's
status query string no open for everything not yet COMPLETE, CANCELLED, REJECTED or EXPIRED, or one status such as COMPLETE
limit query number no The most entries to return, from 1 to 10000
cursor query number no How many matching entries to skip; pass the previous page's page.next_cursor
curl http://127.0.0.1:8080/api/orders/details \
  -H "access-token: $ACCESS_TOKEN"
import os

import requests

response = requests.get(
    'http://127.0.0.1:8080/api/orders/details',
    headers={'access-token': os.environ['ACCESS_TOKEN']},
    timeout=10,
)
print(response.status_code, response.json())

Response

The example below shows one order and is shortened to two brokers in brokers. The keys are exactly the ones the combining script writes; the values are made up.

{
  "orders": [
    {
      "broker": "zerodha",
      "order_id": "250915000000011",
      "exchange_order_id": "1100000012345678",
      "parent_order_id": null,
      "status": "OPEN",
      "status_message": null,
      "id": "NSE:RELIANCE",
      "instrument_token": "738561",
      "tradingsymbol": "RELIANCE",
      "exchange": "NSE",
      "transaction_type": "BUY",
      "product": "MIS",
      "order_type": "LIMIT",
      "validity": "DAY",
      "quantity": 10,
      "filled_quantity": 0,
      "pending_quantity": 10,
      "cancelled_quantity": 0,
      "disclosed_quantity": 0,
      "price": 2500.0,
      "trigger_price": 0.0,
      "average_price": 0.0,
      "order_timestamp": "2026-09-15T09:20:04+05:30",
      "exchange_timestamp": "2026-09-15T09:20:04+05:30",
      "tag": null,
      "instrument_id": "11111111-1111-5111-8111-000000000001",
      "engine_parent_id": null,
      "synthetic_type": null,
      "intent_id": null,
      "leg_role": null
    }
  ],
  "summary": {
    "count": 1,
    "by_status": {"OPEN": 1},
    "filled_value": 0.0
  },
  "brokers": [
    {"broker": "dhan", "status": "missing", "as_of": null},
    {"broker": "zerodha", "status": "ok", "as_of": "2026-09-15 09:20:05.120000"}
  ],
  "as_of": "2026-09-15T09:20:05"
}

Response attributes

The document has four top-level keys. Each order in orders follows the order contract described in Data contracts.

Attribute Type Description
orders array Every order at every broker, sorted by broker, then order time, then order id
orders[].broker string The broker holding the order
orders[].order_id string The broker's own order id, which is what modify and cancel take
orders[].status string One of the normalized statuses in the glossary
orders[].exchange string The broker's own exchange code, such as NSE, NSE_EQ or nse_cm
orders[].quantity, filled_quantity, pending_quantity, cancelled_quantity, disclosed_quantity number Quantities, as the broker reports them
orders[].price, trigger_price, average_price number Prices, as the broker reports them
orders[].order_timestamp, exchange_timestamp string ISO timestamps with the IST offset
orders[].instrument_id string or null The unified instrument id, or null when the order could not be resolved to one
orders[].engine_parent_id string or null The order engine parent that placed this order, or null for an order placed elsewhere
orders[].leg_role string or null The order's role in that parent: its part's path in the plan, such as root or root.each_fill.children.0, or entry for a simple order; parents placed before 2026-10-03 also show the roles of the retired classes, such as stop or slice
orders[].synthetic_type string or null The parent's order type: plan for every type but simple, since every other type runs as a plan; the type asked for is the parent's routed_from
orders[].intent_id string or null The intent the parent was placed for, as the place answer named it
summary.count number How many orders there are in the day, whatever the filters
summary.by_status object How many orders there are in each status
summary.filled_value number The sum of filled quantity times average price, rounded to two places
brokers[] array One entry per broker, with broker, status and as_of
brokers[].status string ok (seen within a minute), stale (older than a minute, still included), missing (no data today) or unreadable
as_of string When the document was written, in local time
page.matched number With filters: how many entries matched
page.returned number With filters: how many entries this answer carries
page.cursor, page.next_cursor number or null With filters: where this page started, and where the next one starts, which is null on the last page

Status codes

The status codes below come from the token check, from the filter reader in unified_broker_interface/utilities/order_book_filter.py, and from the shared document reader in unified_broker_interface/utilities/unified_documents.py.

Status When
200 The document is fresh and at least one broker's data was read.
400 A paging parameter cannot be read: limit must be a whole number, not '<text>', limit must be from 1 to 10000, not <n>, cursor must be a whole number, not '<text>' or cursor must be at least 0, not <n>.
401 Access token is required, Invalid access token or Access token has expired.
502 Unable to retrieve orders information: the document exists, but no broker is ok or stale. The body still lists brokers.
503 Orders are not available: nothing is keeping unified:orders:orders: the key is missing or unreadable, so the combining script is not running.
503 Orders are out of date: unified:orders:orders was last written at <as_of>: the document is more than 30 seconds old. The body carries as_of and brokers.
Under the hood
  • Redis keys read: unified:orders:orders, written by bin/unified/orders/api_order_details every 0.5 seconds.
  • Where that script reads from: each broker's <broker>:orders:orders hash, which the broker's api_order_details poller and websocket_order_details socket both write.
  • Freshness limit: 30 seconds, passed to read_document in unified_broker_interface/blueprints/orders.py.
  • Class: OrdersBlueprint.

Trade book

GET /api/orders/tradesaccess-token

This route answers with today's fills at every broker. Like the order book, it never asks a broker. It reads the document that bin/unified/orders/api_trade_details rewrites in unified:orders:trades every half second.

Request parameters

The trade book takes the same filters as the order book, each applied to the trade's own order.

Name In Type Required Description
access-token header string yes The token from POST /api/session/connect
order_id query string no Keep only the fills of these orders: give it more than once, or as a comma-separated list
parent_id query string no Keep only the fills of this order engine parent
intent_id query string no Keep only the fills of orders placed for this intent, as a place answer names it
broker query string no Keep only this broker's
limit query number no The most entries to return, from 1 to 10000
cursor query number no How many matching entries to skip; pass the previous page's page.next_cursor
curl http://127.0.0.1:8080/api/orders/trades \
  -H "access-token: $ACCESS_TOKEN"
import os

import requests

response = requests.get(
    'http://127.0.0.1:8080/api/orders/trades',
    headers={'access-token': os.environ['ACCESS_TOKEN']},
    timeout=10,
)
print(response.status_code, response.json())

Response

The example below shows one trade, with brokers shortened to one entry. The keys are the ones the combining script writes; the values are made up.

{
  "trades": [
    {
      "broker": "zerodha",
      "trade_id": "10000001",
      "order_id": "250915000000011",
      "exchange_order_id": "1100000012345678",
      "exchange_trade_id": "10000001",
      "instrument_id": "11111111-1111-5111-8111-000000000001",
      "instrument_token": "738561",
      "tradingsymbol": "RELIANCE",
      "exchange": "NSE",
      "transaction_type": "BUY",
      "product": "MIS",
      "quantity": 10,
      "price": 2500.0,
      "value": 25000.0,
      "trade_timestamp": "2026-09-15 09:20:06",
      "exchange_timestamp": "2026-09-15 09:20:06",
      "engine_parent_id": null,
      "synthetic_type": null,
      "intent_id": null,
      "leg_role": null
    }
  ],
  "summary": {
    "count": 1,
    "buy_value": 25000.0,
    "sell_value": 0.0,
    "total_value": 25000.0
  },
  "brokers": [
    {"broker": "zerodha", "status": "ok", "as_of": "2026-09-15 09:20:06.500000"}
  ],
  "as_of": "2026-09-15T09:20:07"
}

Response attributes

Each trade is one fill, in the same shape for every broker.

Attribute Type Description
trades array Every fill, sorted by broker, then trade time, then trade id
trades[].trade_id, order_id string The broker's own ids for the fill and its order
trades[].engine_parent_id, leg_role, synthetic_type, intent_id string or null The order engine parent the fill's order belongs to, as for an order; null for an order placed elsewhere
trades[].exchange_order_id, exchange_trade_id string or null The exchange's ids, when the broker reports them
trades[].transaction_type, product string On the shared vocabulary
trades[].quantity, price number The fill's size and price
trades[].value number Quantity times price, rounded to paise
trades[].trade_timestamp, exchange_timestamp string or null Passed through as each broker writes them, so they only sort within one broker
summary.count number How many fills there are
summary.buy_value, sell_value, total_value number The summed value of the buys, of the sells, and of both
brokers[] array One entry per broker, with broker, status (ok, stale, missing or unreadable) and as_of
as_of string When the document was written, in local time

Status codes

The trade book shares the order book's rules.

Status When
200 The document is fresh and at least one broker's data was read.
400 A paging parameter cannot be read, with the same messages as the order book.
401 Access token is required, Invalid access token or Access token has expired.
502 Unable to retrieve trades information: no broker is ok or stale.
503 Trades are not available: nothing is keeping unified:orders:trades.
503 Trades are out of date: unified:orders:trades was last written at <as_of>, when it is more than 30 seconds old.
Under the hood
  • Redis keys read: unified:orders:trades, written by bin/unified/orders/api_trade_details every 0.5 seconds from each broker's <broker>:orders:trades.
  • Freshness limit: 30 seconds.

Place an order

POST /api/orders/placeaccess-token

This route places one order at one broker. You name the instrument, the side, the product, the order type and the quantity in units, and the API offers the order to the brokers in the order its broker selector ranks them. The first broker that can take the order gets it. The route reads only Redis before it calls the broker, and it never retries a sent order at another broker.

One request, one real order

Without dry_run, a 200 means a real order now rests at a real broker. A 504 means the order may exist. Check the order book before you send the same order again.

Limit orders are held until they can fill

A plain LIMIT order is not sent to a broker straight away. The order engine holds it in its own virtual order book, as a virtual_limit order, and sends it only once the other side of the book reaches its price: for a buy, when the best offer is at or below it. A limit that never fills therefore costs no order messages at all, where a resting one costs a place and a cancel.

A held order answers 202 with outcome: armed and a parent_id, and no broker order id, because none exists yet. You can change its price or quantity while it is held, through PUT /api/orders/modify with parent_id, and cancel it through DELETE /api/orders/parents.

The table below shows which orders are held.

Order Held?
LIMIT with a price, DAY validity, no synthetic object Yes
LIMIT with after_market true, which the broker queues for the next session No, sent at once, because no live price arrives to release it
LIMIT with "synthetic": {"type": "simple"} No, sent at once
LIMIT with IOC validity, which means trade now or never No, sent at once
MARKET, which is sent at once as a marketable limit, as the next section describes No
SL, SL-M, or a LIMIT priced only by price_reference No, sent at once
A plan, or a ladder, scheduled, good_till_time, time_stop, account_conditional, limit_if_touched, indicator_triggered, cross_instrument, gtt, bracket, cover, scale_out, oto, oca, scale_with_profit_taker, freeze_slicer, twap or implementation_shortfall order run as a plan, unless it gives hold_limits: false Its limit orders, entry, every OCA candidate, each ladder rung or each timed slice is held until the market reaches it, while exits and profit-takers rest at the broker; Holding orders until the market reaches them lists the exceptions
Any order naming another synthetic type Run as that type

The setting UNIFIED_BROKER_INTERFACE_API_ORDER_HOLD_LIMITS, on by default, turns this off for every order, and for the rungs of a ladder run as a plan.

Market orders are sent as marketable limits

A plain MARKET order is not sent to a broker as a market order. The order engine runs it as a marketable_limit order instead: a LIMIT priced two ticks past the other side's best price, which for a buy is the best offer, so it trades at once against what rests there but can never fill far from the price you saw. The engine moves it after that price on every tick until it fills, and cancels whatever has not filled 30 seconds after it was placed.

The order is refused with 409, and nothing is sent to any broker, when it cannot be priced as it arrives. This happens when nobody is offering for a buy, nobody is bidding for a sell, no live quote for the instrument has arrived, or the quote is marked stale. The table below lists the refusal messages.

Book when the order arrives status_message
A buy, and nobody is offering nobody is offering this instrument, so the BUY could not be priced and nothing was sent
A sell, and nobody is bidding nobody is bidding for this instrument, so the SELL could not be priced and nothing was sent
No live quote yet no live quote has arrived for this instrument, so the <side> could not be priced and nothing was sent
The quote is marked stale the live quote for this instrument is marked stale, so the <side> could not be priced and nothing was sent

A sent order answers like any other, with 200 and the broker's order id, as soon as the broker accepts the limit. The 30 seconds run after that answer, so you see the end of the order in the order book or through GET /api/orders/parents: the parent ends completed when anything filled and cancelled when nothing did.

Three kinds of market order are still sent as market orders:

  • an order with after_market true, because the broker queues it for the next session, when there is no live book to price a limit from;
  • an order that names any synthetic type, simple included, which is how POST /api/orders/flatten keeps its closing orders as market orders;
  • every market order while UNIFIED_BROKER_INTERFACE_API_ORDER_MARKET_AS_LIMIT is off.

To choose a different buffer or time for one order, name the type yourself, as in "synthetic": {"type": "marketable_limit", "buffer_ticks": 0, "fill_within_seconds": 10}.

A limit on what crossing the spread may cost

A marketable limit can also be refused for what it would cost. With maximum_cost_bps set, on the order as "synthetic": {"type": "marketable_limit", "maximum_cost_bps": 15} or for every market order through UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_COST_BPS, the engine works out, just before sending, what filling the whole quantity against the visible book would cost in basis points of the mid-price, using the pre-trade estimate. The setting is unset by default, so no limit applies until you choose one. The table below lists the two refusals, both answered with 409 before anything is sent.

Why status_message
The estimate is above the maximum crossing the spread now would cost an estimated <cost> basis points, more than the maximum of <maximum>, so nothing was sent
The five visible levels do not hold the whole quantity the visible book holds only <visible> of the <quantity> units this <side> needs, so what crossing the spread would cost cannot be estimated and nothing was sent; a type that sends it in pieces, such as twap, participation or iceberg, can work it instead

The estimate walks the whole quantity through the book, while the order itself is a limit only buffer_ticks past the best price, which would rest whatever the first levels cannot fill. So the guard is cautious: it refuses some orders that would only have rested part of their quantity.

Reaching as deep as the order's size

Two ticks past the best price is enough for a small order, but an order bigger than what the first levels hold fills only part at once and then chases the price. With reaches_size: true, on the order or for every market order through UNIFIED_BROKER_INTERFACE_API_ORDER_MARKET_REACHES_SIZE, the limit goes no nearer than the deepest visible level the order's unfilled quantity reaches, so it takes those levels at once. It never goes past the fifth visible level, and on each later tick it is set again for whatever is still unfilled. The table below shows a book that offers 100 at each price from 1000.05 to 1000.25.

Buy Limit without reaches_size Limit with it
10 1000.15 1000.15
350 1000.15 1000.20
600 1000.15 1000.25, the last visible level

The setting is off by default, because it lets a market order trade further from the best price than two ticks; a maximum_cost_bps can be set beside it to bound that.

Each move of the resting limit is a modification, which counts against the broker's daily order cap like a placement. The repricing throttle allows each order one move a second by default (UNIFIED_BROKER_INTERFACE_API_ORDER_REPRICE_MINIMUM_SECONDS), so one order can send at most about 30 modifications before it is cancelled.

A dry run still shows the body as a market order in request, because a dry run builds the request from the body as you wrote it, and adds the plan the order would run as in plan.

Request parameters

The body is a JSON object. You name the instrument in one of two ways: by instrument_id, or by exchange, segment and that segment's identity fields. When instrument_id is given, the identity fields are ignored.

Name In Type Required Description
access-token header string yes The day's token.
instrument_id body string (UUID) one way The unified instrument id. It must parse as a UUID, and any spelling is normalized to lower case.
exchange body string other way nse, bse, mcx or ncdex, needed when instrument_id is absent.
segment body string other way A segment from the table above. It may carry the exchange prefix, as in nse_equities.
symbol body string security The trading symbol of a security, such as RELIANCE.
underlying_symbol body string future, option The underlying, such as NIFTY.
expiry_date body string future, option The expiry as YYYY-MM-DD.
strike_price body number or string option The strike, as a finite number.
option_type body string option CE or PE.
transaction_type body string yes BUY or SELL.
product body string yes CNC, MIS or NRML.
order_type body string yes MARKET, LIMIT, SL or SL-M.
quantity body integer yes The quantity in units, a whole number of at least 1. A numeric string such as "10" is accepted. It may be left out only when quantity_reference is given.
validity body string no DAY (default) or IOC.
price body number depends The limit price, a finite number of at least 0. LIMIT and SL need it; MARKET and SL-M refuse it.
trigger_price body number depends The trigger, a finite number of at least 0. SL and SL-M need it; MARKET and LIMIT refuse it.
disclosed_quantity body integer no A whole number of at least 0, and never more than quantity. Defaults to 0.
after_market body boolean no Places an after-market order. Defaults to false.
tag body string no Your own label, 1 to 20 letters and digits, with surrounding spaces removed.
dry_run body boolean no Builds and returns the broker request without sending it. Defaults to false.
price_reference body object no Order engine only. Works the price out from the live quote. See Price and quantity references.
quantity_reference body object no Order engine only. Works the quantity out from the position held.
synthetic body object no Order engine only. Runs the order as one of the synthetic order types.

The true-or-false fields after_market and dry_run accept a JSON boolean, or the text true, 1, yes, false, 0, no or an empty string, in any case. Anything else is refused with 400.

The order engine then runs these checks against the instrument and the chosen broker. Each failure is answered without calling a broker.

  1. The instrument must be mapped today, or the answer is 404 the instrument is not mapped. When the whole catalogue is missing rather than the one instrument, which happens every night between midnight, when the day's catalogue keys expire, and the morning mapping that publishes the next one, the answer is 503 today's instrument catalogue is not published yet: the catalogue for <date> has expired, and the daily mapping has not published a new one instead. That check runs only after an order has already been refused as not mapped, so an order that is placed pays nothing for it.
  2. The instrument's segment must be tradeable, or the answer is 400 orders are not sent for <segment> instruments.
  3. For a currency or commodity instrument, today's contract size decision must be trusted, and both quantities must be whole lots of it (see contract sizes).
  4. A broker must be able to take the order (see How the broker is chosen).
  5. For a securities instrument, quantity must be a whole number of the chosen broker's lot size.
  6. price and trigger_price must be whole numbers of the tick size most brokers agree on.
curl -X POST http://127.0.0.1:8080/api/orders/place \
  -H "access-token: $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "exchange": "bse",
        "segment": "equities",
        "symbol": "KWIL",
        "transaction_type": "SELL",
        "product": "CNC",
        "order_type": "LIMIT",
        "quantity": 5,
        "price": "101.25",
        "validity": "ioc",
        "tag": "abc123",
        "disclosed_quantity": 2,
        "dry_run": true
      }'
import os

import requests

order = {
    'exchange': 'bse',
    'segment': 'equities',
    'symbol': 'KWIL',
    'transaction_type': 'SELL',
    'product': 'CNC',
    'order_type': 'LIMIT',
    'quantity': 5,
    'price': '101.25',
    'validity': 'ioc',
    'tag': 'abc123',
    'disclosed_quantity': 2,
    'dry_run': True,
}
response = requests.post(
    'http://127.0.0.1:8080/api/orders/place',
    headers={'access-token': os.environ['ACCESS_TOKEN']},
    json=order,
    timeout=15,
)
print(response.status_code, response.json())

Response

The bodies below are real answers recorded by the offline suite python -m test_runs.order_routes, which runs the route against an in-memory Redis and stubbed brokers, so no real order was involved. The suite records only the names of the timing_ms keys, so the numbers shown here are illustrative.

{
  "broker": "zerodha",
  "instrument_id": "11111111-1111-5111-8111-000000000002",
  "intent_id": "00000000000040008000000000000001",
  "parent_id": "00000000-0000-4000-8000-000000000002",
  "tag": "abc123",
  "outcome": "accepted",
  "order_id": "250915000000011",
  "status_message": null,
  "broker_response": {
    "data": {"order_id": "250915000000011"},
    "status": "success"
  },
  "skipped": [],
  "timing_ms": {"preparation": 1.412, "broker": 86.207}
}
{
  "broker": "zerodha",
  "instrument_id": "11111111-1111-5111-8111-000000000002",
  "intent_id": "00000000000040008000000000000001",
  "tag": "abc123",
  "dry_run": true,
  "request": {
    "method": "POST",
    "url": "https://api.kite.trade/orders/regular",
    "form": {
      "disclosed_quantity": 2,
      "exchange": "BSE",
      "order_type": "LIMIT",
      "price": "101.25",
      "product": "CNC",
      "quantity": 5,
      "tag": "abc123",
      "tradingsymbol": "KWIL-zerodha",
      "transaction_type": "SELL",
      "trigger_price": "0",
      "validity": "IOC"
    }
  },
  "skipped": [],
  "timing_ms": {"preparation": 1.207}
}
{
  "broker": "zerodha",
  "instrument_id": "11111111-1111-5111-8111-000000000001",
  "intent_id": "00000000000040008000000000000001",
  "parent_id": "00000000-0000-4000-8000-000000000002",
  "tag": null,
  "outcome": "rejected",
  "order_id": null,
  "status_message": "bad request",
  "broker_response": {"message": "bad request"},
  "skipped": [],
  "timing_ms": {"preparation": 1.1, "broker": 40.3}
}
{
  "error": "no broker can take this order",
  "instrument_id": "11111111-1111-5111-8111-000000000001",
  "intent_id": "00000000000040008000000000000001",
  "skipped": [
    {"broker": "dhan", "reason": "has no login in Redis"},
    {"broker": "flattrade", "reason": "has no login in Redis"},
    {"broker": "fyers", "reason": "has no login in Redis"}
  ]
}

The last example is shortened: the recording lists all ten brokers in skipped, each with the same reason.

Response attributes

A sent order and a dry run share most keys. Every answer also carries intent_id, and an order the engine recorded carries parent_id; see Order engine.

Attribute Type Description
broker string The broker the order went to, or would have gone to.
instrument_id string The resolved instrument id.
intent_id string The id the order was handed to the order engine under; read its answer later by it.
parent_id string The order engine parent recorded for the order. Absent on a dry run and on a refusal.
tag string or null The tag the broker request carried.
outcome string accepted, rejected or unknown. Absent on a dry run.
order_id string or null The broker's order id, for an accepted order. Keep it: modify and cancel take it.
status_message string or null Why the outcome is not accepted, cut to 300 characters.
broker_response object, string or null The broker's own body, decoded as JSON when possible and otherwise its first 300 characters.
dry_run boolean true, on a dry run only.
request object On a dry run only: the method, URL and the form, JSON or query the broker would have received.
skipped array Each broker passed over before the chosen one, with broker and reason.
timing_ms.preparation number Milliseconds the API spent before the request left the machine.
timing_ms.broker number Milliseconds the broker call took. Absent on a dry run.

Status codes

Every failure below is answered without calling a broker, except the three outcomes of a sent order. The messages are the exact strings in the code; <...> marks a value filled in.

Status When
200 The broker accepted the order, or this was a dry run.
400 The body failed validation. The messages include the request body must be a JSON object, transaction_type must be one of BUY, SELL, product must be one of CNC, MIS, NRML, order_type must be one of MARKET, LIMIT, SL, SL-M, validity must be one of DAY, IOC, quantity is required, quantity must be a whole number of at least 1, disclosed_quantity must be a whole number of at least 0, disclosed_quantity cannot be more than quantity, price must be a number of at least 0, trigger_price must be a number of at least 0, a <type> order needs a price, a <type> order takes no price, a <type> order needs a trigger_price, a <type> order takes no trigger_price, after_market must be true or false, dry_run must be true or false and tag must be 1 to 20 letters and digits.
400 The instrument was named badly: instrument_id must be a UUID, give instrument_id, or an exchange of nse, bse, mcx or ncdex with a segment and its identity fields, orders are not sent for the segment '<segment>', a security segment needs symbol, a future segment needs underlying_symbol and expiry_date (or an option segment ...), expiry_date must be a date in YYYY-MM-DD format, an option segment needs a numeric strike_price, option_type must be CE or PE, or the identity fields match more than one instrument, so give instrument_id.
400 A reference was malformed: price_reference must be a JSON object, price_reference kind must be one of ..., an absolute price_reference needs a price above zero, level must be a whole number from 1 to 5, which is as deep as the unified quote carries, and the same shapes for quantity_reference.
400 The order does not fit the instrument: orders are not sent for <segment> instruments, quantity must be a whole number of lots of <lot>, disclosed_quantity must be a whole number of lots of <lot>, price must be a whole number of ticks of <tick> or trigger_price must be a whole number of ticks of <tick>.
401 Access token is required, Invalid access token or Access token has expired.
404 the instrument is not mapped: no instrument matches the id or the identity fields today.
422 outcome is rejected: the broker refused the order, or the connection to it could not be opened, so nothing was sent.
503 Redis could not be read: <error>, the order engine is not running, so the order was not placed; start unified-orders@order_engine.service, the order could not be written for the order engine: <error>, no instruments have been mapped yet, today's instrument catalogue is not published yet: the catalogue for <date> has expired, and the daily mapping has not published a new one, every broker is excluded from order placement, no broker can take this order (with skipped), or the contract size of this <segment> instrument is not trusted today (<status>), so no order is sent (with contract_size_status).
504 outcome is unknown: the broker answered with a server error, answered success without an order id, or the network failed after the request left. The order may exist.

The engine adds 202, 403, 409, 429 and more 503 and 504 cases; they are listed on the Order engine page.

What happens, step by step

The sequence below shows a placement by instrument_id with the default lowest-cost selector.

sequenceDiagram
    autonumber
    participant C as Your program
    participant A as API worker
    participant R as Redis
    participant E as Order engine
    participant B as Chosen broker
    C->>A: POST /api/orders/place
    A->>A: header present?
    A->>R: pipeline: token, mapping date, warm id
    R-->>A: replies
    A->>A: check token, validate body
    A->>R: EXISTS engine lock, XADD intent
    A->>R: BLPOP the intent's reply list
    E->>R: read the intent, choose the broker<br/>(one EVAL of every broker's counts, skip unfit brokers)
    E->>E: tradeable? contract size?<br/>lot size and tick size checks
    alt dry_run
        E->>R: RPUSH 200 with the request it would send
    else send
        E->>B: one HTTP request
        B-->>E: answer
        E->>R: RPUSH 200 accepted / 422 rejected / 504 unknown
    end
    R-->>A: the answer
    A-->>C: the answer, plus intent_id

An instrument named by its identity fields costs the API one more round trip, a ZRANGEBYLEX on the segment's catalogue, unless the worker has already looked that name up under the current warm.

Under the hood
  • Redis keys the route reads: last_login (the API's token), unified:catalogue:current_date, unified:catalogue:warm_identifier, :catalogue:<segment> (only for a lookup by fields), unified:orders:engine:lock, and the intent's reply list.
  • Redis keys the route writes: unified:orders:intents:stream.
  • What the engine reads and writes: every broker's login and settings, the instrument's catalogue data, the rate budget's windows, and unified:orders:daily_count:<broker>, which the lowest-cost selector reads for every broker and the daily count writes for a capped one; see Order engine.
  • Stores never read: MongoDB and PostgreSQL. The broker cost table comes from PostgreSQL, but only when a process starts and at 06:00 IST, never while an order waits.
  • Classes: PlaceOrderRequest validates the body, IntentHandoff writes the intent and waits, OrderPlacement chooses the broker and builds the request inside the engine, and each broker's BrokerOrders subclass builds its own request and reads its own answer.
  • Answer rules: BrokerAnswer maps accepted, rejected and unknown to 200, 422 and 504.

Read an answer later

GET /api/orders/intents/<intent_id>access-token

This route returns what the order engine did with one order, for a caller whose place request stopped waiting before the answer came. Every place answer, single or listed, carries the order's intent_id. The engine keeps each answer for UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RESULT_TTL_SECONDS, 300 by default, after it gives it.

Request parameters

Name In Type Required Description
access-token header string Yes The token from connect
intent_id path string Yes The intent_id from the place answer

Response

{
  "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70",
  "status": 200,
  "response": {"broker": "flattrade", "outcome": "accepted", "order_id": "26091500000021", "parent_id": "…", "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70", "…": "…"}
}
Attribute Type Description
intent_id string The id asked for
status integer The HTTP status the place route would have answered this order with
response object The body the place route would have answered this order with

Errors

Status Meaning
401 The token is missing, wrong or expired.
404 no answer is stored for this intent: the order engine has not answered it yet, the id is not one, or its answer has expired
503 Redis could not be read: <error>
Under the hood
  • Redis key read: unified:orders:intents:answer:<intent_id>, which the engine writes, only if absent, as it answers each intent.
  • Classes: IntentHandoff.stored_answer.

Modify an order

PUT /api/orders/modifyaccess-token

This route changes one open order at the broker that holds it. You name the order by the broker's own order id and give at least one field to change. Every field you leave out keeps the stored order's value. The route finds the broker by looking the id up in every broker's order book in Redis, checks the change against the instrument's lot size and tick size, and sends one request to that broker.

A modification changes a live order

A changed price or quantity takes effect at the exchange as soon as the broker accepts it. Send it with dry_run first to see the exact request.

An order the engine placed

When the order is a leg of one of the order engine's parents, which the route finds in unified:orders:children, the route still checks the change as above, and then hands it to the worker that owns the parent instead of sending it itself. The order type records the change and carries on from the new price or quantity: a trailing stop ratchets from the trigger you set, a chaser steps on from the price you set, and a linked pair of exits stays sized to the open position. A plan carries on in the same way, through the part that placed the order, and refuses to raise an order that closes a position with 409 an order that closes a position can only be reduced, and <quantity> is more than its <current>. The change cannot race the type's own repricing, because only that worker touches the parent. The answer carries parent_id and synthetic_type, and the change passes the engine's re-pricing throttle, daily cap and rate budget.

Only price, trigger_price and quantity can be changed on such an order. Changing order_type, validity or disclosed_quantity is refused with 409 this order belongs to an order the engine manages, which can only have its price, trigger_price or quantity changed, not <field>, because the type would then be managing an order that is not the one it placed. A dry run is answered by the route as for any other order.

A held order

An order the engine is still holding, such as a held limit order, has no broker order id yet, so you name it by the parent_id the place route answered with. Only its price and quantity can change, and nothing is sent to a broker: the engine's worker that owns the order changes the terms it is held by, and it is sent at the new price and quantity when the other side reaches it. The queue estimate starts again, as a changed price at the exchange goes to the back of the queue. dry_run checks the change without making it.

{"parent_id": "00000000-0000-4000-8000-000000000002", "price": 999.5, "quantity": 20}

The answer is 200 with parent_id, synthetic_type, held: true, the new price and quantity, and outcome: accepted. A change is refused with 400 when it names any other field or neither, when the price is not a whole number of ticks, or when the quantity is not a whole number of lots at any broker; with 404 when the engine holds no such parent; and with 409 when the order has already been sent (the answer then names its broker and order_id, which the ordinary form takes), has finished, is a type that holds no order of its own, or is a paper order that has already filled the new quantity.

A part of a plan that has not been sent

A plan is a tree of parts, and a part sends nothing until its turn comes: a bracket's stop and target wait for the entry to fill, and an order on a trigger waits for the trigger. Such a part has no broker order id yet, so you name it by the plan's parent_id and the part's path in part, as GET /api/orders/parents shows it under parameters.parts, such as root.each_fill.children.0 for a bracket's stop. Its price, trigger_price and quantity can change, and nothing is sent to a broker: the part keeps the new values and sends them when its turn comes. dry_run checks the change without making it, and the list form takes the same items.

{"parent_id": "00000000-0000-4000-8000-000000000002", "part": "root.each_fill.children.0", "trigger_price": 985, "price": 983}

These rules decide what a part can take.

  • A price or trigger_price replaces what the part's pricing gives when it is sent, so only a part with a fixed price can take one: a plain limit or a native_stop. A part priced from the market when it is sent, such as marketable, peg or trail, is refused with 400. A trigger_price needs a stop. A MARKET order given a price is sent as a LIMIT, and an SL-M as an SL.
  • A quantity becomes the part's total. An order that closes a position can only be reduced. A part sized by what an earlier part fills, such as a bracket's exits, has no quantity until those fills arrive, so it is refused with 409; change its broker order once it is sent instead.
  • A part held on a limit_marketable trigger is changed as a held order is.
  • A part kept whole, such as a grid, keeps its orders by its own rules and is refused with 409.

The answer below is a real recording from the offline suite.

{
  "parent_id": "00000000-0000-4000-8000-000000000002",
  "synthetic_type": "plan",
  "part": "root.each_fill.children.0",
  "state": "pending",
  "price": "983",
  "trigger_price": "985",
  "quantity": 10,
  "outcome": "accepted",
  "status_message": "changed before it was sent; nothing was sent to a broker",
  "intent_id": "00000000000040008000000000000006"
}
Status When
200 The part was changed, or this was a dry run.
400 part must name a part of the plan, such as root.first, a plan part that has not been sent can only change its price, trigger_price and quantity, not <field>, give price, trigger_price or quantity to change, part <path> works out its price from the market when it is sent, so its price cannot be set beforehand, part <path> is not a stop, so it has no trigger price to change, or a price or quantity off the tick or lot.
404 the order engine holds no parent with this id, or the plan has no order at part <path>.
409 part <path> is already <state>, so change its broker orders with broker and order_id instead, with orders listing each broker and order_id; part <path> is sized by what an earlier part fills, so its quantity is not known until then; an order that closes a position can only be reduced, ...; a part kept whole; a plan that has finished; or a <type> order has no parts, ... for any other type.
503 The order engine is not running.

Request parameters

order_id, broker and dry_run may come from the body or the query string; the body wins when both are given. The fields to change come from the body only, and a field that is absent or empty is not changed.

Name In Type Required Description
access-token header string yes The day's token.
order_id body or query string yes The broker's order id, as place and the order book answer it: 1 to 64 letters, digits, hyphens or underscores. A JSON integer is accepted.
broker body or query string no One of the ten broker names. Needed only when two brokers hold an order with the same id.
dry_run body or query boolean no Returns the request without sending it.
quantity body integer at least one change The new total quantity in units, a whole number of at least 1.
disclosed_quantity body integer at least one change The new disclosed quantity in units, at least 0 and not more than the quantity.
price body number at least one change The new limit price, at least 0.
trigger_price body number at least one change The new trigger price, at least 0.
order_type body string at least one change MARKET, LIMIT, SL or SL-M.
validity body string at least one change DAY or IOC.

Prices are merged with the stored order by these rules, which follow from the order type after the change.

  • A price you give is used as it is, but it must fit the new order type: a LIMIT needs a price and refuses a trigger price, and so on, exactly as place checks.
  • A price you leave out is carried over from the stored order only when the new order type needs it and the old one needed it too. So changing SL to LIMIT drops the trigger price, and changing LIMIT to SL without a trigger_price is refused with a SL order needs a trigger_price.
  • A carried-over price that Redis does not hold as a positive number is refused with 503 Redis does not hold this <type> order's <field>, so give <field>.

Not every broker can change every field. The matrix below comes from each broker's MODIFIABLE_FIELDS in unified_broker_interface/utilities/broker_orders/. Asking to change a field the broker cannot change is refused with 400 <broker> cannot change <field> on an order.

Broker quantity disclosed_quantity price trigger_price order_type validity Order types it can change to
Dhan any
Flattrade LIMIT, SL
Fyers any
Groww any
INDmoney none (no SL or SL-M at all)
Kotak any
Shoonya LIMIT, SL
Stoxkart any
Wisdom Capital any
Zerodha any

Flattrade and Shoonya share the Noren platform, whose modify request can only carry LIMIT or SL, so changing an order to MARKET or SL-M there is refused with <broker> cannot change an order to <type>. INDmoney takes no stop-loss orders, so asking it for SL or SL-M is refused with indmoney takes no <type> orders.

curl -X PUT http://127.0.0.1:8080/api/orders/modify \
  -H "access-token: $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"order_id": "250915000000011", "quantity": 20, "price": "2501.5", "dry_run": true}'
import os

import requests

change = {
    'order_id': '250915000000011',
    'quantity': 20,
    'price': '2501.5',
    'dry_run': True,
}
response = requests.put(
    'http://127.0.0.1:8080/api/orders/modify',
    headers={'access-token': os.environ['ACCESS_TOKEN']},
    json=change,
    timeout=15,
)
print(response.status_code, response.json())

Response

These bodies are real recordings from the offline suite, against stubbed brokers. The timing numbers are illustrative.

{
  "broker": "zerodha",
  "order_id": "250915000000011",
  "instrument_id": "11111111-1111-5111-8111-000000000001",
  "status_before_modify": "OPEN",
  "outcome": "accepted",
  "status_message": null,
  "broker_response": {"s": "ok", "stat": "Ok", "status": "success", "type": "success"},
  "timing_ms": {"preparation": 0.912, "broker": 71.44}
}
{
  "broker": "zerodha",
  "order_id": "250915000000011",
  "instrument_id": "11111111-1111-5111-8111-000000000001",
  "status_before_modify": "OPEN",
  "dry_run": true,
  "request": {
    "method": "PUT",
    "url": "https://api.kite.trade/orders/amo/250915000000011",
    "form": {"order_type": "LIMIT", "price": "2501.5", "quantity": 20}
  },
  "timing_ms": {"preparation": 0.85}
}
{
  "error": "more than one broker holds an order with this order_id, so give broker",
  "order_id": "26091500099999",
  "brokers": ["flattrade", "shoonya"]
}
{
  "error": "indmoney cannot change validity on an order",
  "broker": "indmoney",
  "order_id": "EQ-100072817"
}

Response attributes

The answer names the broker and says what the order's status was before the change.

Attribute Type Description
broker string The broker holding the order.
order_id string The order id you gave.
instrument_id string or null The instrument the stored order resolves to, or null when it could not be found in today's catalogue.
status_before_modify string The stored order's status when the request arrived.
outcome string accepted, rejected or unknown. accepted means the broker took the instruction, not that the exchange has acted on it.
status_message string or null Why the outcome is not accepted.
broker_response object, string or null The broker's own body.
dry_run, request boolean, object On a dry run only.
timing_ms object preparation, and broker when sent.

Status codes

Every refusal after the order has been found carries broker and order_id beside error.

Status When
200 The broker accepted the modification, or this was a dry run.
400 The parameters are invalid: the request body must be a JSON object, order_id is required, order_id must be 1 to 64 letters, digits, hyphens or underscores, broker must be one of ..., dry_run must be true or false, give at least one of quantity, disclosed_quantity, price, trigger_price, order_type or validity to change, or one of the number and choice messages place uses.
400 The broker cannot make the change: <broker> cannot change <field> on an order, <broker> takes no <type> orders or <broker> cannot change an order to <type>.
400 The order after the change does not fit: a <type> order needs a <field>, a <type> order takes no <field>, quantity must be a whole number of lots of <lot>, disclosed_quantity must be a whole number of lots of <lot>, price must be a whole number of ticks of <tick>, trigger_price must be a whole number of ticks of <tick> or disclosed_quantity cannot be more than quantity.
401 Access token is required, Invalid access token or Access token has expired.
404 no broker order book in Redis holds this order_id.
409 the order is already <status>, more than one broker holds an order with this order_id, so give broker (with brokers), or the order has <field> <value>, which the modify route does not handle, for a stored order whose product, order type, validity or side is outside the route's words.
422 outcome is rejected.
501 modifying orders is not implemented for <broker>, for a broker with no modifiable fields. Every broker lists at least one today, so this is not expected.
503 Redis is missing something the request needs: Redis could not be read: <error>, <broker> has no login in Redis, <broker> has no <settings> in its Redis settings, Redis does not hold this order's <field> yet, so try again after the broker's next order book poll, Redis does not hold this order's <field> as a whole number, ..., or a broker-specific login problem such as a missing Kotak session id.
503 A changed quantity cannot be converted: the order's instrument could not be found in today's catalogue, so a changed quantity cannot be converted into the broker's terms, the contract size of this <segment> instrument is not trusted today (<status>), so its quantity cannot be changed, <broker> does not know how it counts quantity in <market> orders or <broker>'s mapping carries no whole lot size for the instrument.
504 outcome is unknown; the change may have been applied.

A price-only change never needs the instrument, so it goes ahead even when the instrument cannot be found or its contract size is not trusted; only the tick check is skipped.

Modify several orders

PUT /api/orders/modifyaccess-token

A body with an orders list changes each order in it. Each item carries what the single body carries, order_id, an optional broker and the fields to change, or parent_id with price and quantity for a held order, and also part and trigger_price for a part of a plan, and each order is checked exactly as a single modification is. Held and sent orders can be mixed in one list, and the results come back in the list's own order. Several orders in one request describes the list, its answer and its statuses.

curl -X PUT "http://127.0.0.1:8080/api/orders/modify" \
  -H "access-token: $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [
      {"order_id": "250926000123456", "price": 2501.5},
      {"order_id": "112509260000012", "quantity": 20, "price": 2499}
    ],
    "dry_run": true
  }'
Under the hood
  • Redis keys read: last_login, settings, unified:catalogue:current_date, unified:catalogue:warm_identifier, every broker's <broker>:orders:orders (one HGET each), unified:orders:children (one HMGET per order, to see whether the engine owns it), then unified:catalogue:<date>:tokens:<broker> and the candidates' identity, order_handles and contract_sizes.
  • A list: modify_order_list reads every order's entries in the first pipeline, then warm_order_instruments reads every order's token candidates in one pipeline and their catalogue data in another, each skipped when this worker holds it. Each order is then prepared as a single one is, by prepare_modification, into a PreparedModification.
  • How the instrument is found: the stored order's broker token is looked up in tokens:<broker>. A candidate is kept only when it is tradeable, its market is one the broker takes, the broker's handle carries the same token, and its market matches the stored exchange code. Exactly one candidate must remain, because several brokers number tokens per exchange.
  • Classes: ModifyOrderRequest validates the parameters and OrderModification lays the change over the stored order.
  • Rate budget: before sending, the route takes room in the per-broker rate budget the order engine also uses, waiting up to UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_WAIT_SECONDS. With no room it answers 503 the order rate budget is full, so this change was not sent; try again in a moment; in a list, only that item. The order engine's loss lockout does not hold it, and the daily order count does count it.

Cancel an order

DELETE /api/orders/cancelaccess-token

This route cancels one open order at the broker that holds it. It reads Redis once, finds the broker by the order id, and sends one cancel request. An order can be cancelled only after the broker's order poller or order websocket has recorded it in Redis. An order the order engine manages can instead be named by its parent_id, which cancels the whole parent or, with part, one part of a plan, as modify names a held order or a plan part.

A cancel cannot be taken back

outcome: accepted means the broker took the cancel request. The order may still fill in the moment before the exchange acts on it, so read the order book to see the final status.

An order the engine placed

When the order is a leg of one of the order engine's parents, the cancel is handed to the worker that owns the parent, which records it and lets the order type react to a cancel it knows about. The answer then carries parent_id and synthetic_type. Cancelling one leg does not cancel the parent; cancel the parent to stop it placing anything more. The engine also reads the order from the broker's order book, so an order it has only just placed can be cancelled once the broker's poller or websocket has recorded it, as for any other order.

A plan does not send again what you cancelled, and it reacts as the older order types do. A TWAP's cancelled slice is skipped and the later slices go out on time, an iceberg whose slice you cancel shows no more, a bracket whose entry you cancel drops its exits, and a cancelled bracket stop leaves the target resting. To stop a part sending its later pieces as well, cancel the part.

A whole parent

A body with parent_id and no part cancels the whole parent, exactly as DELETE /api/orders/parents does: every leg still resting at a broker is cancelled, and the parent ends as cancelled, so it places, moves and cancels nothing more. This is the only way to cancel a parent that has placed nothing yet, such as an armed trigger or a held limit order. With dry_run the answer lists the legs a cancel would be sent for under resting_legs, and nothing is cancelled.

{"parent_id": "00000000-0000-4000-8000-000000000002"}

The answer is the one DELETE /api/orders/parents gives, with its 207 when a leg's cancel was refused.

A part of a plan

A plan is a tree of parts, and you name one by the plan's parent_id and the part's path in part, as GET /api/orders/parents shows it under parameters.parts, such as root.each_fill.children.0 for a bracket's stop. Cancelling a part stops it sending anything more, whether or not its turn has come.

  • A part whose turn has not come, such as a bracket's stop before the entry fills, is not sent when its turn comes, and is marked done as cancelled then. Nothing is sent to a broker. The rest of the plan carries on: when the entry fills, the bracket's target is still sent.
  • A part waiting on its trigger is marked done as cancelled at once.
  • A part that has sent orders sends no further piece, such as a TWAP's later slices or a peg's next move, and each of its orders still resting is cancelled. It is marked done once the brokers confirm. Each order in the answer's orders says whether its broker accepted the cancel in cancel_accepted.

The plan then reacts as it does to that part finishing: a bracket whose entry you cancel before it fills drops its exits. dry_run lists the part's resting orders and changes nothing. The answer below is a real recording from the offline suite.

{
  "parent_id": "00000000-0000-4000-8000-000000000002",
  "synthetic_type": "plan",
  "part": "root.each_fill.children.0",
  "state": "pending",
  "orders": [],
  "outcome": "accepted",
  "status_message": "it will not be sent when its turn comes; nothing was sent to a broker",
  "intent_id": "00000000000040008000000000000006"
}
Status When
200 The part is cancelled and every resting order's cancel was accepted, or none was resting, or this was a dry run.
207 A broker did not accept the cancel of one of the part's orders, which may still be resting. outcome is partial, or rejected when no cancel was accepted. The part still sends nothing more, and asking again retries the cancels.
400 part must name a part of the plan, such as root.first, or name the order either by parent_id or by order_id, not both; <field> cannot be given with parent_id.
404 the order engine holds no parent with this id, or the plan has no order at part <path>.
409 part <path> is already done: <reason>; part <path> is already cancelled, and will not be sent when its turn comes; a part kept whole, such as a scale_out, that has not started, which keeps its orders by its own rules (cancel it once it has started, or cancel the whole parent); a plan that has finished; or a <type> order has no parts, ... for any other type.
503 The order engine is not running.

Request parameters

order_id, broker and dry_run may come from the JSON body or the query string, and the body wins when both are given. parent_id and part come from the body only.

Name In Type Required Description
access-token header string yes The day's token.
order_id body or query string one of order_id and parent_id The broker's order id: 1 to 64 letters, digits, hyphens or underscores. A JSON integer is accepted; a boolean is not.
broker body or query string no One of the ten broker names, needed only when two brokers hold the same id. Not taken with parent_id.
parent_id body string one of order_id and parent_id The parent the place route answered with, to cancel the whole parent.
part body string no With parent_id, the path of one part of a plan to cancel.
dry_run body or query boolean no Returns the request without sending it, or, with parent_id, says what would be cancelled.
curl -X DELETE "http://127.0.0.1:8080/api/orders/cancel?order_id=250915000000011&dry_run=true" \
  -H "access-token: $ACCESS_TOKEN"
import os

import requests

response = requests.delete(
    'http://127.0.0.1:8080/api/orders/cancel',
    headers={'access-token': os.environ['ACCESS_TOKEN']},
    json={'order_id': '250915000000011', 'dry_run': True},
    timeout=15,
)
print(response.status_code, response.json())

Response

These bodies are real recordings from the offline suite, against stubbed brokers. The timing numbers are illustrative.

{
  "broker": "zerodha",
  "order_id": "250915000000011",
  "status_before_cancel": "OPEN",
  "outcome": "accepted",
  "status_message": null,
  "broker_response": {"s": "ok", "stat": "Ok", "status": "success", "type": "success"},
  "timing_ms": {"preparation": 0.41, "broker": 65.02}
}
{
  "broker": "zerodha",
  "order_id": "250915000000011",
  "status_before_cancel": "OPEN",
  "dry_run": true,
  "request": {
    "method": "DELETE",
    "url": "https://api.kite.trade/orders/amo/250915000000011"
  },
  "timing_ms": {"preparation": 0.38}
}
{
  "error": "no broker order book in Redis holds this order_id",
  "order_id": "NOSUCHORDER"
}

Response attributes

The cancel answer mirrors the modify answer, without an instrument.

Attribute Type Description
broker string The broker holding the order.
order_id string The order id you gave.
status_before_cancel string The stored order's status when the request arrived.
outcome string accepted, rejected or unknown.
status_message string or null Why the outcome is not accepted.
broker_response object, string or null The broker's own body.
dry_run, request boolean, object On a dry run only.
timing_ms object preparation, and broker when sent.

Status codes

The cancel route has fewer failure modes than modify, because it needs no instrument.

Status When
200 The broker accepted the cancel, or this was a dry run.
400 the request body must be a JSON object, order_id is required, order_id must be 1 to 64 letters, digits, hyphens or underscores, broker must be one of ... or dry_run must be true or false.
401 Access token is required, Invalid access token or Access token has expired.
404 no broker order book in Redis holds this order_id.
409 the order is already <status>, or more than one broker holds an order with this order_id, so give broker.
422 outcome is rejected.
503 Redis could not be read: <error>, <broker> has no login in Redis, <broker> has no <settings> in its Redis settings, or a message saying Redis does not yet hold a value the broker's cancel needs.
504 outcome is unknown; the cancel may have been applied.

The sequence below shows a cancel from start to finish. Two Redis round trips happen before the broker call: one pipeline that reads everything, and one that takes room in the rate budget. A dry run needs only the first.

sequenceDiagram
    autonumber
    participant C as Your program
    participant A as API worker
    participant R as Redis
    participant B as Broker holding the order
    C->>A: DELETE /api/orders/cancel?order_id=...
    A->>A: header present? order_id valid?
    A->>R: one pipeline: token, logins, settings,<br/>HGET <broker>:orders:orders for all ten,<br/>HMGET unified:orders:children
    R-->>A: replies
    A->>A: check token
    A->>A: find_stored_order: exactly one broker?
    alt none holds it
        A-->>C: 404
    else two hold it and no broker given
        A-->>C: 409 with brokers
    else already finished
        A-->>C: 409 the order is already COMPLETE
    else
        A->>A: login and settings present? build cancel
        A->>R: take room in the rate budget
        A->>B: one HTTP request
        B-->>A: answer
        A-->>C: 200 / 422 / 504
    end

Cancel several orders

DELETE /api/orders/cancelaccess-token

A body with an orders list cancels each order in it. Each item carries what the single body carries, order_id and an optional broker, or parent_id for a whole parent and also part for a part of a plan, and each order is checked exactly as a single cancel is. Broker orders and parents can be mixed in one list, and the results come back in the list's own order. Several orders in one request describes the list, its answer and its statuses.

curl -X DELETE "http://127.0.0.1:8080/api/orders/cancel" \
  -H "access-token: $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [
      {"order_id": "250926000123456"},
      {"order_id": "26092600000021", "broker": "flattrade"}
    ]
  }'
import os

import requests

response = requests.delete(
    'http://127.0.0.1:8080/api/orders/cancel',
    headers={
        'access-token': os.environ['ACCESS_TOKEN'],
    },
    json={
        'orders': [
            {
                'order_id': '250926000123456',
            },
            {
                'order_id': '26092600000021',
                'broker': 'flattrade',
            },
        ],
    },
    timeout=30,
)
for result in response.json()['results']:
    print(result['request_index'], result['status'], result['response'].get('outcome') or result['response'].get('error'))
Under the hood
  • Redis keys read: last_login, settings, <broker>:orders:orders for every broker, and unified:orders:children, which says whether the order engine owns the order.
  • Why the stored order matters: several brokers' cancel requests need values only their own order book carries, such as Zerodha's variety (the amo in the URL above comes from the stored order), Kotak's after-market flag or Wisdom Capital's identifier.
  • Classes: CancelOrderRequest validates a cancel named by order_id, and ParentCancel one named by parent_id, which is handed to the engine as a cancel_parent command and runs PlanOrder.cancel_part for a part.
  • A list: cancel_order_list reads every broker order's entries in the same single pipeline, however many orders it has, and prepares each with prepare_cancel into a PreparedCancel. The parent_id items are handed to the engine together, as the modify list hands its held orders.

The engine's parents

GET /api/orders/parentsaccess-token

A parent is one order you asked the order engine for, such as a bracket, and its legs are the broker orders it placed for it. This route shows one parent, named by parent_id in the query string, or every parent that has not finished. It is the only way to see a parent that has placed nothing yet, such as an armed trigger or a scheduled order.

Name In Type Required Description
access-token header string Yes The token from connect
parent_id query string No The parent to show; without it, every open parent is listed under parents

The answer is the parent as the engine holds it in unified:orders:parents: parent_order_id, synthetic_type, state, instrument_id, the caller's body, the type's parameters and one entry per leg with its broker, order id, state, quantities and prices. A named parent the engine does not hold is answered 404 the order engine holds no parent with this id.

Cancel a parent

DELETE /api/orders/parentsaccess-token

This route cancels one parent, named by parent_id in the body, or each of a list given as parents, an array of objects each holding parent_id. The worker that owns the parent cancels every leg still resting at a broker and ends the parent as cancelled, so it places, moves and cancels nothing more. DELETE /api/orders/cancel with parent_id does the same, and also takes part, dry_run and a list mixing parents with broker orders; this route is kept for the programs that already call it.

An order placed a moment earlier may not have reached its broker's order book in Redis yet, and a cancel is built from that book, so the engine waits up to 3 seconds for it to appear before giving up. When it still has not appeared, that leg's cancel is rejected with the reason, because nothing was sent. When a broker refuses a leg's cancel, or its outcome is unknown, the answer is 207 and the parent's state is cancelling, not cancelled, because that leg may still be live. Each entry of cancelled_legs gives its own outcome and status_message, so you can see which leg is still resting. The parent no longer acts, becomes cancelled on its own once the broker reports the leg finished, and sending the same cancel again retries the legs still resting. In the live test of 2026-09-27, INDmoney refused one cancel for its rate limit, and before this rule the parent was reported cancelled while the order stayed pending at the broker.

Cancelling a parent cancels live orders

Every leg the parent still has resting at a broker is cancelled. A position the parent already opened is not closed; close it yourself or use flatten.

{
  "parent_id": "0c2d4e6f-8a1b-4c3d-9e5f-7a8b9c0d1e2f",
  "synthetic_type": "bracket",
  "state": "cancelled",
  "cancelled_legs": [
    {"leg_id": "0c2d4e6f-8a1b-4c3d-9e5f-7a8b9c0d1e2f:2", "broker": "flattrade", "order_id": "26091500000031", "outcome": "accepted", "status_message": null}
  ],
  "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70"
}
Status Meaning
200 The parent is cancelled; cancelled_legs says what each leg's cancel came back as. A list is answered with results, one entry per parent.
207 A leg's cancel was refused or its outcome is unknown, so the parent's state is cancelling.
400 parent_id must name a parent, parents must be a non-empty list, or, for one entry of a list, each entry of parents must name a parent_id
404 the order engine holds no parent with this id
409 the parent is already <state>
503 The order engine is not running.
Under the hood
  • Redis keys read: unified:orders:parents and unified:orders:parents:open for the read; the cancel is handed to the engine as a cancel_parent command on unified:orders:intents:stream.
  • Classes: ParentCommands runs the cancel through the parent's own order type, with SyntheticOrder.cancel_by_caller.

Several orders in one request

POST /api/orders/place, PUT /api/orders/modify and DELETE /api/orders/cancel take a list of orders on the same paths. The list form is chosen by the body: a body with an orders key is a list and is answered with {"results": [...]}, even when it holds one order, and a body without one is the single form, answered exactly as described above. The shape of the answer follows the form of the request, never the number of orders.

Request body

Name In Type Required Description
access-token header string Yes The token from connect
orders body array of objects Yes One item per order. For place, an item is one order exactly as the single body gives it, including a synthetic object for any order type, and a list holds at most UNIFIED_BROKER_INTERFACE_API_ORDER_PLACE_LIST_MAXIMUM items, 500 by default. For modify and cancel, an item carries order_id, an optional broker and, for modify, the fields to change, with no limit on the number. An item may instead carry parent_id: for modify with price and quantity, for a held order, and with part for a plan part; for cancel alone for a whole parent, or with part for a plan part.
dry_run body, or query for modify and cancel boolean No true shows every order's broker request instead of sending it. It applies to the whole list, including the items named by parent_id, which are checked and not changed.

The list takes no other key, in the body or the query string, so a field such as price or broker has to go inside each order. That rule is there so that nobody can put price beside the list expecting it to apply to every order, and it is why dry_run is refused inside an order: one order must never go live while its neighbours are only shown.

Placing a list

A placed list goes to the order engine in one step, and the engine's worker lanes place the orders in parallel, each at the broker its selector chooses, so a list spread over ten brokers goes out about ten times as fast as one order after another. A broker given in an item is ignored, as it is in the single form. An item that is not a valid order, or whose instrument cannot be found, gets its own entry and does not hold up the rest.

The request waits for the engine's answers for the single form's wait plus a tenth of a second per order, and never longer than UNIFIED_BROKER_INTERFACE_API_ORDER_PLACE_LIST_WAIT_SECONDS, 25 by default, which is below gunicorn's 30-second worker timeout. An order still unanswered then gets 504 with outcome unknown and its intent_id, because the engine may still place it; read its answer later by that id. A place entry carries intent_id beside request_index, and it is null for an item refused before it reached the engine.

{
  "results": [
    {"request_index": 0, "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70", "status": 200, "response": {"broker": "flattrade", "outcome": "accepted", "order_id": "26091500000021", "parent_id": "…", "…": "…"}},
    {"request_index": 1, "intent_id": null, "status": 400, "response": {"error": "quantity must be a whole number of at least 1"}},
    {"request_index": 2, "intent_id": "8b1f2e3d4c5b6a79881726354a3b2c1d", "status": 200, "response": {"broker": "fyers", "outcome": "accepted", "order_id": "26091500000013", "…": "…"}}
  ]
}

The whole list is refused, with nothing placed, when orders is missing, empty or longer than the maximum, when the body has a key other than orders and dry_run, and with 503 when the order engine is not running.

Response

Each entry carries the order's position in the list, the status the single form would have answered with, and in response exactly the body the single form would have answered with. The example below is shortened.

{
  "results": [
    {"request_index": 0, "status": 200, "response": {"broker": "zerodha", "order_id": "250926000123456", "status_before_cancel": "OPEN", "outcome": "accepted", "...": "..."}},
    {"request_index": 1, "status": 422, "response": {"broker": "dhan", "order_id": "112509260000012", "outcome": "rejected", "status_message": "bad request", "...": "..."}},
    {"request_index": 2, "status": 409, "response": {"error": "the order is already COMPLETE", "broker": "zerodha", "order_id": "250926000099999"}},
    {"request_index": 3, "status": 400, "response": {"error": "this order is already named at request_index 0", "order_id": "250926000123456"}}
  ]
}
Attribute Type Description
results array of objects One entry per item of orders, in the same order
results[].request_index integer The item's position in orders, counting from 0
results[].status integer The status the single form answers this order with: 200, 422 or 504 once a request was sent, or the refusal's status
results[].response object The body the single form answers this order with

Status codes

The status of the whole response says only whether the list could be read. Check each entry's status to learn what happened to each order, because a 200 can hold orders that were refused or whose outcome is unknown.

Status When
200 The list was read and every order has an entry.
400 orders must be a non-empty list, a list may hold at most <maximum> orders, not <n> (for place), a list takes only orders and dry_run, so give <name> inside each order, a list takes only dry_run in the query string, so give <name> inside each order, or dry_run must be true or false.
401 Access token is required, Invalid access token or Access token has expired.
503 Redis could not be read: <error>, and for place also no instruments have been mapped yet or the order engine is not running, so the order was not placed; start unified-orders@order_engine.service.

An entry gets any status the single form gives. Three refusals exist only in a list, each with 400: each entry of orders must be an object, dry_run applies to the whole list, so give it beside orders rather than inside an order, and this order is already named at request_index <n> for an item that names an order an earlier item already names. Two items name the same order when their order_id is the same and their broker is the same or either leaves it out, so the same id at two named brokers is two orders.

How a list is sent

Every order is checked before anything is sent, and the orders that pass are sent to their brokers up to four at a time, each exactly once. The requests of one list therefore leave in no fixed order, and a list is not all-or-nothing: an order that is refused, or whose outcome is unknown, does not stop the others. A sending failure that the broker code did not expect is answered as a 504 entry with sending failed unexpectedly, so whether the broker received it is unknown: <error>, so that it cannot hide the answers of orders already sent.

Every order sent counts towards its broker's daily order cap, exactly as a single request does.

List Redis round trips before the broker calls
DELETE /cancel 1 whatever the number of orders, plus one per cancel sent, to take room in the rate budget
PUT /modify 1 to 3 whatever the number of orders: the first pipeline, then every order's token candidates, then their catalogue data, each skipped when this worker already holds it; plus one per change sent, to take room in the rate budget

These counts are recorded by test_runs/order_change_lists.py: a cancel list of 60 orders that no broker holds costs one round trip, a dry-run modify list of one order at each of the ten brokers costs three, and the same modify list sent costs thirteen.

How the broker is chosen

POST /api/orders/place names an instrument, not a broker, so the API has to pick one. It does this in three stages. First it drops the brokers that configuration excludes, then the broker selector ranks the rest, and finally each broker in that ranking is asked whether it can take this particular order. The first broker that says yes gets the order.

flowchart TD
    A["All ten brokers"] --> B{"Named in<br/>ORDER_EXCLUDED_BROKERS?"}
    B -- yes --> X["Left out of the rotation"]
    B -- no --> C["Rotation"]
    C --> D{"Any broker left?"}
    D -- no --> E["503 every broker is excluded<br/>from order placement"]
    D -- yes --> F["Selector ranks the rotation<br/>lowest_cost, round_robin or fixed_priority"]
    F --> G["Next broker in the ranking"]
    G --> H{"place_skip_reason<br/>says it can take it?"}
    H -- no --> I["Add to skipped<br/>with the reason"]
    I --> J{"More brokers?"}
    J -- yes --> G
    J -- no --> K["503 no broker can take this order<br/>with skipped"]
    H -- yes --> L["Lot size and tick size checks"]
    L --> M["Build and send the request"]

Excluded brokers

UNIFIED_BROKER_INTERFACE_API_ORDER_EXCLUDED_BROKERS is a comma-separated list of broker names that never receive a placement. When it names every broker, every placement is refused with 503 every broker is excluded from order placement. Modify and cancel ignore this setting, because an order already at a broker can only be changed there.

The three selectors

UNIFIED_BROKER_INTERFACE_API_ORDER_BROKER_SELECTOR picks the ranking algorithm, and an unknown name stops the API from starting rather than falling back. The table below compares the three selectors.

Selector How it ranks Redis cost When to use it
lowest_cost (default) Cheapest broker for the order's category first, keeping every broker inside its per-second, per-minute, per-hour and per-day budgets, with every number read from unified.broker_order_costs; see Choosing a broker by cost One EVAL, queued on the pipeline that reads the instrument Paying the least brokerage without running a broker out of its order budget
round_robin Starts at INCR unified:orders:round_robin modulo the rotation's length and walks the rotation from there. Every gunicorn worker shares the counter. One INCR, queued on the pipeline that reads the instrument Spreading orders evenly across accounts
fixed_priority Puts the brokers named in UNIFIED_BROKER_INTERFACE_API_ORDER_BROKER_PRIORITY first, in that order, then the rest of the rotation in turn order None Sending everything to one preferred broker, with the others as fallbacks

With round robin, a skipped broker's turn passes to the next broker in the rotation, and the counter is then moved on past every broker passed over with one INCRBY, so the next order starts after the broker that took this one. A run of orders some brokers cannot take, such as after-market orders, is therefore spread evenly over the brokers that can. Before this, the broker after a skipped one took two turns in a row, and in the live test of 2026-09-27 INDmoney received 30 of 100 after-market orders.

Why a broker is skipped

Each broker's place_skip_reason answers "can you take this order?" before anything is built. The checks run in the order below, and the first one that fails becomes the reason in skipped.

Check Reason text
The instrument's market is in the broker's MARKETS does not take <exchange> <asset class> <kind> orders
For a currency or commodity market, the broker knows how it counts quantity there does not know how it counts quantity in <market> orders
The broker has an order handle for the instrument has no mapping for the instrument
The handle carries the field the broker names instruments by its mapping carries no broker_token or its mapping carries no order_symbol
Where the broker counts in its own lot size, the handle has a whole one its mapping carries no whole lot size
Redis holds a login with an access token has no login in Redis
Broker-specific login checks, such as Kotak's session id a broker-specific message
The settings the place request needs are present has no <fields> in its Redis settings
The broker takes SL and SL-M orders, when this is one takes no <type> orders
The broker takes after-market orders, when this is one takes no after-market orders
The broker has the free cash for the order, when the lowest-cost selector's funds check is on needs about <margin> of margin but has <cash> free, its funds are stale, its funds were read <n> seconds ago or has no funds in unified:portfolio:funds

The last check comes from the selector rather than the broker: the lowest-cost selector estimates the margin the order needs at each broker and compares it with that broker's free cash, without calling the broker. It applies only when the selector chooses, so an order that names its broker is never checked. Choosing a broker by cost explains the estimate.

The markets each broker takes are listed below, from the MARKETS table of each order class. A market is an exchange, an asset class and a kind of instrument.

Market Dhan Flattrade Fyers Groww INDmoney Kotak Shoonya Stoxkart Wisdom Capital Zerodha
NSE cash
BSE cash
NSE derivatives
BSE derivatives
MCX commodity derivatives
NSE currency derivatives
BSE currency derivatives
NSE commodity derivatives
NCDEX commodity derivatives
Takes after-market orders
Takes SL and SL-M

Contract sizes, lots and ticks

The size checks differ between the securities markets and the currency and commodity markets, because the brokers agree on lot sizes in the first and disagree in the second.

  • Securities (equities, fixed income, funds, trusts and their derivatives): quantity must be a whole number of the chosen broker's own lot_size. The quantity is sent to the broker unchanged, in units.
  • Currency and commodity: the lot size comes only from this morning's contract size decision in unified:catalogue:<date>:contract_sizes, because brokers count these lots in different units. When the decision is missing, not marked tradeable, or not a positive number, the order is refused with 503 and contract_size_status (for example conflict or undecided). Otherwise quantity and disclosed_quantity must both be whole lots, and each broker converts them into its own terms, which is broker_lot_size (lots times the broker's own lot size) for every broker and market listed today.
  • Ticks: price and trigger_price must be whole numbers of the tick size that the most brokers' handles agree on. When two tick sizes tie, the check is skipped.

Finding the broker that holds an order

Modify and cancel take a broker's order id, and the API has to find which of the ten brokers issued it. It reads the id from every broker's <broker>:orders:orders hash in the same pipeline that reads the token, and then find_stored_order decides.

  1. When you gave broker, only that broker's entry is considered.
  2. An entry that is missing, not JSON, or not a JSON object counts as not holding the order.
  3. When no broker holds it, the answer is 404 no broker order book in Redis holds this order_id.
  4. When more than one broker holds it, the answer is 409 with brokers listing them, and you retry with broker set.
  5. When the one stored order is finished (COMPLETE, CANCELLED, REJECTED or EXPIRED), the answer is 409 the order is already <status>.

Because the lookup reads Redis rather than the broker, an order placed a moment ago may not be there yet. The broker's poller records it within about a second, and its order websocket often sooner.

Daily order caps

Some brokers refuse every order message past a fixed number a day, and a modification or a cancellation counts as a message just like a placement. A broker's cap is its orders_per_day in unified.broker_order_costs, which caps Zerodha at 4,500 and Fyers at 100,000. For a broker the table leaves uncapped, UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAPS can still set one, written as broker=number,broker=number, for example dhan=7000. That setting is empty by default. A broker capped by neither is never counted, which costs nothing.

When a broker is capped, every request actually sent to it is counted in the Redis key unified:orders:daily_count:<broker>. The count expires at the next 06:00 IST, so it starts again each trading day. A request that could not even connect is not counted; one the broker refused is.

Setting Default Effect
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAPS empty Caps for brokers whose orders_per_day in the cost table is empty
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAP_EXIT_RESERVE 0.05 The share of each cap kept back for orders that close a position

The diagram below shows how a cap of 5,000 with the default reserve is split.

flowchart LR
    A["0 messages"] -->|"entries and exits allowed"| B["4,750<br/>entry limit"]
    B -->|"only exits allowed"| C["5,000<br/>cap"]
    C -->|"everything refused"| D["429"]

The refusal with 429 happens in the order engine, which checks the count before it places each leg. PUT /api/orders/modify and DELETE /api/orders/cancel are counted but never refused because of the count.

When the engine refuses an order, the message says which limit was hit. For a new entry it is <broker> has been sent <n> order messages today, and the last <m> of its daily cap of <cap> are kept for closing positions, so this was not sent. For an exit it is <broker> has been sent <n> order messages today, which is its daily cap of <cap>, so this was not sent. A count that cannot be read never refuses an order; the failure is logged instead.

Connection warming

Opening a new TLS connection to a broker costs 50 to 130 ms more than using an open one, so every connection in each broker's pool is kept freshly used. UNIFIED_BROKER_INTERFACE_API_ORDER_WARM_BROKERS names the brokers to warm, comma-separated, and is all unless set; set it empty to turn warming off. For each warmed broker, a background thread sends a HEAD request with no credentials through the same connection pool the orders use. After the answer, it watches the connection for one second and returns it to the pool only if the server has not closed it.

A warmed broker's pool hands out the connection that has waited longest, rather than urllib3's usual most recent one, so single pings rotate through the whole pool. The warmer first sends one ping per place in the pool back to back, which opens every connection, and then pings one connection at a time, spaced so that each is used about once every WARM_INTERVAL_SECONDS. A ping that fails is followed by a pause of the whole interval. The order engine's pool for each broker holds UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_WORKERS_PER_BROKER plus one connections (31 by default), one for every worker a broker's lane can grow to and one for the ping; an API worker's holds five, one for each of its four send threads and one for the ping. A broker that is not warmed keeps urllib3's usual order, which keeps its one busy connection hot.

Each broker's pool also refuses to reuse a connection that has been idle longer than MAXIMUM_IDLE_SECONDS, well before the broker's server would drop it. The table below lists both values for each broker.

Broker Ping interval (s) Longest idle reuse (s) Warmed host
Dhan 60 180 https://api.dhan.co/
Flattrade 60 300 https://piconnect.flattrade.in/
Fyers 60 300 https://api-t1.fyers.in/
Groww 60 300 https://api.groww.in/
INDmoney 60 300 https://api.indstocks.com/
Kotak 60 300 none until the first Kotak request names its host
Shoonya 15 45 https://api.shoonya.com/
Stoxkart 60 300 https://openapi.stoxkart.com/
Wisdom Capital 15 45 https://trade.wisdomcapital.in/
Zerodha 60 300 https://api.kite.trade/

One round of a pool takes WARM_INTERVAL_SECONDS or the pool size times the one-second settle check, whichever is longer: about 31 seconds for a pool of 31, which is below the shortest idle limit, Shoonya's and Wisdom Capital's 45 seconds.

Warming only saves time, so nothing about it can stop an order. An unknown broker name is logged and ignored, every exception in the warming thread is caught and logged, and a ping never touches the session's cookies or headers. Once a real request has gone to a broker, later pings go to that request's host instead of the default one.

Redis round trips per route

The order routes were written so that the API's own work adds as little as possible to the time the broker takes. The table below counts the Redis round trips each route makes before its broker call, as the code and the recorded fixtures show them.

Route Round trips What they are
GET /api/orders/details, /trades token check, then 1 The token check, then one GET of the document
POST /place 4 or 5 The first pipeline, an optional lookup by fields, the engine lock's EXISTS, the XADD of the intent and the BLPOP for the answer; the engine's own reads are not counted here
PUT /modify 2 to 4 The first pipeline with every broker's order book; the token candidates; their catalogue data, each skipped when held by this worker; one more for the rate budget before sending
PUT /modify, a list 1 to 3, plus one per change sent The same three, each read once for the whole list, plus one for the rate budget per change sent
DELETE /cancel 2 One pipeline with the token, logins, settings and every broker's order book, and one for the rate budget
DELETE /cancel, a list 1, plus one per cancel sent The same pipeline, holding every order of the list, and one for the rate budget per cancel
POST /flatten 1, plus more One read of everything; the engine lock's EXISTS, an XADD and a BLPOP to halt every open parent; one for the rate budget per cancel; one re-read of the order books every 0.25 s while waiting for the cancels; an HGET per position closed, then the engine lock's EXISTS, one pipeline of XADDs and a BLPOP per answer; one re-read of the position books every 0.25 s while waiting for the closed positions to show zero

When a broker is capped by ORDER_DAILY_CAPS, each request sent to it costs one more pipeline afterwards, to increment the count.