## Orders
### POST /v1/orders — Place order

**Scope:** `orders.write`

To learn what an order would block before you send it, post the same body to [Order margin](/api/v1/reference/funds/#post-v1marginsorder--order-margin).

#### Request

| Field | Type | Required | Notes |
|---|---|---|---|
| `exchange_segment` | string | yes | `NSE` \| `NFO` \| `BSE` \| `BFO`. |
| `symbol` | string | yes | The scrip master's `symbol` column (e.g. `RELIANCE`, `NIFTY29DEC26C24500`, `SENSEX26NOVFUT`). Look it up rather than building it: NFO and BFO contracts follow different exchange formats. |
| `txn_type` | string | yes | `B` \| `S`. |
| `qty` | integer | yes | Total quantity in units. Must not exceed the instrument's freeze quantity — see [Freeze quantity](#freeze-quantity). |
| `order_type` | string | yes | `LMT` \| `MKT` \| `SL-LMT` \| `SL-MKT`. |
| `product` | string | yes | `C` \| `I` \| `M` \| `F`. |
| `validity` | string | yes | `DAY` \| `IOC`. |
| `price` | integer | yes | Paise. Use `0` for `MKT` / `SL-MKT`. Must be a multiple of tick size for `LMT` / `SL-LMT`. |
| `trigger_price` | integer | conditional | Paise. Required for `SL-LMT` / `SL-MKT`. |
| `disclosed_qty` | integer | no | Disclosed quantity. |
| `market_protection` | number | no | `MKT` / `SL-MKT` only: `-1` applies auto protection, or a custom percentage (`0.01`–`100`, e.g. `2` = 2%). Omitted means auto protection. `0` is rejected. Ignored for `LMT` / `SL-LMT`. See [Market protection](#market-protection). |
| `variant` | string | no | `REGULAR` (default) \| `AMO`. `AMO` queues an after-market order for the next trading session — `LMT` / `SL-LMT` only. See [After-market orders](#after-market-orders). |

#### Example

```http
POST /v1/orders
Authorization: Bearer eyJhbGciOiJFUzI1NiIs...
Content-Type: application/json
```
```json
{
  "exchange_segment": "NSE",
  "symbol": "RELIANCE",
  "txn_type": "B",
  "qty": 10,
  "order_type": "LMT",
  "product": "I",
  "validity": "DAY",
  "price": 249950
}
```

#### Response — `201 Created`

```json
{
  "order_ids": ["26101900133912345678"]
}
```

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

The body is an acknowledgement; the full order state is observed via `GET /v1/orders` and live on the order-updates stream. Synchronous rejection returns a non-2xx error with `code` set per the error enum.

#### Market protection

Every `MKT` / `SL-MKT` order is placed with market protection: it is converted, once, into a limit order before it reaches the exchange (a SEBI retail-algo requirement — no unprotected market order is accepted).

- **`MKT`** becomes `LMT` priced at LTP plus or minus protection% (buy above, sell below), clamped to the day's price band and snapped to tick size.
- **`SL-MKT`** becomes `SL-LMT` priced at `trigger_price` plus or minus protection% (no market-data dependency).
- **`market_protection: -1`** (or omitting the field) applies auto protection, tiered by reference price — equity & futures: below ₹100 → 2%, ₹100–500 → 1%, above ₹500 → 0.5%; options: below ₹10 → 5%, ₹10–100 → 3%, ₹100–500 → 2%, above ₹500 → 1%.
- **`market_protection: 0`** is rejected; custom values must be between `0.01` and `100`.
- A `MKT` order for an instrument with no traded price (no LTP) is rejected with `422`.

The conversion is **one-shot**: the resulting order *is* a plain `LMT` / `SL-LMT` — `GET /v1/orders` shows the converted order type and the computed price, and later modifications follow plain limit-order semantics (modifying only `trigger_price` on a converted stop order keeps the existing limit price). To re-anchor protection at the current price, send `order_type: "MKT"` / `"SL-MKT"` (optionally with `market_protection`) in a modify.

#### After-market orders

A `REGULAR` order is refused while the market is closed (`422 VALIDATION_FAILED`; the message carries the next opening time). To place an order for the next session, send it with `variant: "AMO"`: Sahi accepts it, holds it, and releases it to the exchange when the next trading session opens. Use it while the market is closed.

- **`LMT` / `SL-LMT` only.** An AMO with `order_type` `MKT` / `SL-MKT` is rejected with `400`: the only price it could be protected at is the previous session's, not the one it will trade at. Price the order yourself.
- **Not for expired contracts.** An AMO on a contract past its expiry is rejected with `422`.
- **It shows as `Open` with `placed_at: null`** in `GET /v1/orders` and on the order-updates stream until the exchange takes it at open; from then on it is an ordinary order and `placed_at` is set. Read an `Open` order with `placed_at` `null` as one that has not reached the exchange yet.
- **Modify and cancel work while it waits**, through the usual endpoints. A cancelled AMO shows as `Cancelled`. It cannot be modified to `MKT` / `SL-MKT` (`400`), and `variant` itself is not modifiable.
- **Everything else is as for a regular order**: `product`, `validity`, `disclosed_qty` and the rate-limit budget are unchanged, and [`POST /v1/margins/order`](/api/v1/reference/funds/#post-v1marginsorder--order-margin) takes the same body.

#### Freeze quantity

Exchanges cap how much quantity one order may carry — the contract's **freeze quantity** (the `freeze_quantity` column in the scrip master, in units). A `qty` above it is rejected with `400 VALIDATION_FAILED`:

```json
{ "code": "VALIDATION_FAILED", "message": "qty must not exceed the exchange freeze quantity of 1800 for this instrument" }
```

- **One request is one order.** Sahi does not slice an oversized order into freeze-sized child orders for you, because each slice would be its own order with its own `order_id` and a single request cannot return a manageable order. Split the quantity yourself and send one request per slice.
- **The cap is the freeze quantity exactly.** A `qty` *equal* to it is accepted; only a larger one is refused.
- **Instruments with no cap are not restricted.** The scrip master's `freeze_quantity` is a per-order regulatory ceiling and carries `0` where none applies; `0` (or an absent value) means the quantity is unrestricted here.
- **Modify is capped the same way**, whenever the request carries a `qty`. A modify that leaves `qty` alone is never refused by this rule, even on an order that is somehow already above the cap.
- **Margin is not capped.** [`POST /v1/margins/order`](/api/v1/reference/funds/#post-v1marginsorder--order-margin) and [`POST /v1/margins/basket`](/api/v1/reference/funds/#post-v1marginsbasket--basket-margin) will quote a quantity larger than the freeze quantity, so you can size a position before deciding how to slice it.

---

### PUT /v1/orders/{order_id} — Modify order

**Scope:** `orders.write`

Only orders in modifiable states (`Open`, `OpenPartial`) can be modified — including an [after-market order](#after-market-orders) still waiting for the next session (`Open`, `placed_at` `null`).

#### Request

Provide only the fields being changed; at least one is required.

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

#### Response — `200 OK`

```json
{
  "order_id": "26101900133912345678"
}
```

| Field | Type | Notes |
|---|---|---|
| `order_id` | string | The modified order, echoed back. |

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

---

### DELETE /v1/orders/{order_id} — Cancel order

**Scope:** `orders.write`

Cancellable states: `Open`, `OpenPartial`.

#### Response — `204 No Content`

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

---

### GET /v1/orders — Order book (today)

**Scope:** `orders.read`

Returns every order on your account for the current trading day, in any status. Single-shot; no pagination.

One exception, and it is a fault on our side rather than a state you can reach: an order whose symbol is
missing from the day's scrip master is left out of `orders`. The scrip master always carries the instrument
for an order accepted today, so a gap means our instrument table is stale, and we log it. Reconcile against
[the order-updates stream](#wss-v1streamorders--order-updates), which reports the order either way.

#### Response — `200 OK`

```json
{
  "orders": [
    {
      "order_id": "26101900133912345678",
      "exchange_order_id": "1300000123456789",
      "exchange_segment": "NSE",
      "symbol": "RELIANCE",
      "txn_type": "B",
      "order_type": "LMT",
      "product": "I",
      "validity": "DAY",
      "qty": 10,
      "disclosed_qty": 0,
      "filled_qty": 4,
      "price": 250000,
      "trigger_price": 0,
      "avg_price": 249975,
      "status": "OpenPartial",
      "rejection_reason": null,
      "created_at": 1747798201123,
      "placed_at": 1747798201456,
      "completed_at": null,
      "cancelled_at": null,
      "rejected_at": null,
      "last_modified_at": 1747798202789
    }
  ]
}
```

`orders` holds one object per order. Its fields:

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

#### Order status

| Value | Meaning |
|---|---|
| `Open` | Working, no fills yet: at the exchange (`placed_at` set), or an [after-market order](#after-market-orders) waiting for the next session (`placed_at` `null`). |
| `OpenPartial` | Partially filled, remainder open at exchange. |
| `PartialFilled` | Partially filled, remainder cancelled / IOC unfilled. |
| `Completed` | Fully filled. |
| `Cancelled` | Cancelled by you, EOD expiry, or system. |
| `Rejected` | Rejected by Sahi risk or the exchange (`rejection_reason` set). |

#### Timestamps

All timestamps are integer epoch milliseconds. Each terminal-state timestamp is `null` until the order reaches that state.

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

---

### GET /v1/orders/{order_id} — Order details

**Scope:** `orders.read`

Returns one order of yours by `order_id`, for the current trading day. Use it
to follow an order you have just placed or modified without pulling the whole
book and filtering it.

#### Response — `200 OK`

```json
{
  "order_id": "26101900133912345678",
  "exchange_order_id": "1300000123456789",
  "exchange_segment": "NSE",
  "symbol": "RELIANCE",
  "txn_type": "B",
  "order_type": "LMT",
  "product": "I",
  "validity": "DAY",
  "qty": 10,
  "disclosed_qty": 0,
  "filled_qty": 4,
  "price": 250000,
  "trigger_price": 0,
  "avg_price": 249975,
  "status": "OpenPartial",
  "rejection_reason": null,
  "created_at": 1747798201123,
  "placed_at": 1747798201456,
  "completed_at": null,
  "cancelled_at": null,
  "rejected_at": null,
  "last_modified_at": 1747798202789
}
```

The body is the order object itself, not a wrapper: the same fields, with the
same meanings, as an element of the `orders` array in
[GET /v1/orders](#get-v1orders--order-book-today) — including
[Order status](#order-status) and [Timestamps](#timestamps), so whatever type
you already parse the order book's entries with parses this response too.

Polling this endpoint is not a substitute for
[the order-updates stream](#wss-v1streamorders--order-updates). Reach for it
when you hold a handful of `order_id`s and want the current state of one of
them; anything you follow continuously belongs on the socket.

`ORDER_NOT_FOUND` if the order isn't on today's book, or doesn't belong to the
caller. `VALIDATION_FAILED` if `order_id` isn't a well-formed Sahi order id.
`INTERNAL` if the order's symbol is missing from the day's scrip master — the
same stale-instrument fault that drops a row from
[the order book](#get-v1orders--order-book-today).

---

### GET /v1/orders/{order_id}/trades — Trades for an order

**Scope:** `orders.read`

Returns the individual fills that make up one of your orders, for the current
trading day. A single order fills in one or more trades, each at its own price —
this is where you see them separately rather than collapsed into the order
book's `filled_qty` and `avg_price`.

Single-shot; no pagination. Fills are ordered oldest first.

#### Response — `200 OK`

```json
{
  "trades": [
    {
      "exchange_trade_id": "80123456",
      "order_id": "26101900133912345678",
      "exchange_order_id": "1300000123456789",
      "exchange_segment": "NSE",
      "symbol": "RELIANCE",
      "txn_type": "B",
      "product": "I",
      "qty": 2,
      "price": 249950,
      "traded_at": 1747798201789
    },
    {
      "exchange_trade_id": "80123481",
      "order_id": "26101900133912345678",
      "exchange_order_id": "1300000123456789",
      "exchange_segment": "NSE",
      "symbol": "RELIANCE",
      "txn_type": "B",
      "product": "I",
      "qty": 2,
      "price": 250000,
      "traded_at": 1747798202104
    }
  ]
}
```

`trades` holds one object per fill. Its fields:

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

The fills above are the same order shown under
[GET /v1/orders](#get-v1orders--order-book-today): `2 + 2` units make its
`filled_qty` of `4`, and the quantity-weighted average of `249950` and `250000`
is its `avg_price` of `249975`.

Reconcile that way rather than assuming the two are written together. The order
book's totals and this list reach Sahi as separate messages from the exchange,
so a request made in the middle of a fill can briefly see one ahead of the
other. They converge once the order stops filling.

An order that never reached the exchange — one still `AwaitingTrigger`, or
rejected by Sahi risk before placement — has no fills, and returns
`{"trades": []}` rather than an error.

`ORDER_NOT_FOUND` if the order isn't on today's book, or doesn't belong to the
caller.

---

### WSS /v1/stream/orders — Order updates

**Scope:** `orders.read`

Scoped to your account: each connection only receives updates for the JWT's `sub`. No subscribe message — the server starts pushing as soon as the connection is open.

#### Server → client messages

Each frame is a JSON order object identical in shape to a single element of `GET /v1/orders` — the fields are tabulated [there](#get-v1orders--order-book-today).

```json
{
  "order_id": "26101900133912345678",
  "exchange_order_id": "1300000123456789",
  "exchange_segment": "NSE",
  "symbol": "RELIANCE",
  "txn_type": "B",
  "order_type": "LMT",
  "product": "I",
  "validity": "DAY",
  "qty": 10,
  "disclosed_qty": 0,
  "filled_qty": 10,
  "price": 250000,
  "trigger_price": 0,
  "avg_price": 249975,
  "status": "Completed",
  "rejection_reason": null,
  "created_at": 1747798201123,
  "placed_at": 1747798201456,
  "completed_at": 1747798205890,
  "cancelled_at": null,
  "rejected_at": null,
  "last_modified_at": 1747798205890
}
```

#### Stream + Snapshot

Delivery favours freshness over exactly-once. An order update may arrive **more than once** — the stream can replay recent events after a restart on our side — and nothing is replayed for the time the socket was down. Bootstrap in this order rather than trusting the socket alone:

1. Open the WebSocket and wait for the connection to be accepted.
2. *Then* fetch `GET /v1/orders`.
3. Keep the stream frames that arrive before the snapshot does. Do not discard them.
4. Reconcile per `order_id`, keeping the higher `last_modified_at`.

Every reconnect repeats the sequence, `4408` and `4409` included. Duplicates are absorbed by step 4 rather than needing to be prevented.

#### Close codes

| Code | Meaning |
|---|---|
| `1000` | Normal closure (client-initiated). |
| `4401` | Auth expired — token reached `exp`. Mint a fresh token and reconnect. |
| `4403` | Forbidden — token lacks required scope. In practice an under-scoped (or otherwise invalid) token is rejected at the HTTP upgrade handshake with `403`, before the WebSocket is established; this close code is reserved for scope failures detected after the upgrade. |
| `4408` | Ping timeout — no pong received within 15s of the server ping. |
| `4409` | Dropped — the client fell too far behind (slow consumer); reconnect and re-sync via `GET /v1/orders`. |
| `4429` | Per-account concurrent-connection limit exceeded — [5 open connections](/#rate-limits). |

---

