# Cirrus API Reference

> Cirrus trading API docs: place orders across broker accounts, protect positions, run strategies, stream live updates and get signed webhooks.

- REST base: `https://api.cirrus.trade/v1`
- Stream: `wss://api.cirrus.trade/v1/stream`
- OpenAPI 3.1 spec: https://web.cirrus.trade/docs/openapi.json (live from the API: https://api.cirrus.trade/v1/openapi.json)
- HTML docs: https://web.cirrus.trade/docs

## Overview

The Cirrus order API is plain HTTPS + JSON under `/v1`, plus one WebSocket for live order and portfolio updates. It places orders directly with your broker, across one or many accounts.

- REST base: `https://api.cirrus.trade/v1`
- Stream: `wss://api.cirrus.trade/v1/stream`

- Every response uses one envelope: `status`, `data`, `error` (see Errors). The OAuth token endpoint follows RFC 6749 instead.
- Every response carries an `x-request-id` header. Send your own (1-64 letters, digits, `-` or `_`) to correlate logs; quote it when reporting a problem.
- Order sides, types and products are upper case (`BUY`, `LIMIT`, `MIS`). Unknown body fields are rejected, not ignored.

## Using your API key

1. Create a key on [API access (sign in)](https://web.cirrus.trade/api-access). Pick only the scopes you need, and optionally the IPs it may be used from.
2. Copy the key and secret when shown: the secret is not shown again.
3. Send them on every request as shown. Examples on this page read the same variable.

**Quick start**

```bash
export CIRRUS_API_KEY='ck_xxxxxxxx:cs_xxxxxxxx'

curl "https://api.cirrus.trade/v1/portfolio/positions" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

## Authentication

Two credentials work on the public API: your own API keys, and partner OAuth tokens for apps acting for other users.

### API keys

- Send `Authorization: token ck_…:cs_…` (key id, colon, secret). Keys are created and revoked on API access (sign in), only by you, signed in yourself.
- The secret is shown once and stored only as a hash. Lost it? Create a new key and revoke the old one.
- Optional IP allowlist (up to 20 addresses): from any other IP the key gets 403.
- Revoking a key takes effect within about 20 seconds, open streams included.

### Partner apps (OAuth)

- Partner apps use OAuth 2 authorization code + PKCE (S256): the user approves your app on a consent screen, you exchange the code for a `pa_…` access token (1 day) and a rotating `pr_…` refresh token (90 days).
- Call the API with `Authorization: Bearer pa_…`. The full guide, with examples, is in OAuth for partner apps.

Full guide: [OAuth for partner apps](https://web.cirrus.trade/docs/oauth.md).

**API key**

```bash
curl "https://api.cirrus.trade/v1/user/profile" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Partner app (OAuth access token)**

```bash
curl "https://api.cirrus.trade/v1/user/profile" \
  -H "Authorization: Bearer pa_xxxx"
```

## OAuth for partner apps

Build an app that other Cirrus users connect to their own accounts. Each user approves your app once; you get tokens that act for that user within the scopes they granted, and never see their password or API keys.

- For a product serving many users (a trading tool, journal, analytics or copy-trading service): each user connects their own account through a consent screen.
- For your own scripts and bots, use an API key instead (see Authentication): no redirect, no consent screen, no token refresh.
- The only grant is authorization code with PKCE (RFC 6749 + RFC 7636, `S256`). There is no client credentials, implicit or password grant: every token acts for one user who approved it.

### Register your app

- Register on API access in the app (signed in, Partner apps), or with `POST /v1/partner-apps` from your own signed-in app session. API keys and partner tokens cannot manage apps (403); an impersonated session cannot register, rotate, disable or disconnect.
- The reply carries `client_id` (`cp_` + 20 characters) and `client_secret` (`cps_` + 48 characters). The secret is shown once and stored only as a keyed hash: store it in your server’s secret store right away. Lost or leaked? Rotate it (below): the new secret is shown once and the old one keeps working for 24 hours.
- At most 10 active apps per user. Apps cannot be edited: to change the name, redirect URIs or scopes, register a new app (and disable the old one).
- New apps are not verified. Verification is set by the platform operator only; the consent screen shows the app as Verified or Not verified, and warns users about unverified apps.
- Rotate the secret with Rotate secret on API access or `POST /v1/partner-apps/{client_id}/rotate-secret`. For 24 hours both secrets authenticate, so update every server before the old one stops; connected users are not affected.
- Disabling (`DELETE /v1/partner-apps/{client_id}`, or Disable on API access) is permanent: every token of every user of the app stops working within about 5 seconds, and the token endpoint answers `invalid_client`.

**POST /v1/partner-apps body (JSON)**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | 1-80 characters. Shown to users on the consent screen. |
| `redirect_uris` | `string[]` | yes | 1-10 URIs, matched exactly (no prefixes, no wildcards). `https://`, or `http://localhost` / `http://127.0.0.1` (any port) for development. No `#fragment`, no whitespace, at most 512 characters each. |
| `scopes` | `string[]` | yes | At least one of `read`, `orders`, `triggers`: the most a user can grant your app. Ask for less at authorize time when you can. |
| `logo_url` | `string` | no | `https://` image shown on the consent screen. |

**Registered · data**

```json
{
  "client_id": "cp_8fK2mQ7xL0aZ4tR9wY1b",
  "owner": "you@example.com",
  "name": "Trade Journal",
  "verified": false,
  "logo_url": null,
  "redirect_uris": ["https://partner.example/callback"],
  "scopes": ["read", "orders"],
  "created_at": "2026-09-28T09:00:00Z",
  "disabled": false,
  "client_secret": "cps_…",
  "note": "Store the client secret now: it is not shown again."
}
```

**App-session routes (never with an API key or partner token)**

| Route | Who | What |
| --- | --- | --- |
| `GET /v1/partner-apps` | app owner | Your apps, disabled ones included. |
| `POST /v1/partner-apps` | app owner | Register an app. 201 with `client_secret`, shown once. |
| `POST /v1/partner-apps/{client_id}/rotate-secret` | app owner | New `client_secret`, shown once. The old secret keeps working for 24 hours (`previous_secret_expires_at`). |
| `DELETE /v1/partner-apps/{client_id}` | app owner | Disable the app for good; 404 `unknown_app` when it is not an active app of yours. |
| `GET /v1/connections` | user | Apps connected to your account: `client_id`, `app_name`, `verified`, `scopes`, `connected_at`, `updated_at`. |
| `DELETE /v1/connections/{client_id}` | user | Disconnect an app; 404 `connection_not_found` when there is no live connection. |
| `GET /v1/oauth/consent · POST /v1/oauth/authorize` | consent screen | Used by the consent screen itself; partners never call them. |

### The flow

**Authorization code + PKCE**

```
User's browser         Your server              Consent screen             API
      |                      |                        |                          |
      | 1. "Connect"         |                        |                          |
      |--------------------->|                        |                          |
      |                      | make verifier, state;  |                          |
      |                      | keep them server-side  |                          |
      | 2. 302 to /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
      |<---------------------|                        |                          |
      | 3. sign in, review the consent screen, Allow  |                          |
      |---------------------------------------------->| POST /v1/oauth/authorize |
      |                      |                        |------------------------->|
      | 4. 302 to redirect_uri?code=oc_…&state=…      |<-------------------------|
      |<----------------------------------------------|                          |
      |--------------------->| 5. POST /v1/oauth/token (code, code_verifier,     |
      |                      |    client_id + client_secret)                     |
      |                      |-------------------------------------------------->|
      |                      | 6. access_token pa_… (1 day), refresh_token pr_…  |
      |                      |<--------------------------------------------------|
      |                      | 7. API calls: Authorization: Bearer pa_…          |
      |                      |-------------------------------------------------->|
```

1. Create a PKCE `code_verifier` (43-128 characters from `A-Z a-z 0-9 - . _ ~`) and a random `state`. Keep both in the user’s server-side session.
2. Redirect the browser to the consent screen with the parameters below and `code_challenge = BASE64URL(SHA256(code_verifier))`, no padding.
3. The user signs in if needed (they come back to the consent screen afterwards), sees your app’s name, logo, verified badge and the scopes you asked for, and chooses Allow or Deny.
4. On Allow the browser returns to your `redirect_uri` with `code` and your `state`. Check `state` against the session before anything else.
5. Within 60 seconds, exchange the code on your server for tokens at `POST /v1/oauth/token`, sending the verifier and your client credentials.
6. Call the API with the access token. Refresh it before or when it expires (1 day); ask the user to connect again if the refresh is refused.

### Send the user to the consent screen

`GET https://web.cirrus.trade/oauth/authorize` in the user’s browser, with these query parameters:

**Query parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | Your app’s `cp_…` id. Unknown or disabled apps are refused on the screen. |
| `redirect_uri` | `string` | yes | One of the app’s registered URIs, character for character. Anything else is refused on the screen and never redirected to. |
| `scope` | `string` | yes | Space separated (`read orders`), lower case. Each must be one the app registered; unknown scopes and scopes the app may not request are refused (`invalid_scope`). Repeats are ignored. |
| `code_challenge` | `string` | yes | `BASE64URL(SHA256(code_verifier))` without `=` padding: exactly 43 characters of `A-Z a-z 0-9 - _`. |
| `code_challenge_method` | `S256` | yes | Only `S256`. `plain` is refused. |
| `state` | `string` | no | Returned unchanged (percent-encoded) with the code or the denial. Optional for the server, but always send a fresh random value and check it: it is your CSRF protection. |
| `response_type` | `code` | no | Not needed: the code flow is the only one. OAuth libraries that send `response_type=code` work. |

**Consent URL**

```
https://web.cirrus.trade/oauth/authorize?client_id=cp_xxxx&redirect_uri=https%3A%2F%2Fpartner.example%2Fcallback&scope=read%20orders&state=<random>&code_challenge=<BASE64URL(SHA256(verifier))>&code_challenge_method=S256
```

**Make the verifier, challenge and state**

**Python**

```python
import base64, hashlib, secrets
from urllib.parse import urlencode

CLIENT_ID = "cp_xxxx"
REDIRECT_URI = "https://partner.example/callback"

def start_connect(session: dict) -> str:
    """Returns the consent URL; keeps verifier and state in the session."""
    verifier = secrets.token_urlsafe(64)  # 86 chars from A-Z a-z 0-9 - _
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    session["pkce_verifier"] = verifier
    session["oauth_state"] = secrets.token_urlsafe(32)
    return "https://web.cirrus.trade/oauth/authorize?" + urlencode({
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "read orders",
        "state": session["oauth_state"],
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })
```

**Node.js**

```js
import crypto from 'node:crypto';

export const CLIENT_ID = 'cp_xxxx';
export const REDIRECT_URI = 'https://partner.example/callback';

/** Returns the consent URL; keeps verifier and state in the session. */
export function startConnect(session) {
  const verifier = crypto.randomBytes(48).toString('base64url'); // 64 chars
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
  session.pkceVerifier = verifier;
  session.oauthState = crypto.randomBytes(24).toString('base64url');
  const url = new URL('https://web.cirrus.trade/oauth/authorize');
  url.search = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'read orders',
    state: session.oauthState,
    code_challenge: challenge,
    code_challenge_method: 'S256'
  }).toString();
  return url.toString();
}
```

**Scopes (the same as for API keys)**

| Scope | Consent screen | Allows |
| --- | --- | --- |
| `read` | Portfolio, orders, triggers, GTT and the live stream | GET /v1/accounts, GET /v1/instruments (and .csv), GET /v1/portfolio/*, POST /v1/portfolio/refresh, /v1/stream, GET /v1/gtt, GET /v2/triggers, GET /v1/baskets, GET /v1/shared (shared with you), POST /v1/margins/orders |
| `orders` | Place, modify, cancel and convert orders and GTT | POST / PATCH / DELETE /v1/orders and /v1/orders/bracket, POST / PATCH / DELETE /v1/gtt, POST / PUT / DELETE /v1/baskets, POST /v1/positions/convert |
| `triggers` | Create, change and remove stop-loss / target triggers | POST / PATCH / DELETE /v2/triggers, and pause, resume and validate them |

- Problems with the request (missing parameter, unknown or disabled app, unregistered `redirect_uri`, bad scope, bad PKCE) are shown to the user on the consent screen. They are never sent to your `redirect_uri`, because it is not trusted until it matched.
- Scopes do not imply each other: ask for `read` as well as `orders` if you need to see what you placed.
- Connecting again (same user, same app) replaces the connection’s scopes with the new ones once the new code is exchanged. Tokens issued before keep the scopes they were issued with.

### Handle the callback

**Back at your redirect_uri**

| Query | When | Meaning |
| --- | --- | --- |
| `code` | Allow | `oc_…`, single use, valid 60 s, bound to your `client_id`, this `redirect_uri`, the scopes and the challenge. Added with `&` when your URI already has a query. |
| `state` | Allow, Deny | Your value, when you sent one. |
| `error=access_denied` | Deny | The user refused. It is the only `error` sent to your `redirect_uri`, without `error_description`. |

**Callbacks**

```
https://partner.example/callback?code=oc_xxxx&state=<your state>
https://partner.example/callback?error=access_denied&state=<your state>
```

### Exchange the code for tokens

- `POST /v1/oauth/token` on the API host, from your server only. Body form-encoded (`application/x-www-form-urlencoded`, as RFC 6749 says) or JSON (`application/json`).
- Client authentication: HTTP Basic (`Authorization: Basic base64(client_id:client_secret)`), or `client_id` and `client_secret` in the body. Basic wins when both are sent.
- Replies are plain RFC 6749 JSON, not the usual `status` / `data` / `error` envelope, with `Cache-Control: no-store`.
- `grant_type` is `authorization_code` or `refresh_token`; anything else is `unsupported_grant_type`.
- Too many failed attempts from one IP (wrong secrets, bad codes): 429 with a `Retry-After` header, in seconds. Wait that long before trying again.

**grant_type=authorization_code**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `grant_type` | `authorization_code` | yes | Exchange a code. |
| `code` | `string` | yes | The `oc_…` code from the callback. Used up by the first attempt, even a failed one. |
| `redirect_uri` | `string` | yes | Exactly the `redirect_uri` of the authorize request. |
| `code_verifier` | `string` | yes | The verifier behind `code_challenge`. |
| `client_id` | `string` | no | When not using HTTP Basic. |
| `client_secret` | `string` | no | When not using HTTP Basic. |

**Exchange the code**

**curl**

```bash
curl -X POST https://api.cirrus.trade/v1/oauth/token \
  -u "$PARTNER_CLIENT_ID:$PARTNER_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=oc_xxxx \
  --data-urlencode redirect_uri=https://partner.example/callback \
  -d code_verifier=<verifier from the session>
```

**Python**

```python
import os, secrets
import requests

TOKEN_URL = "https://api.cirrus.trade/v1/oauth/token"
AUTH = (os.environ["PARTNER_CLIENT_ID"], os.environ["PARTNER_CLIENT_SECRET"])

def finish_connect(session: dict, query: dict) -> dict:
    """Handles GET https://partner.example/callback?code=…&state=…"""
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(query.get("state", ""), expected):
        raise PermissionError("state does not match: start again")
    if "error" in query:  # access_denied: the user said no
        raise PermissionError(query["error"])
    reply = requests.post(TOKEN_URL, auth=AUTH, timeout=10, data={
        "grant_type": "authorization_code",
        "code": query["code"],
        "redirect_uri": "https://partner.example/callback",
        "code_verifier": session.pop("pkce_verifier"),
    })
    body = reply.json()
    if reply.status_code != 200:
        raise RuntimeError(f"{body['error']}: {body['error_description']}")
    return body  # store refresh_token encrypted, per user
```

**Node.js**

```js
import crypto from 'node:crypto';

export const TOKEN_URL = 'https://api.cirrus.trade/v1/oauth/token';
export const basic = Buffer.from(
  `${process.env.PARTNER_CLIENT_ID}:${process.env.PARTNER_CLIENT_SECRET}`
).toString('base64');

/** Handles GET https://partner.example/callback?code=…&state=… */
export async function finishConnect(session, query) {
  const expected = session.oauthState ?? '';
  delete session.oauthState;
  const given = String(query.state ?? '');
  if (!expected || given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    throw new Error('state does not match: start again');
  }
  if (query.error) throw new Error(query.error); // access_denied
  const reply = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { Authorization: `Basic ${basic}` },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: query.code,
      redirect_uri: 'https://partner.example/callback',
      code_verifier: session.pkceVerifier
    })
  });
  delete session.pkceVerifier;
  const body = await reply.json();
  if (!reply.ok) throw new Error(`${body.error}: ${body.error_description}`);
  return body; // store refresh_token encrypted, per user
}
```

**Token response**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `access_token` | `string` | yes | `pa_…` bearer token for the API. |
| `token_type` | `"Bearer"` | yes | Always `Bearer`. |
| `expires_in` | `integer` | yes | Seconds the access token is valid: `86400` (1 day). |
| `refresh_token` | `string` | yes | `pr_…`, valid 90 days from issue, single use. Every reply carries a new one. |
| `scope` | `string` | yes | Granted scopes, space separated (`read orders`). |

**Token response**

```
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "pa_…",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_…",
  "scope": "read orders"
}
```

**Lifetimes**

| Credential | Lifetime | Notes |
| --- | --- | --- |
| `oc_… code` | 60 s | Single use; the first exchange attempt uses it up. |
| `pa_… access token` | 1 day | Stays valid after a refresh until it expires, unless the connection is revoked. |
| `pr_… refresh token` | 90 days | Single use (a repeat within 30 s gets the same reply); each refresh returns a new 90-day one, so an app in use stays connected. |

### Refresh tokens

- Refresh tokens rotate: store the new `refresh_token` from every reply before using the new access token.
- Grace window: presenting the same refresh token again within 30 seconds of its first use (a retry after a lost reply, or two workers at once) returns the same new token pair, not an error.
- Reusing a refresh token later than that revokes the whole connection (all its tokens): it is treated as stolen.
- A refused refresh (`invalid_grant`: expired, used, or the user disconnected) means the user has to connect again through the consent screen.

**grant_type=refresh_token**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `grant_type` | `refresh_token` | yes | Get a new token pair. |
| `refresh_token` | `string` | yes | The latest `pr_…` you received. |
| `client_id` | `string` | no | When not using HTTP Basic. |
| `client_secret` | `string` | no | When not using HTTP Basic. |

**Refresh**

**curl**

```bash
curl -X POST https://api.cirrus.trade/v1/oauth/token \
  -u "$PARTNER_CLIENT_ID:$PARTNER_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=pr_xxxx
```

**Python**

```python
def refresh(refresh_token: str) -> dict:
    """Save the new refresh token first: reuse after 30 s disconnects the user."""
    reply = requests.post("https://api.cirrus.trade/v1/oauth/token", auth=AUTH, timeout=10, data={
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
    })
    body = reply.json()
    if reply.status_code == 400 and body["error"] == "invalid_grant":
        raise PermissionError("connection ended: ask the user to connect again")
    reply.raise_for_status()
    return body  # save body["refresh_token"] before using the new access token
```

**Node.js**

```js
/** Save the new refresh token first: reuse after 30 s disconnects the user. */
export async function refresh(refreshToken) {
  const reply = await fetch('https://api.cirrus.trade/v1/oauth/token', {
    method: 'POST',
    headers: { Authorization: `Basic ${basic}` },
    body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken })
  });
  const body = await reply.json();
  if (reply.status === 400 && body.error === 'invalid_grant') {
    throw new Error('connection ended: ask the user to connect again');
  }
  if (!reply.ok) throw new Error(`${body.error}: ${body.error_description}`);
  return body; // save body.refresh_token before using the new access token
}
```

### Token errors

**Token endpoint errors ({"error", "error_description"})**

| error | HTTP | When |
| --- | --- | --- |
| `invalid_request` | 400 | Unreadable body, or `code`, `redirect_uri`, `code_verifier` or `refresh_token` missing. |
| `unsupported_grant_type` | 400 | `grant_type` is neither `authorization_code` nor `refresh_token`. |
| `invalid_client` | 401 | No client credentials, unknown or disabled app, or wrong secret. With HTTP Basic the reply carries a `WWW-Authenticate: Basic` header. |
| `invalid_grant` | 400 | Code invalid, expired or already used; code issued to another app or `redirect_uri`; `code_verifier` does not match; refresh token invalid, reused after the 30 s grace (the connection is then revoked) or expired; the user disconnected the app. |
| `temporarily_unavailable` | 503 | A store is briefly down, or partner access is off on this server. Nothing was issued; retry. |

**Error response**

```
HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store

{
  "error": "invalid_grant",
  "error_description": "code_verifier does not match"
}
```

### Call the API

- Send `Authorization: Bearer pa_…` on every request. The stream accepts the same token (header on the upgrade, or `{"type": "auth", "token": "pa_…"}`).
- A partner token reaches exactly what its scopes allow (see Scopes) for the one user who connected it. App-session routes (API keys, partner apps, connections, webhooks, signal URLs, shared data) answer 403 `forbidden`. `GET /v1/user/profile` works with any scope and shows `auth.kind: "partner"`, your `client_id` and app name.
- Rate limit: 20 requests a second per app and user by default, counted separately for each connected user; over it, 429 `rate_limited` with the wait in the message. Order actions also share the per-broker-account budget.
- An expired, revoked or unknown token answers 401 `unauthorized`. Repeated failures from one IP get 429 for a minute.

**Read positions as the user**

```bash
curl "https://api.cirrus.trade/v1/portfolio/positions" \
  -H "Authorization: Bearer pa_xxxx"
```

### Connections and revocation

Users see every connected app on API access (or `GET /v1/connections`) and can disconnect any of them at any time.

**What ends a connection**

| Event | Tokens | Then |
| --- | --- | --- |
| `User disconnects` | all dead | On API access or `DELETE /v1/connections/{client_id}`: every access token stops within about 5 s (open streams close within 10 s) and refresh tokens are deleted. Send the user through consent again. |
| `Refresh token reused after 30 s` | all dead | Same as a disconnect. |
| `Password change or log out everywhere` | all dead | Every app the user connected, as if each were disconnected. The user connects again. |
| `App disabled` | all dead | For every user of the app, within about 5 s; the token endpoint answers `invalid_client`. |
| `Access token expires` | that one | After 1 day. Refresh. |
| `Refresh token expires` | no new ones | 90 days after it was issued if never used. Connect again. |

### Security checklist

- Keep `client_secret`, access and refresh tokens on your server (secret store, encrypted at rest). Never ship the secret in a browser or mobile app, never log tokens.
- Always send a fresh random `state` and compare it (constant time) before exchanging the code; drop the callback when it does not match.
- PKCE is mandatory: make a new verifier per authorization, keep it server-side, send it only to the token endpoint.
- Register exact redirect URIs over HTTPS; keep `http://localhost` ones in a separate development app.
- Ask for the fewest scopes; the user sees them. Place orders only on the user’s explicit instruction.
- Save the new refresh token from every reply; only a repeat within 30 seconds is forgiven, later reuse disconnects the user.
- Rotate the client secret if it may have leaked, and remove the old one from your servers within the 24-hour overlap.

### POST /v1/oauth/token

Access: no authentication

The partner’s server-to-server token endpoint: exchange an authorization code, or rotate a refresh token. The client authenticates with `client_id` + `client_secret` in the form body or with HTTP Basic.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | no | Optional HTTP Basic client authentication: `Basic base64(client_id:client_secret)`. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `grant_type` | `string` | yes | `authorization_code` or `refresh_token`; missing or anything else is `unsupported_grant_type`. One of: `authorization_code`, `refresh_token`. |
| `code` | `string \| null` | no | `authorization_code`: the code from the redirect (`oc_...`). |
| `redirect_uri` | `string \| null` | no | `authorization_code`: the same `redirect_uri` the code was issued for. |
| `code_verifier` | `string \| null` | no | `authorization_code`: the PKCE verifier (43-128 characters of `A-Z a-z 0-9 - . _ ~`) whose S256 challenge was sent to authorize. |
| `refresh_token` | `string \| null` | no | `refresh_token`: the latest refresh token (`pr_...`). |
| `client_id` | `string \| null` | no | The app's client id (unless sent with HTTP Basic). |
| `client_secret` | `string \| null` | no | The app's client secret (unless sent with HTTP Basic). Left out by a dynamically registered client (`token_endpoint_auth_method: none`). |
| `resource` | `string \| null` | no | RFC 8707 resource (optional): when sent, it must be the one the user authorized (the MCP server URL), else `invalid_grant`. |

- Send the body form-encoded (`application/x-www-form-urlencoded`, as RFC 6749 specifies) or as JSON. Replies are not the REST envelope: errors are `{"error", "error_description"}`.

```bash
curl -X POST "https://api.cirrus.trade/v1/oauth/token" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "oc_3zb5bRr1InWe3ipR7EXinezj5kpxNWDpk9piRJNB",
    "redirect_uri": "https://partner.example/callback",
    "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
    "client_id": "cp_3Oq3YXcuepv3Qx5Z2iC2",
    "client_secret": "cps_dG69y0yLbVBxwXqsWbWbrQk1aP0sEuT7mYw2HcN4vZ8LxJfR"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `access_token` | `string` | yes | Access token (`pa_...`); send it as `Authorization: Bearer <access_token>`. |
| `token_type` | `string` | yes | Always `Bearer`. |
| `expires_in` | `integer (int64)` | yes | Seconds until the access token expires (86400: one day). |
| `refresh_token` | `string` | yes | Refresh token (`pr_...`), valid for 90 days and single use: every refresh returns a new one. Presenting a used refresh token again revokes the whole connection. |
| `scope` | `string` | yes | Granted scopes, space separated (OAuth convention), e.g. `read orders`. |

**Example: Code exchanged for an access and a refresh token (200)**

```json
{
  "access_token": "pa_skuF0MRH41ytp5qLfnrdbtWjm4mXpHG8VsfirtfNG9T25WFF",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_X0I0VMDKFSkae48wpL4Yd6PSF7mLh12LbmwW3vHeZpcmgMf83zEOgHlj",
  "scope": "read orders"
}
```

**Example: Refresh token rotated: new access and refresh tokens (200)**

```json
{
  "access_token": "pa_K4OtjRq36Cl0on8mGbl04TAR3jkkotH0u0GbVq9wUvPNfnbY",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_u7YF0HEew41R9LKS8u1GVVmksir6giVn5Cfk6mUF3Sz0U4cyhCVXGkdh",
  "scope": "read orders"
}
```

**Example: An authorization code used twice is refused (400)**

```json
{
  "error": "invalid_grant",
  "error_description": "code is invalid, expired or already used"
}
```

**Example: Only authorization_code and refresh_token are supported (400)**

```json
{
  "error": "unsupported_grant_type",
  "error_description": "unsupported grant_type `password`; use authorization_code or refresh_token"
}
```

**Example: Wrong client secret (401)**

```json
{
  "error": "invalid_client",
  "error_description": "client authentication failed"
}
```

**Example: Partner access is off on this server (503)**

```json
{
  "error": "temporarily_unavailable",
  "error_description": "storage unavailable: partner access is not enabled"
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | Unreadable body or a missing field (`code`, `redirect_uri`, `code_verifier`, `refresh_token`). |
| 400 | `unsupported_grant_type` | `grant_type` missing or not `authorization_code` / `refresh_token`. |
| 400 | `invalid_grant` | The code is invalid, expired or already used, was issued to another client or `redirect_uri`, or `code_verifier` does not match; the refresh token is invalid, already used, expired, or the user revoked the connection; `resource` is not the one the user authorized. |
| 401 | `invalid_client` | No client credentials, an unknown, disabled or deleted app, a wrong secret, or a secret sent by a public client. |
| 429 | `slow_down` | Too many failed attempts from this source; retry after `Retry-After` seconds. |
| 503 | `temporarily_unavailable` | Partner access is off on this server or its store is temporarily unavailable; retry. |

## Scopes

A key or partner token can call only what its scopes allow; scopes do not imply each other (orders does not include read). Your own signed-in app session can call everything.

| Scope | Grants |
| --- | --- |
| `read` | GET /v1/accounts, GET /v1/instruments (and .csv), GET /v1/portfolio/*, POST /v1/portfolio/refresh, /v1/stream, GET /v1/gtt, GET /v2/triggers, GET /v1/baskets, GET /v1/shared (shared with you), POST /v1/margins/orders |
| `orders` | POST / PATCH / DELETE /v1/orders and /v1/orders/bracket, POST / PATCH / DELETE /v1/gtt, POST / PUT / DELETE /v1/baskets, POST /v1/positions/convert |
| `triggers` | POST / PATCH / DELETE /v2/triggers, and pause, resume and validate them |
| `strategies` | Create, edit, backtest and paper-deploy strategies; pause and stop them. Partner apps may be granted it. |
| `strategies_live` | Start and stop strategies that trade real money. Partner apps may be granted it; treat it like orders. |
| `strategies_signal` | Send entry and exit signals to your deployments. Your own API keys only. |
| `webhooks` | List, create, change and remove your outbound webhooks. Your own API keys only. |
| `notifications` | Read your notifications and change where and which ones are sent. Your own API keys only. |
| `watchlists` | List, create and edit your watchlists. Your own API keys only. |
| `app` | Everything else: API keys, partner apps, OAuth consent, connections, webhooks, signal URLs, shared data. Never reachable with a key or partner token. GET /v1/user/profile needs no scope. |

A missing scope answers 403 `forbidden` with the scope named in the message.

## Errors & idempotency

Success is {"status": "success", "data": …, "error": null}. Errors keep the same shape: branch on error.code, show error.message to people.

**Common error codes**

| Code | HTTP | Meaning |
| --- | --- | --- |
| `malformed_json` | 400 | Body is not valid JSON for this route. |
| `invalid_request` | 422 | Validation failed; `field` names the input, e.g. orders[0].price. |
| `unauthorized` | 401 | Missing, invalid or expired credential. |
| `forbidden` | 403 | Credential lacks the scope, key used from an IP outside its allowlist, or app-only. |
| `oms_not_enabled` | 403 | Your account is not served by this API yet. |
| `account_not_found` | 404 | Account id is not one of yours. |
| `idempotency_key_required` | 400 | Idempotency-Key header missing. |
| `invalid_idempotency_key` | 400 | Not 1-128 printable characters. |
| `request_in_progress` | 409 | The first request with this key is still running. |
| `idempotency_key_reused` | 422 | Key already used with a different body. |
| `rate_limited` | 429 | Rate limit reached; message says when to retry. |
| `service_unavailable` | 503 | A dependency is down; nothing was done. Retry. |

### Idempotency-Key

- Required on `POST /v1/orders` and `POST /v1/orders/bracket`: 1-128 printable characters, a new UUID per request. Keys are per user.
- A retry with the same key and the same body returns the original response unchanged (for 24 hours), with the header `idempotent-replayed: true`. Nothing is placed twice.
- While the first request is still running a retry gets 409 `request_in_progress`; the same key with a different body gets 422 `idempotency_key_reused`.
- After a timeout or a slice with status `unknown`, retry with the same key, never a new one.

**Error envelope**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "price must be > 0 for LIMIT orders",
    "field": "orders[0].price"
  }
}
```

## Rate limits

Defaults shown; a deployment can change them.

- Per API key: 20 requests a second by default. Over it, 429 `rate_limited` at once; the message says how many ms to wait.
- Per partner app and user: the same budget, counted separately for each user who connected the app.
- Failed credentials: after about 30 failures in a minute from one IP, that IP gets 429 until the minute is up.
- Per broker account: orders, modifies, cancels, GTT and convert share one budget (10 a second by default). A burst waits up to 2 s for a slot instead of failing.
- Portfolio reads come from snapshots and never spend the broker budget.
- Signal webhook URLs each have their own limit (429 over it).

## Build with AI

Coding agents write better integrations when they read the reference instead of guessing. Give yours the machine-readable docs and a rules file for your tool.

1. Give the agent the reference: `llms-full.txt` for reading, `openapi.json` for exact schemas. Every docs section is also served as Markdown at `/docs/<section>.md`.
2. Add the rules file for your tool (below) to your repository. Every file carries the same rules; only the file name and header differ.
3. To let an AI assistant act on your account instead of writing code, connect it over MCP: see [Connect an AI agent](https://web.cirrus.trade/docs/connect-agent.md).
4. Keep your API key in an environment variable, and make the agent ask you before it places, modifies or cancels a live order.

- [llms.txt](https://web.cirrus.trade/llms.txt): Index of the docs as Markdown pages, in the llmstxt.org format.
- [llms-full.txt](https://web.cirrus.trade/llms-full.txt): The whole reference in one Markdown file.
- [openapi.json](https://web.cirrus.trade/docs/openapi.json): OpenAPI 3.1 spec: every endpoint, schema, enum value, stream message and webhook body. Live from the API: https://api.cirrus.trade/v1/openapi.json.

**Starter prompt**

```
Read https://web.cirrus.trade/llms-full.txt and the OpenAPI spec at
https://web.cirrus.trade/docs/openapi.json, and follow the Cirrus API rules in this repository.
Write a script that lists my accounts and prints open positions.
Read the API key from $CIRRUS_API_KEY. Do not place any orders.
```

### Rules files

- **Claude Code**: `CLAUDE.md`, download https://web.cirrus.trade/docs/ai/claude-md
- **Claude Code**: `.claude/skills/cirrus-api/SKILL.md`, download https://web.cirrus.trade/docs/ai/claude-skill
- **Codex / AGENTS.md**: `AGENTS.md`, download https://web.cirrus.trade/docs/ai/agents-md
- **Cursor**: `.cursor/rules/cirrus-api.mdc`, download https://web.cirrus.trade/docs/ai/cursor
- **GitHub Copilot**: `.github/copilot-instructions.md`, download https://web.cirrus.trade/docs/ai/copilot
- **Windsurf**: `.windsurf/rules/cirrus-api.md`, download https://web.cirrus.trade/docs/ai/windsurf
- **Gemini CLI**: `GEMINI.md`, download https://web.cirrus.trade/docs/ai/gemini
- **Cline / Roo**: `.clinerules/cirrus-api.md`, download https://web.cirrus.trade/docs/ai/cline
- **Aider**: `CONVENTIONS.md`, download https://web.cirrus.trade/docs/ai/aider
- **Aider**: `.aider.conf.yml`, download https://web.cirrus.trade/docs/ai/aider-conf
- **Continue**: `.continue/rules/cirrus-api.md`, download https://web.cirrus.trade/docs/ai/continue
- **Kiro**: `.kiro/steering/cirrus-api.md`, download https://web.cirrus.trade/docs/ai/kiro

### The rules

#### Cirrus API integration rules

Follow these rules when writing or reviewing code that calls the Cirrus trading API: place and manage orders across broker accounts, read the portfolio, stream live updates and receive signed webhooks. Human docs: https://web.cirrus.trade/docs.

##### Essentials

- REST base URL: `https://api.cirrus.trade/v1`. WebSocket: `wss://api.cirrus.trade/v1/stream`.
- Machine-readable spec (OpenAPI 3.1): https://web.cirrus.trade/docs/openapi.json (the API also serves it at https://api.cirrus.trade/v1/openapi.json). Full reference as Markdown: https://web.cirrus.trade/llms-full.txt. Check endpoints, fields and enum values there; never invent them.
- Auth: send `Authorization: token <key_id>:<secret>` (`ck_…:cs_…`) on every request. Partner apps acting for other users send `Authorization: Bearer pa_…` (OAuth 2 authorization code + PKCE).
- Read the key from the `CIRRUS_API_KEY` environment variable. Fail fast with a clear message when it is missing.
- Every response is `{"status", "data", "error"}`. Branch on `error.code`; show `error.message` to people. Keep the `x-request-id` response header in logs.
- All JSON keys are snake_case, in requests and responses, including webhook and stream payloads. Enum values are upper case (`BUY`, `LIMIT`, `MIS`). Unknown body fields are rejected, not ignored: send only documented fields.
- A key only reaches what its scopes allow: `read`, `orders`, `triggers`. A missing scope answers 403 `forbidden`.

##### Placing orders

- `POST /v1/orders` and `POST /v1/orders/bracket` require an `Idempotency-Key` header: a new UUID for each new order request. Reuse a key only to retry that same request with the same body; a different body with the same key fails with 422 `idempotency_key_reused`.
- Results are per account and per slice: a 200 can mix `accepted`, `rejected` and `unknown`. Check every entry of `results[]` and the `accepted` / `rejected` / `unknown` counts; never treat the call as all-or-nothing.
- `unknown` (and a timeout) means the broker may or may not have the order. Never retry blindly with a new key: retry with the same `Idempotency-Key`, or wait for the order on the stream or a webhook (match on `order_tag`).
- 409 `request_in_progress`: the first request with that key is still running; wait, then retry with the same key.
- Before trading, call `GET /v1/accounts` and use only accounts whose `status` is `ready`.
- Use `POST /v1/margins/orders` (same body, places nothing) to check margin first. Each figure says its `source`: `broker`, or `estimate` (from exchange risk files when the broker cannot quote; the broker may charge more). Compare an account's funds with its `accounts[]` total (hedge-aware); a null total means some order had no figure, so do not add up the rows yourself.

##### Rate limits and retries

- Default budget: 20 requests a second per API key (and per partner app and user). Over it: 429 `rate_limited`; the message says how many ms to wait. Back off; do not hammer.
- Order actions (place, modify, cancel, GTT, convert) share 10 a second per broker account; bursts queue for up to 2 s.
- Portfolio reads come from snapshots and never spend the broker budget: prefer them to repeated broker calls.
- Signal URLs answer with `X-RateLimit-Limit`, `X-RateLimit-Remaining` and, on 429, `Retry-After`.
- 503 `service_unavailable`: nothing was done; retry with exponential backoff and jitter. Other 4xx errors are final: fix the request.

##### Getting updates: stream, webhooks or polling

- Stream (`wss://api.cirrus.trade/v1/stream`, `read` scope): for long-running processes. Authenticate with the `Authorization` header or a first `{"type": "auth", "token": …}` message within 5 s; never put tokens in the URL. You get a snapshot per account and kind, `snapshot_done`, then `order_update` and `portfolio` events with full state (never diffs). Reconnect with backoff after any close except 4001; send `{"type": "resync"}` for fresh snapshots.
- Webhooks: for servers that should be called back (up to 5 HTTPS endpoints, managed on API access). Events: `order_update`, `trade`, `account_alert`, `positions`. Answer 2xx within 5 s, handle the work asynchronously, deduplicate on the event `id`, and expect events out of order (keep the order with the highest `filled_qty` / latest state).
- Polling: for scripts and cron jobs. `GET /v1/portfolio/{kind}` (orders, positions, holdings, trades, margins); `POST /v1/portfolio/refresh` asks for a fresh broker read. Accounts without data are listed in `missing[]`, never silently left out.

##### Verifying webhooks

- Deliveries follow the Standard Webhooks specification (headers `webhook-id`, `webhook-timestamp`, `webhook-signature`); prefer an official Standard Webhooks library.
- Signature: `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))`, where `key` is the base64-decoded part of the secret after `whsec_`. `webhook-signature` holds one or more space-separated `v1,<base64>` entries (two during a secret rotation): accept when any matches.
- Verify the raw body before parsing JSON, compare in constant time, reject timestamps more than 5 minutes off, and ignore ids already handled.
- Keep the webhook secret in an environment variable, like the API key.

##### Instruments

- Every order needs an `instrument_token`. Look it up with `GET /v1/instruments?q=…` (plus exact filters `exchange`, `instrument`, `expiry`, `strike`, `option_type`) or `GET /v1/instruments/{instrument_token}`.
- For many lookups, download `https://api.cirrus.trade/v1/instruments.csv` once a day after 08:40 IST (send `If-None-Match` with the saved `ETag` to get 304 while unchanged) and search locally.
- Never hardcode tokens for derivatives: contracts expire and new ones are listed daily.

##### Partner apps (OAuth), only when acting for other users

- Flow: authorization code + PKCE `S256` only (no `plain`, no client credentials or implicit grant). Guide: https://web.cirrus.trade/docs/oauth.md.
- Send the user's browser to `https://web.cirrus.trade/oauth/authorize` with `client_id` (`cp_…`), `redirect_uri` (exactly as registered), `scope` (space separated: `read`, `orders`, `triggers`), a random `state`, `code_challenge` and `code_challenge_method=S256`. Keep the verifier and state server-side and check `state` on the callback; a denial comes back as `error=access_denied`.
- Exchange the `oc_…` code (single use, 60 s) at `POST https://api.cirrus.trade/v1/oauth/token` with `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier` and HTTP Basic client credentials, form-encoded. Replies are plain RFC 6749 JSON (`access_token`, `token_type`, `expires_in`, `refresh_token`, `scope`), not the envelope.
- Access tokens `pa_…` last 1 day: `Authorization: Bearer pa_…`. Refresh tokens `pr_…` last 90 days and are single use: save the new one from every refresh. A repeat within 30 s returns the same pair; reusing one later revokes the user's whole connection. `invalid_grant` on refresh means the user must connect again.
- The client secret and all tokens stay on the server, never in a browser or mobile app, never in logs.

##### Safety rules for the agent

- Never hardcode, print, log or commit the API key or webhook secrets. Read them from the environment (`CIRRUS_API_KEY`); keep `.env` files out of version control.
- Never place, modify or cancel live orders, and never run code that would, without the user's explicit confirmation for that specific action. Default to read-only calls while exploring; ask before switching to a key with the `orders` scope.
- Prefer a key with only the scopes the task needs. Test order code with the smallest quantity or a paper account first.
- If a response is `unknown` or the network fails mid-order, stop and report it; do not loop retries.

## Connect an AI agent

Let an AI assistant (Claude, ChatGPT, Cursor and others) read your Cirrus account and act on it through the Cirrus MCP server. You approve it once, set what it may do, and can disconnect it at any time.

### The server URL

Every client needs the same URL. It signs you in with Cirrus in your browser (OAuth).

**MCP server URL**

```
https://mcp.cirrus.trade
```

A client marked Not tested yet follows the steps its maker documents. It has not yet been tested end to end against Cirrus.

### Your client

### Claude

Not tested yet. On claude.ai or Claude Desktop, open Settings, then Connectors, and add a custom connector. Paste the URL above and sign in to Cirrus when the browser opens.

### Claude Code

Not tested yet. Add the server, then run `/mcp` inside Claude Code to sign in.

**Claude Code**

```bash
claude mcp add --transport http cirrus https://mcp.cirrus.trade
```

### ChatGPT

Not tested yet. Add the URL as an app or connector (a paid plan with developer mode may be needed), then sign in to Cirrus when asked.

### Grok

Not tested yet. Open the connectors page on grok.com and add the URL as a remote server.

### OpenClaw

Not tested yet. Register the server with a streamable-http transport, then sign in. Run `openclaw mcp set --help` if your version takes different arguments.

**OpenClaw**

```bash
openclaw mcp set cirrus '{"url":"https://mcp.cirrus.trade","transport":"streamable-http"}'
openclaw mcp login cirrus
```

### Cursor

Not tested yet. Add a remote MCP server by URL in Cursor’s MCP settings, then sign in.

### VS Code / Copilot

Not tested yet. Add an HTTP MCP server by URL (MCP: Add Server), then sign in.

### Codex CLI

Not tested yet. Add a remote MCP server by URL in your Codex configuration, then sign in.

### Gemini CLI

Not tested yet. Add a remote HTTP MCP server by URL in your Gemini settings, then sign in.

### Windsurf

Not tested yet. Add a remote MCP server by URL in Windsurf’s MCP settings, then sign in.

### What the agent can do

- **Read:** your profile, accounts, holdings, positions, orders, trades and activity; instrument search and details.
- **Orders:** margin check, place, modify, cancel, brackets, convert, exit; baskets; GTT.
- **Triggers:** list, create, modify, pause, resume and delete alerts, exits and loss limits.
- **Strategies:** build, validate, dry-run, version and clone strategies; run backtests; deploy, pause, resume, stop and exit; set risk limits.

### Limits you set

The consent screen asks for these before the agent connects. Each can be changed later.

- **Paper only (on by default):** The agent can act only on paper-trading accounts. Turn it off to unlock real accounts.
- **Accounts:** Which accounts it may trade or deploy on. None until you pick.
- **Max order value:** The largest order it may place or modify, in rupees.
- **Orders per day:** How many orders it may place in one day (IST).
- **Live strategies:** Whether it may deploy or take a strategy live. Off by default; offered only when paper only is off.
- **Max strategies:** How many strategies started by this connection may be active at once. Default 1.

- A limit is checked when the agent asks. If the agent breaks one, the request fails with `forbidden` and a message naming the limit; it shows in your activity.
- Orders that fire later (GTT at the broker, triggers, a running strategy’s cycles) are not checked against these limits again. A strategy is bounded by its accounts, live setting, max strategies, its own caps and your risk limits.
- Exits, cancels, pause, stop and stop-all are never blocked by a limit.
- Change limits later in API Access → Connected apps.

### No market data

- There are no market data tools. The agent cannot stream quotes or fetch candles; it sees prices only through your orders, positions and the instrument search.
- Treat what comes back (orders, notes, market text) as data, not instructions. Review live orders and strategies yourself.

### See what it did

- Every change an agent makes appears in your activity with who did it, for example “By Claude · via MCP · Place order”, and a badge that says how far Cirrus could confirm it.
- **Verified**: Cirrus recognised the app from where it signs in (such as claude.ai). **On your computer**: an app running on your computer, shown by the name it gave itself. **Unconfirmed app**: Cirrus could not confirm who runs it; check where it signs in before you approve it.

### Disconnect

- Open API Access → Connected apps and choose Disconnect. The agent’s next call fails.
- Disconnecting stops new commands only. Orders, GTT, triggers and strategies the agent created stay as they are, including live ones. They are listed under that connection, each with its normal controls, so you decide what to cancel, pause or exit.

### Use an API key instead

- For command-line clients, scripts and CI you can skip the browser sign-in and send your own API key as `Authorization: token ck_…:cs_…` (key id, colon, secret). The key keeps its own scopes and IP allowlist; the connection limits above do not apply, so give it only the scopes it needs.
- A key with an IP allowlist works from your own machine and is refused from a vendor’s cloud (such as claude.ai or ChatGPT). Delete the key to disconnect.

**Claude Code with an API key**

```bash
claude mcp add --transport http cirrus https://mcp.cirrus.trade \
  --header "Authorization: token $CIRRUS_API_KEY"
```

Keep the key in `$CIRRUS_API_KEY` or your secret store, never in a repository.

## OpenAPI spec

An OpenAPI 3.1 description of every route, enum, error code, stream message and webhook body. Use it to generate clients or to give a coding agent exact schemas. These docs also serve a copy at `/docs/openapi.json` with this API’s host filled in.

### GET /v1/openapi.json

Access: no authentication

The OpenAPI document. No credential needed. Cached for five minutes; send `If-None-Match` with the last `ETag` to revalidate.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `If-None-Match` | `string` | no | An `ETag` from an earlier response. |

```bash
curl -o openapi.json https://api.cirrus.trade/v1/openapi.json
```

**Response `data` (200):** no body.

## Accounts

Your linked broker accounts: whether each can trade right now, what its setup lacks and how order updates arrive. Credentials are never returned.

**status (first match wins)**

| Value | Trading | When |
| --- | --- | --- |
| `unsupported` | no trading | This broker cannot trade here yet. |
| `setup_incomplete` | no trading | A `required` field is missing; see `missing`. |
| `login_required` | no trading | The broker refused the login and none succeeded since, or there is no login from today (every broker except Paper). |
| `ready` | trading | Can trade. |

**updates: how order updates arrive now**

| Value | Speed | When |
| --- | --- | --- |
| `stream` | live | Updates arrive on the broker socket. |
| `postback` | live | The broker posts each update to us. |
| `polling` | every few s | The order book is read periodically: the socket is down or was refused, or a postback broker lacks the API secret needed to verify postbacks. |

Poll `GET /v1/accounts` before trading, or watch `account_alert` events on a webhook: a login that expires or live updates that stop are reported there as they happen.

### GET /v1/accounts

Scope: `read`

Every account of yours, sorted by account id.

- `missing` lists labels only, never values. `required: true` blocks trading; `required: false` turns one feature off (for example postbacks).

```bash
curl https://api.cirrus.trade/v1/accounts \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes |  |
| `items[].account` | `string` | yes | Account id (the broker's client code), as used in an order's `accounts` and the `account` filters. |
| `items[].broker` | `string` | yes | Broker id (`zerodha`, `upstox`, `paper`, ...). |
| `items[].broker_name` | `string \| null` | yes | Broker display name; null for an unknown broker. |
| `items[].tag` | `string \| null` | yes | The user's own label for the account. |
| `items[].multiplier` | `number (double)` | yes | Quantity multiplier applied when an order is placed in this account. |
| `items[].supported` | `boolean` | yes | Whether the broker is supported for trading. |
| `items[].status` | `string` | yes | Whether the account can trade now: `ready`, `login_required` (log in to the broker again), `setup_incomplete` (a required field is missing, see `missing`) or `unsupported` (broker not supported for trading). One of: `ready`, `login_required`, `setup_incomplete`, `unsupported`. |
| `items[].missing` | `object[]` | yes | Fields the account lacks (empty when complete). |
| `items[].missing[].field` | `string` | yes | snake_case id, e.g. `api_secret`. |
| `items[].missing[].label` | `string` | yes | As shown in the app, e.g. `API Secret`. |
| `items[].missing[].required` | `boolean` | yes | `true`: the account cannot trade without it; `false`: one feature is off. |
| `items[].updates` | `string \| null` | yes | Null for an unsupported broker. One of: `stream`, `postback`, `polling`. |
| `items[].static_ip` | `string \| null` | yes | The account's static IP as the broker sees it (what the user registers): an IPv6 seat's `whitelist_ip`, else `ip`. Null when none is assigned. |
| `items[].last_login_at` | `string (date-time) \| null` | yes | Last broker login. |
| `items[].session_expires_at` | `string (date-time) \| null` | yes | When the broker session expires; null when there is no active session (always null for paper accounts, which need none). |
| `items[].seat_assigned` | `boolean \| null` | yes | Whether the account has a trading seat assigned (null when unknown). |
| `items[].last_update_at` | `string (date-time) \| null` | yes | When the order book was last read from the broker. |
| `items[].protection` | `object` | yes | Which broker-held protection orders in this account can use (the `protection.route` values besides `auto` and `trigger`, which every account has). All `false` for an unsupported broker. |
| `items[].protection.bracket` | `boolean` | yes | Bracket orders (entry + stop-loss + target), intraday only. |
| `items[].protection.cover` | `boolean` | yes | Cover orders (entry + stop-loss), intraday only. |
| `items[].protection.gtt` | `boolean` | yes | GTT protection for carryforward / delivery positions. |
| `items[].protection.gtt_two_leg` | `boolean` | yes | A GTT may hold a stop-loss and a target together; `false`: one leg. |
| `items[].warnings` | `string[]` | yes | What needs fixing on the account although it may be `ready`, as snake_case codes (empty when nothing). `static_ip_not_registered`: the broker refused an order because no static IP is registered for the account's API app; register `static_ip` at the broker (cleared by the account's next accepted order). More codes may be added: show an unknown one as is. |

**Example: Ready, login-required and incomplete accounts (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "account": "5P1",
        "broker": "5paisa",
        "broker_name": "5paisa",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "login_required",
        "missing": [
          {
            "field": "static_ip",
            "label": "Static IP",
            "required": false
          }
        ],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": false,
          "cover": false,
          "gtt": false,
          "gtt_two_leg": false
        },
        "warnings": []
      },
      {
        "account": "MO1",
        "broker": "motilaloswal",
        "broker_name": "Motilal Oswal",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "login_required",
        "missing": [
          {
            "field": "static_ip",
            "label": "Static IP",
            "required": false
          }
        ],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": false,
          "cover": false,
          "gtt": false,
          "gtt_two_leg": false
        },
        "warnings": []
      },
      {
        "account": "P1",
        "broker": "paper",
        "broker_name": "Paper",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "ready",
        "missing": [],
        "updates": "postback",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": true,
          "cover": true,
          "gtt": true,
          "gtt_two_leg": true
        },
        "warnings": []
      },
      {
        "account": "P2",
        "broker": "paper",
        "broker_name": "Paper",
        "tag": null,
        "multiplier": 2,
        "supported": true,
        "status": "ready",
        "missing": [],
        "updates": "postback",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": true,
          "cover": true,
          "gtt": true,
          "gtt_two_leg": true
        },
        "warnings": []
      },
      {
        "account": "PF1",
        "broker": "pocketful",
        "broker_name": "Pocketful",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "login_required",
        "missing": [],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": true,
          "cover": true,
          "gtt": true,
          "gtt_two_leg": false
        },
        "warnings": []
      },
      {
        "account": "TJ1",
        "broker": "tradejini",
        "broker_name": "Tradejini",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "ready",
        "missing": [
          {
            "field": "static_ip",
            "label": "Static IP",
            "required": false
          }
        ],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": "2026-10-12T11:31:53Z",
        "seat_assigned": null,
        "last_update_at": "2026-10-12T10:31:53.299825Z",
        "protection": {
          "bracket": true,
          "cover": true,
          "gtt": true,
          "gtt_two_leg": true
        },
        "warnings": []
      },
      {
        "account": "U1",
        "broker": "upstox",
        "broker_name": "Upstox",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "login_required",
        "missing": [],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": false,
          "cover": false,
          "gtt": false,
          "gtt_two_leg": false
        },
        "warnings": []
      },
      {
        "account": "Z1",
        "broker": "zerodha",
        "broker_name": "Zerodha",
        "tag": null,
        "multiplier": 1,
        "supported": true,
        "status": "setup_incomplete",
        "missing": [
          {
            "field": "api_key",
            "label": "API Key",
            "required": true
          },
          {
            "field": "static_ip",
            "label": "Static IP",
            "required": false
          }
        ],
        "updates": "stream",
        "static_ip": null,
        "last_login_at": null,
        "session_expires_at": null,
        "seat_assigned": null,
        "last_update_at": null,
        "protection": {
          "bracket": false,
          "cover": false,
          "gtt": true,
          "gtt_two_leg": true
        },
        "warnings": []
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/accounts/{account}

Scope: `read`

One account, plus `health`: what the order engine last reported for it.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id. |

- 404 `account_not_found` for an id that is not one of yours.

```bash
curl https://api.cirrus.trade/v1/accounts/<account_id> \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (the broker's client code), as used in an order's `accounts` and the `account` filters. |
| `broker` | `string` | yes | Broker id (`zerodha`, `upstox`, `paper`, ...). |
| `broker_name` | `string \| null` | yes | Broker display name; null for an unknown broker. |
| `tag` | `string \| null` | yes | The user's own label for the account. |
| `multiplier` | `number (double)` | yes | Quantity multiplier applied when an order is placed in this account. |
| `supported` | `boolean` | yes | Whether the broker is supported for trading. |
| `status` | `string` | yes | Whether the account can trade now: `ready`, `login_required` (log in to the broker again), `setup_incomplete` (a required field is missing, see `missing`) or `unsupported` (broker not supported for trading). One of: `ready`, `login_required`, `setup_incomplete`, `unsupported`. |
| `missing` | `object[]` | yes | Fields the account lacks (empty when complete). |
| `missing[].field` | `string` | yes | snake_case id, e.g. `api_secret`. |
| `missing[].label` | `string` | yes | As shown in the app, e.g. `API Secret`. |
| `missing[].required` | `boolean` | yes | `true`: the account cannot trade without it; `false`: one feature is off. |
| `updates` | `string \| null` | yes | Null for an unsupported broker. One of: `stream`, `postback`, `polling`. |
| `static_ip` | `string \| null` | yes | The account's static IP as the broker sees it (what the user registers): an IPv6 seat's `whitelist_ip`, else `ip`. Null when none is assigned. |
| `last_login_at` | `string (date-time) \| null` | yes | Last broker login. |
| `session_expires_at` | `string (date-time) \| null` | yes | When the broker session expires; null when there is no active session (always null for paper accounts, which need none). |
| `seat_assigned` | `boolean \| null` | yes | Whether the account has a trading seat assigned (null when unknown). |
| `last_update_at` | `string (date-time) \| null` | yes | When the order book was last read from the broker. |
| `protection` | `object` | yes | Which broker-held protection orders in this account can use (the `protection.route` values besides `auto` and `trigger`, which every account has). All `false` for an unsupported broker. |
| `protection.bracket` | `boolean` | yes | Bracket orders (entry + stop-loss + target), intraday only. |
| `protection.cover` | `boolean` | yes | Cover orders (entry + stop-loss), intraday only. |
| `protection.gtt` | `boolean` | yes | GTT protection for carryforward / delivery positions. |
| `protection.gtt_two_leg` | `boolean` | yes | A GTT may hold a stop-loss and a target together; `false`: one leg. |
| `warnings` | `string[]` | yes | What needs fixing on the account although it may be `ready`, as snake_case codes (empty when nothing). `static_ip_not_registered`: the broker refused an order because no static IP is registered for the account's API app; register `static_ip` at the broker (cleared by the account's next accepted order). More codes may be added: show an unknown one as is. |
| `health` | `object \| null` | yes | Null when no live worker has reported for the account recently. |
| `health.stream` | `string \| null` | yes | The broker's live order-update stream: `up`, `down` or `none` (the broker pushes nothing for this account). |
| `health.polls` | `boolean \| null` | yes | `true` while the order book is polled. |
| `health.last_book_read_at` | `string (date-time) \| null` | yes | When the order book was last read from the broker. |
| `health.open_orders` | `integer (int64) \| null` | yes | Open orders at that read. |
| `health.last_error_kind` | `string \| null` | yes | Kind of the last failed broker call. |
| `health.last_error_message` | `string \| null` | yes | What that call's failure means, in words safe to show the user (e.g. the broker's rejection reason); never request details. |
| `health.last_error_at` | `string (date-time) \| null` | yes | When that call failed. |

**Example: No session and no recent health report (200)**

```json
{
  "status": "success",
  "data": {
    "account": "U1",
    "broker": "upstox",
    "broker_name": "Upstox",
    "tag": null,
    "multiplier": 1,
    "supported": true,
    "status": "login_required",
    "missing": [],
    "updates": "stream",
    "static_ip": null,
    "last_login_at": null,
    "session_expires_at": null,
    "seat_assigned": null,
    "last_update_at": null,
    "protection": {
      "bracket": false,
      "cover": false,
      "gtt": false,
      "gtt_two_leg": false
    },
    "warnings": [],
    "health": null
  },
  "error": null
}
```

**Example: The broker refused an order: no static IP registered for the API app (200)**

```json
{
  "status": "success",
  "data": {
    "account": "Z1",
    "broker": "zerodha",
    "broker_name": "Zerodha",
    "tag": null,
    "multiplier": 1,
    "supported": true,
    "status": "setup_incomplete",
    "missing": [
      {
        "field": "api_key",
        "label": "API Key",
        "required": true
      },
      {
        "field": "static_ip",
        "label": "Static IP",
        "required": false
      }
    ],
    "updates": "stream",
    "static_ip": null,
    "last_login_at": null,
    "session_expires_at": null,
    "seat_assigned": null,
    "last_update_at": null,
    "protection": {
      "bracket": false,
      "cover": false,
      "gtt": true,
      "gtt_two_leg": true
    },
    "warnings": [
      "static_ip_not_registered"
    ],
    "health": null
  },
  "error": null
}
```

**Example: A live account with its health (200)**

```json
{
  "status": "success",
  "data": {
    "account": "TJ1",
    "broker": "tradejini",
    "broker_name": "Tradejini",
    "tag": null,
    "multiplier": 1,
    "supported": true,
    "status": "ready",
    "missing": [
      {
        "field": "static_ip",
        "label": "Static IP",
        "required": false
      }
    ],
    "updates": "stream",
    "static_ip": null,
    "last_login_at": null,
    "session_expires_at": "2026-10-06T06:33:54Z",
    "seat_assigned": null,
    "last_update_at": "2026-10-06T05:33:54.471247Z",
    "protection": {
      "bracket": true,
      "cover": true,
      "gtt": true,
      "gtt_two_leg": true
    },
    "warnings": [],
    "health": {
      "stream": "up",
      "polls": false,
      "last_book_read_at": "2026-10-06T05:33:54.471247Z",
      "open_orders": 2,
      "last_error_kind": null,
      "last_error_message": null,
      "last_error_at": null
    }
  },
  "error": null
}
```

**Example: Not one of the caller's accounts (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "No linked broker account with this id",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Instruments

Everything you can trade, from the daily instrument master, with the `instrument_token` every order needs. The list is refreshed every day after 08:40 IST; until then the previous day is served.

Daily file, with your API key: https://api.cirrus.trade/v1/instruments.csv

- Contracts listed that morning (new weekly expiries, new strikes) appear after 08:40 IST.
- Older integrations may send `cirrus_token` and `cirrus_tag`: both are accepted as other names for `instrument_token` and `order_tag`, and responses carry both.

### GET /v1/instruments

Scope: `read`

Search by text and / or exact filters. Send `q` or at least one filter.

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | `string` | no | Search text (at most 64 characters). |
| `exchange` | `string` | no | Exchange as this route returns it (`NSE`, `BSE`, `MCX`, ...); futures and options are under `NSE` / `BSE`. `NFO` / `BFO` find the futures and options listed under `NSE` / `BSE`. |
| `underlying` | `string` | no | Exact underlying symbol, e.g. `NIFTY` (not a prefix). |
| `instrument` | `string` | no | Instrument type, e.g. `EQUITY`, `OPTIDX`, `FUTSTK`. |
| `expiry` | `string` | no | Expiry date `YYYY-MM-DD`. |
| `strike` | `number (double)` | no | Strike price. |
| `option_type` | `string` | no | `CE` or `PE`. |
| `limit` | `integer (int32)` | no | Most items to return (default 20; above 100 means 100). |
| `cursor` | `string` | no | `next_cursor` of the previous page (opaque). |

- Ranking: exact trading symbol, then symbol prefix, then a word prefix of the name or underlying. Among equal matches shorter symbols come first, then NSE before BSE before MCX.
- 422 `invalid_request` names the parameter in `field`. 503 `instruments_unavailable` just after a restart: retry in a minute.

```bash
curl "https://api.cirrus.trade/v1/instruments?q=nifty&instrument=OPTIDX&expiry=2026-10-27&strike=25000&option_type=CE" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes |  |
| `items[].instrument_token` | `string` | yes | Instrument id: what orders, triggers and every row call `instrument_token`. |
| `items[].exchange` | `string` | yes | Exchange as the instrument list gives it, upper case (`NSE`, `BSE`, `MCX`, ...). Index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX options); filter by the value returned here. |
| `items[].instrument` | `string \| null` | yes | Instrument type (`EQUITY`, `INDEX`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `items[].exchange_token` | `string \| null` | yes | The exchange's own token for the instrument. |
| `items[].lot_size` | `integer (int64)` | yes | Contract lot size; `0` for indices (not tradable). |
| `items[].expiry` | `string \| null` | yes | Expiry date (`YYYY-MM-DD`); null for non-derivatives. |
| `items[].strike` | `number (double) \| null` | yes | Strike price; null for non-options. |
| `items[].option_type` | `string \| null` | yes | `CE` / `PE`; null for non-options. |
| `items[].tick_size` | `number (double) \| null` | yes | Smallest price step. |
| `items[].underlying_symbol` | `string \| null` | yes | Underlying symbol (derivatives), or the stock's own symbol. |
| `items[].isin` | `string \| null` | yes |  |
| `items[].freeze_qty` | `integer (int64) \| null` | yes | Exchange freeze quantity: larger orders are split into slices. |
| `items[].name` | `string \| null` | yes | Company or instrument name. |
| `items[].tradingsymbol` | `string` | yes | Trading symbol, as the exchange lists it. |
| `items[].sector` | `string \| null` | yes |  |
| `items[].indices` | `string[]` | yes | Index memberships (e.g. `NIFTY 50`); empty when none. |
| `count` | `integer` | yes | Number of `items`. |
| `next_cursor` | `string \| null` | yes | Pass as `cursor` (with the same parameters) for the next page; null on the last page. |

**Example: Filters only: calls at one strike (`NFO` finds the options listed under NSE) (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "exchange": "NSE",
        "instrument": "OPTIDX",
        "exchange_token": "50319",
        "lot_size": 60,
        "expiry": "2026-10-27",
        "strike": 25000,
        "option_type": "CE",
        "tick_size": 0.05,
        "underlying_symbol": "FINNIFTY",
        "isin": null,
        "freeze_qty": 1801,
        "name": "FINNIFTY",
        "tradingsymbol": "FINNIFTY27OCT202625000CE",
        "sector": null,
        "indices": [],
        "instrument_token": "CT:1:6:FINNIFTY27OCT202625000CE"
      }
    ],
    "count": 1,
    "next_cursor": null
  },
  "error": null
}
```

**Example: Search by symbol or name (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "exchange": "NSE",
        "instrument": "EQUITY",
        "exchange_token": "2885",
        "lot_size": 1,
        "expiry": null,
        "strike": null,
        "option_type": null,
        "tick_size": 0.1,
        "underlying_symbol": "RELIANCE",
        "isin": "INE002A01018",
        "freeze_qty": 81195,
        "name": "RELIANCE",
        "tradingsymbol": "RELIANCE",
        "sector": "Oil Gas & Consumable Fuels",
        "indices": [
          "NIFTY",
          "NIFTY100",
          "NIFTYENERGY"
        ],
        "instrument_token": "CT:1:2:RELIANCE-EQ"
      },
      {
        "exchange": "BSE",
        "instrument": "EQUITY",
        "exchange_token": "500325",
        "lot_size": 1,
        "expiry": null,
        "strike": null,
        "option_type": null,
        "tick_size": 0.05,
        "underlying_symbol": "RELIANCE",
        "isin": "INE002A01018",
        "freeze_qty": null,
        "name": "RELIANCE",
        "tradingsymbol": "RELIANCE",
        "sector": "Oil Gas & Consumable Fuels",
        "indices": [
          "NIFTY",
          "NIFTY100",
          "NIFTYENERGY"
        ],
        "instrument_token": "CT:2:2:RELIANCE-A"
      }
    ],
    "count": 2,
    "next_cursor": "2-839b1b69914e"
  },
  "error": null
}
```

**Example: Neither `q` nor a filter (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "Give `q` or at least one filter (exchange, instrument, expiry, strike, option_type, underlying)",
    "field": "q"
  }
}
```

**Example: The instrument list is still loading (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "instruments_unavailable",
    "message": "The instrument list is loading. Please retry in a minute.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

### GET /v1/instruments/{instrument_token}

Scope: `read`

One instrument. One listed during the day (not yet in the daily file) is also found, with `isin`, `name` and `sector` null.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `instrument_token` | `string` | yes | Instrument id. |

- 404 `instrument_not_found` for an unknown or expired instrument.

```bash
curl https://api.cirrus.trade/v1/instruments/<instrument_token> \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `instrument_token` | `string` | yes | Instrument id: what orders, triggers and every row call `instrument_token`. |
| `exchange` | `string` | yes | Exchange as the instrument list gives it, upper case (`NSE`, `BSE`, `MCX`, ...). Index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX options); filter by the value returned here. |
| `instrument` | `string \| null` | yes | Instrument type (`EQUITY`, `INDEX`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `exchange_token` | `string \| null` | yes | The exchange's own token for the instrument. |
| `lot_size` | `integer (int64)` | yes | Contract lot size; `0` for indices (not tradable). |
| `expiry` | `string \| null` | yes | Expiry date (`YYYY-MM-DD`); null for non-derivatives. |
| `strike` | `number (double) \| null` | yes | Strike price; null for non-options. |
| `option_type` | `string \| null` | yes | `CE` / `PE`; null for non-options. |
| `tick_size` | `number (double) \| null` | yes | Smallest price step. |
| `underlying_symbol` | `string \| null` | yes | Underlying symbol (derivatives), or the stock's own symbol. |
| `isin` | `string \| null` | yes |  |
| `freeze_qty` | `integer (int64) \| null` | yes | Exchange freeze quantity: larger orders are split into slices. |
| `name` | `string \| null` | yes | Company or instrument name. |
| `tradingsymbol` | `string` | yes | Trading symbol, as the exchange lists it. |
| `sector` | `string \| null` | yes |  |
| `indices` | `string[]` | yes | Index memberships (e.g. `NIFTY 50`); empty when none. |

**Example: An index option (200)**

```json
{
  "status": "success",
  "data": {
    "exchange": "NSE",
    "instrument": "OPTIDX",
    "exchange_token": "50319",
    "lot_size": 60,
    "expiry": "2026-10-27",
    "strike": 25000,
    "option_type": "CE",
    "tick_size": 0.05,
    "underlying_symbol": "FINNIFTY",
    "isin": null,
    "freeze_qty": 1801,
    "name": "FINNIFTY",
    "tradingsymbol": "FINNIFTY27OCT202625000CE",
    "sector": null,
    "indices": [],
    "instrument_token": "CT:1:6:FINNIFTY27OCT202625000CE"
  },
  "error": null
}
```

**Example: Unknown or expired instrument (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "instrument_not_found",
    "message": "No instrument with this instrument_token (expired or invalid)",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `instrument_not_found` | No instrument with this token (unknown or expired). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/instruments.csv

Scope: `read`

The whole list as CSV (about 10 MB, 1.3 MB gzipped), same columns as above, `indices` pipe-separated. Download once a day after 08:40 IST and search locally for many lookups.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `If-None-Match` | `string` | no | An `ETag` received earlier. |
| `If-Modified-Since` | `string` | no | A `Last-Modified` received earlier. |

- Send `Accept-Encoding: gzip` for a compressed body. `Cache-Control: public, max-age=300`.
- `ETag` and `Last-Modified` are the same on every server: send them back as `If-None-Match` / `If-Modified-Since` and get `304 Not Modified` (no body) while the file is unchanged.

```bash
# First download: keep the ETag
curl --compressed -D headers.txt -o instruments.csv \
  -H "Authorization: token $CIRRUS_API_KEY" \
  https://api.cirrus.trade/v1/instruments.csv

# Later: 304 while unchanged, 200 with the new file after the daily refresh
curl --compressed -o instruments.csv \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H 'If-None-Match: "<etag from headers.txt>"' \
  https://api.cirrus.trade/v1/instruments.csv
```

**Response `data` (200):** no body.

**Example: The instrument list is still loading (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "instruments_unavailable",
    "message": "The instrument list is loading. Please retry in a minute.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

## Orders

Place in one or many accounts, modify, cancel, and broker bracket / cover orders. Order state changes arrive on the stream.

### POST /v1/orders

Scope: `orders`

Place orders, each in one or more accounts. Large quantities are split into freeze-quantity slices.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | yes | A new UUID per order request (1-128 printable characters). |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `orders` | `object[]` | yes |  |
| `orders[].instrument_token` | `string` | yes | Instrument id, e.g. `CT:1:2885:RELIANCE-EQ` (see `/v1/instruments`). |
| `orders[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `orders[].order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `orders[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `orders[].quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `orders[].qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `orders[].price` | `number (double)` | no | Limit price (`LIMIT`, `SL`); 0 or absent for market orders. |
| `orders[].trigger_price` | `number (double) \| null` | no | Trigger price for `SL` / `SL_M`. |
| `orders[].accounts` | `string[]` | yes | Account ids to place this order in (see `/v1/accounts`). |
| `orders[].protection` | `object \| null` | no | Stop-loss / target / trail armed once this order fills (as a broker bracket / cover / GTT order when the broker supports it, otherwise a server-side trigger). |
| `orders[].protection.target` | `object` | no | One leg of protection. |
| `orders[].protection.target.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.target.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.target.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.stop_loss` | `object` | no | One leg of protection. |
| `orders[].protection.stop_loss.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.stop_loss.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.stop_loss.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.trail` | `object` | no | One leg of protection. |
| `orders[].protection.trail.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.trail.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.trail.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.fallback` | `string` | no | When the broker refuses a bracket / cover / GTT: `trigger` (default) or `reject`. One of: `trigger`, `reject`. |
| `orders[].protection.route` | `string` | no | Where the protection should live when it is created (placing an order, or protecting an existing position with `POST /v1/triggers`, which takes `auto`, `gtt`, `stop_order` or `trigger`): `auto` (default), `bracket`, `cover`, `gtt`, `stop_order` or `trigger`; a route that cannot be used is refused before the broker, and a broker refusal follows `fallback`. Not stored with a trigger; refused when changing one. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `orders[].market_protection` | `boolean \| null` | no | Market protection (default `true`): MARKET and LIMIT at 0 go out as a LIMIT a small band past the last price, SL-M as SL a band past its trigger. `false` sends them as they are (LIMIT at 0 as MARKET). |
| `orders[].client_tag` | `string \| null` | no | Your own reference for this order, echoed on each of its results: at most 64 letters, digits, `_`, `.`, `:` or `-`. |
| `orders[].strategy` | `string \| null` | no | The strategy this order belongs to, stored on the order and used to group positions and P&L in `GET /v1/strategies`: at most 64 letters, digits, `_`, `.`, `:` or `-`. Absent: "Unassigned". |
| `orders[].depends_on` | `integer \| null` | no | Index of an EARLIER order of this request (in `orders`) that must be FILLED in the same account before this one is placed. Waits at most `depends_on_timeout_ms` (default 30 s); if it does not fill, this order is rejected in that account (`reason: dependency_failed`), or, with `depends_on_partial: filled_qty`, sent for the share its parent filled once every parent slice there has ended or the wait is over. |
| `orders[].depends_on_partial` | `string \| null` | no | With `depends_on`: what to do when the parent filled only partly by the end of the wait. Default `reject`. One of: `reject`, `filled_qty`. |
| `orders[].depends_on_timeout_ms` | `integer (int64) \| null` | no | With `depends_on`: longest wait for the parent, in ms (1000-30000, default 30000). |
| `orders[].chase` | `object \| null` | no | Re-price a protected market order that rests unfilled, within bounds (see `Chase`). Only for MARKET / LIMIT at 0 with market protection on; 422 otherwise. |
| `orders[].chase.every_ms` | `integer (int64)` | no | Milliseconds between re-prices (200-5000, default 500). |
| `orders[].chase.attempts` | `integer (int32)` | no | Most re-prices (1-10, default 3). |
| `orders[].chase.max_slippage_pct` | `number (double)` | no | Furthest the limit may move from the first limit sent, in percent (above 0, at most 20, default 5). |
| `orders[].chase.on_give_up` | `string` | no | `rest` (default) leaves the order at its last limit; `cancel` cancels it. One of: `rest`, `cancel`. |
| `use_multiplier` | `boolean` | no | Scale quantity by each account's `Multiplier`. |

- Returns 200 with per-slice results once placement starts: some accounts may succeed while others are refused. Request problems (bad JSON, validation, unknown instrument, missing Idempotency-Key) are 4xx and place nothing.
- `unknown` means the broker may or may not have the order (for example a timeout). Never retry blindly; watch the stream or retry with the same Idempotency-Key.
- Unknown fields are rejected, not ignored.
- `protection` is broker-native first: intraday goes out as a broker bracket / cover order, positional gets a broker GTT on fill; a Cirrus trigger only when the broker cannot.

```bash
curl -X POST https://api.cirrus.trade/v1/orders \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [{
      "instrument_token": "<instrument_token>",
      "side": "BUY",
      "order_type": "LIMIT",
      "product": "MIS",
      "quantity": 1,
      "qty_is_in_lot": true,
      "price": 101.5,
      "accounts": ["<account_id>"],
      "protection": {
        "stop_loss": {"enabled": true, "type": "percentage", "value": 1},
        "target": {"enabled": true, "type": "points", "value": 5}
      }
    }]
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `results` | `object[]` | yes |  |
| `results[].order_index` | `integer` | yes | Index of the order in the request. |
| `results[].account` | `string` | yes |  |
| `results[].instrument_token` | `string` | yes | Instrument id. |
| `results[].order_tag` | `string \| null` | yes | Order tag for this slice; present once a broker call was attempted. |
| `results[].quantity` | `integer (int64)` | yes | Units sent to the broker (after lot and multiplier sizing). |
| `results[].status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `results[].order_id` | `string \| null` | yes | Broker order id, when accepted. An accepted cover / bracket entry can have none yet (the broker's reply did not name the entry): it shows up in the order book, and `GET /v1/orders/{order_tag}` finds it once the book has it. |
| `results[].message` | `string \| null` | yes | Why it was rejected, or what is unknown. |
| `results[].order_type` | `string` | yes | Final order type/price after market protection. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `results[].price` | `number (double)` | yes |  |
| `results[].protection` | `string \| null` | no | Where this slice's protection lives; absent for unprotected orders. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `results[].protection_id` | `string \| null` | no | Broker basket id when the broker holds the legs from placement. |
| `results[].reason` | `string \| null` | no | Why it was not accepted (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `results[].retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `results[].client_tag` | `string \| null` | no | The order's `client_tag`, when it had one. |
| `results[].reduced_from` | `integer (int64) \| null` | no | Planned units for this slice, when `depends_on_partial: filled_qty` sent fewer. |
| `results[].trigger_v2_id` | `string \| null` | no | The v2 trigger (`/v2/triggers/{trigger_id}`) that protects this slice once it fills, when its stop-loss / target is held by the server as a v2 trigger: created on the first fill, for the filled quantity, levels measured from the fill price. Units filled later get triggers of their own (listed on `/v2/triggers`). Absent otherwise (no protection, broker-held, or a v1 trigger). |
| `accepted` | `integer` | yes |  |
| `rejected` | `integer` | yes |  |
| `unknown` | `integer` | yes |  |

**Example: Protected entry: a broker bracket where the broker has one, a server-side trigger elsewhere (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "PF1",
        "quantity": 5,
        "status": "accepted",
        "order_id": null,
        "message": "placed as a broker bracket order (basket 777)",
        "order_type": "LIMIT",
        "price": 2500,
        "protection": "bracket",
        "protection_id": "777",
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M6114QW86RVRRF9AGWXPGZ1D"
      },
      {
        "order_index": 0,
        "account": "U1",
        "quantity": 5,
        "status": "accepted",
        "order_id": "250924000000123",
        "message": null,
        "order_type": "LIMIT",
        "price": 2500,
        "protection": "trigger",
        "trigger_v2_id": "01M6114QWBNVGMMAKGXZ6QD44V",
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M6114QW8JR7SEXJA8MW48JYW"
      }
    ],
    "accepted": 2,
    "rejected": 0,
    "unknown": 0
  },
  "error": null
}
```

**Example: Stop-loss and target held by the server as a v2 trigger, created when the order fills (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 5,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 2500,
        "protection": "trigger",
        "trigger_v2_id": "01M528W5RT4849ZV0QXFC6WPDD",
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M528W5RQR83SWDQVE74F02HZ"
      }
    ],
    "accepted": 1,
    "rejected": 0,
    "unknown": 0
  },
  "error": null
}
```

**Example: Placed in one account (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 1,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 2500,
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3PGPRZFZXEQM0KEC0DB653H"
      }
    ],
    "accepted": 1,
    "rejected": 0,
    "unknown": 0
  },
  "error": null
}
```

**Example: Hedge first: the sold leg waits for the bought one and is sent for its filled share (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 300,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 68,
        "instrument_token": "CT:TEST:NIFTY22500CE",
        "order_tag": "01M4XDF6ERZPG3PV2FKERCT8VY"
      },
      {
        "order_index": 1,
        "account": "P1",
        "quantity": 150,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 112.5,
        "reduced_from": 300,
        "instrument_token": "CT:TEST:NIFTY22400CE",
        "order_tag": "01M4XDFBBDFY172K9KX6PYQHRE"
      }
    ],
    "accepted": 2,
    "rejected": 0,
    "unknown": 0
  },
  "error": null
}
```

**Example: One order, two accounts: one accepted, one refused (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 1,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 2500,
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3PGPRZHC3EKF5WBRE13ECS3"
      },
      {
        "order_index": 0,
        "account": "Z1",
        "quantity": 1,
        "status": "rejected",
        "order_id": null,
        "message": "Broker zerodha is not supported yet.",
        "order_type": "LIMIT",
        "price": 2500,
        "reason": "no_session",
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3PGPRZHQRCBZXZSAEJPG4RS"
      }
    ],
    "accepted": 1,
    "rejected": 1,
    "unknown": 0
  },
  "error": null
}
```

**Example: A zero quantity is refused; nothing is placed (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "quantity must be greater than 0",
    "field": "orders[0].quantity"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `idempotency_key_required` | The `Idempotency-Key` header is missing (use a new UUID per order request). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v1/orders/{order_id}

Scope: `orders`

Modify one open order in one account.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag`, as `GET /v1/orders/{order_id}` takes it (1-64 letters, digits, `-`, `_`, `.` or `:`). A tag is sent to the broker as the order's broker id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per modify request (1-128 printable characters). |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id the order is in (see `/v1/accounts`). |
| `instrument_token` | `string` | yes | Instrument id of the order. |
| `side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `price` | `number (double)` | no | Limit price (`LIMIT`, `SL`); 0 or absent for market orders. |
| `trigger_price` | `number (double) \| null` | no | Trigger price for `SL` / `SL_M`. |

```bash
curl -X PATCH https://api.cirrus.trade/v1/orders/<order_id> \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account": "<account_id>", "instrument_token": "<instrument_token>", "side": "BUY",
       "order_type": "LIMIT", "product": "MIS", "quantity": 1, "qty_is_in_lot": true,
       "price": 100.5}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `order_id` | `string` | yes |  |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `warning` | `string \| null` | no | Something the caller must act on although the change was accepted (e.g. a filled bracket / cover entry cancelled with `legs=true`: its position is now unprotected). Absent otherwise. |
| `split_orders` | `object[]` | no | For the id a placement returned when the broker split that order into several orders (each order's `split_from`): one result per open order it was split into, which this call changed or cancelled. Absent otherwise. |
| `split_orders[].order_id` | `string` | yes | The part's own broker order id. |
| `split_orders[].status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `split_orders[].message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `split_orders[].reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: The broker accepted the change (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "order_id": "PAPER-9",
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: No linked account with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Example: A price off the tick grid is refused; nothing is sent (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "price 2500.03 is not a multiple of tick size 0.05",
    "field": "price"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `order_not_open` | The order is already finished (filled, cancelled or rejected), so there is nothing to modify or cancel. Nothing was sent to the broker. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/orders/{order_id}

Scope: `orders`

Cancel one open order in one account.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag`, as `GET /v1/orders/{order_id}` takes it (1-64 letters, digits, `-`, `_`, `.` or `:`). A tag is sent to the broker as the order's broker id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id the order is in. |
| `instrument_token` | `string` | yes | Instrument id of the order. |
| `quantity` | `integer (int64)` | no | Units still open (some brokers need it to cancel); 0 or absent otherwise. |
| `legs` | `boolean` | no | For a bracket / cover entry that has (partly) filled: `true` also cancels its live stop-loss / target legs, leaving the position unprotected. Without it such a cancel is refused (409 `position_protected`). |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/orders/<order_id>?account=<account_id>&instrument_token=<instrument_token>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `order_id` | `string` | yes |  |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `warning` | `string \| null` | no | Something the caller must act on although the change was accepted (e.g. a filled bracket / cover entry cancelled with `legs=true`: its position is now unprotected). Absent otherwise. |
| `split_orders` | `object[]` | no | For the id a placement returned when the broker split that order into several orders (each order's `split_from`): one result per open order it was split into, which this call changed or cancelled. Absent otherwise. |
| `split_orders[].order_id` | `string` | yes | The part's own broker order id. |
| `split_orders[].status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `split_orders[].message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `split_orders[].reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: The broker refused: its reason is in `message` (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "order_id": "PAPER-7",
    "status": "rejected",
    "message": "Order already completed",
    "reason": "broker_rejected"
  },
  "error": null
}
```

**Example: The broker cancelled the order (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "order_id": "PAPER-8",
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: `account` is required (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_query",
    "message": "invalid query string: missing field `account`",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `position_protected` | The order is a (partly) filled bracket / cover entry: cancelling it would cancel the live stop-loss / target legs of its position. Send `legs=true` to do it anyway. |
| 409 | `order_not_open` | The order is already finished (filled, cancelled or rejected), so there is nothing to modify or cancel. Nothing was sent to the broker. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/orders/bracket

Scope: `orders`

Broker bracket (BO: entry + stop-loss + target) or cover (CO: entry + stop-loss) order in one account. Pocketful and Tradejini.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | yes | A new UUID per order request (1-128 printable characters). |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `kind` | `string` | yes | `BO` (bracket: entry + stop-loss + target) or `CO` (cover: entry + stop-loss). One of: `BO`, `CO`. |
| `side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `order_type` | `string` | yes | Entry order type: `LIMIT` or `MARKET`. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `price` | `number (double)` | no | Entry limit price (`LIMIT`); 0 or absent for `MARKET`. |
| `stop_loss` | `number (double)` | yes | Stop-loss price. |
| `target` | `number (double) \| null` | no | Target price: required for `BO`, not allowed for `CO`. |
| `trailing_stop_loss` | `number (double) \| null` | no | Trailing stop-loss step, in points (on the tick grid). |
| `client_tag` | `string \| null` | no | Your own reference for the entry (1-64 letters, digits, `_`, `.`, `:` or `-`), echoed in the answer and kept on the order: `GET /v1/orders/{client_tag}` finds it. |

- Legs are absolute prices checked against the entry (the limit price, or LTP for a market entry): a BUY needs stop_loss < entry < target, on the tick grid.
- The legs show up in the order book as ordinary orders.

```bash
curl -X POST https://api.cirrus.trade/v1/orders/bracket \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"account": "<account_id>", "instrument_token": "<instrument_token>", "kind": "BO",
       "side": "BUY", "order_type": "LIMIT", "product": "MIS", "quantity": 1,
       "qty_is_in_lot": true, "price": 100, "stop_loss": 98, "target": 104}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `basket_id` | `string \| null` | yes | Broker basket / parent id (placement only; null otherwise). |
| `order_id` | `string \| null` | yes | The order changed or cancelled; on placement the entry's broker order id when the broker's reply names it (Zerodha, Dhan, Tradejini, XTS; Pocketful only when its reply carries `oms_order_id`), null otherwise (the entry then shows up in the order book). |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `client_tag` | `string \| null` | no | The `client_tag` sent on placement (absent when none was). |

**Example: Bracket order accepted (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "basket_id": "777",
    "order_id": null,
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: The Idempotency-Key header is required (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_required",
    "message": "Idempotency-Key header is required (use a new UUID per order request)",
    "field": null
  }
}
```

**Example: A bracket order (BO) needs a target (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "a bracket order (BO) needs a target",
    "field": "target"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `idempotency_key_required` | The `Idempotency-Key` header is missing (use a new UUID per order request). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v1/orders/bracket/{order_id}

Scope: `orders`

Change the price, quantity or legs of a bracket / cover order.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag` of the entry, as `GET /v1/orders/{order_id}` takes it (1-64 letters, digits, `-`, `_`, `.` or `:`). |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id the order is in. |
| `instrument_token` | `string` | yes | Instrument id of the order. |
| `kind` | `string` | yes | `BO` (bracket: entry + stop-loss + target) or `CO` (cover: entry + stop-loss). One of: `BO`, `CO`. |
| `side` | `string` | yes | Side of the entry. One of: `BUY`, `SELL`. |
| `entry_price` | `number (double)` | yes | Entry price the legs are measured from. |
| `price` | `number (double) \| null` | no | New entry limit price. |
| `quantity` | `integer (int64) \| null` | no | New quantity, in units. |
| `stop_loss` | `number (double) \| null` | no | New stop-loss price. |
| `target` | `number (double) \| null` | no | New target price (`BO` only). |
| `trailing_stop_loss` | `number (double) \| null` | no | New trailing stop-loss step, in points. |

```bash
curl -X PATCH "https://api.cirrus.trade/v1/orders/bracket/<order_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "PF1",
    "instrument_token": "CT:TEST:RELIANCE",
    "kind": "BO",
    "side": "BUY",
    "entry_price": 2500,
    "stop_loss": 2460,
    "target": 2620
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `basket_id` | `string \| null` | yes | Broker basket / parent id (placement only; null otherwise). |
| `order_id` | `string \| null` | yes | The order changed or cancelled; on placement the entry's broker order id when the broker's reply names it (Zerodha, Dhan, Tradejini, XTS; Pocketful only when its reply carries `oms_order_id`), null otherwise (the entry then shows up in the order book). |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `client_tag` | `string \| null` | no | The `client_tag` sent on placement (absent when none was). |

**Example: Both legs of a bracket order moved (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "basket_id": null,
    "order_id": "250929000001",
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: A cover order (CO) has no target (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "a cover order (CO) has no target",
    "field": "target"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/orders/bracket/{order_id}

Scope: `orders`

Cancel a bracket / cover order.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag` of the entry, as `GET /v1/orders/{order_id}` takes it (1-64 letters, digits, `-`, `_`, `.` or `:`). |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id the order is in. |
| `kind` | `string` | yes | `BO` or `CO`. One of: `BO`, `CO`. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/orders/bracket/<order_id>?account=<account_id>&kind=BO" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `basket_id` | `string \| null` | yes | Broker basket / parent id (placement only; null otherwise). |
| `order_id` | `string \| null` | yes | The order changed or cancelled; on placement the entry's broker order id when the broker's reply names it (Zerodha, Dhan, Tradejini, XTS; Pocketful only when its reply carries `oms_order_id`), null otherwise (the entry then shows up in the order book). |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | Why it was not accepted, machine-readable (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `client_tag` | `string \| null` | no | The `client_tag` sent on placement (absent when none was). |

**Example: Entry and legs cancelled (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "basket_id": null,
    "order_id": "250929000001",
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: No linked account with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Protection rules

The rules object used by an order's protection and by Cirrus triggers. Levels are measured from the entry price.

**Rules**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `stop_loss` | `rule` | no | Exit below (long) / above (short) the entry. |
| `target` | `rule` | no | Profit exit. Enable a stop-loss, a target, or both. |
| `trail` | `rule` | no | Trailing stop-loss; needs `stop_loss` and cannot be of type `price`. |
| `<rule>.enabled` | `boolean` | no | Default false. |
| `<rule>.type` | `percentage \| points \| price` | no | Default percentage; any casing. |
| `<rule>.value` | `number` | no | Must be above 0 when enabled. |

**Rules object**

```json
{
  "stop_loss": {"enabled": true, "type": "percentage", "value": 1.5},
  "target":    {"enabled": true, "type": "price", "value": 250},
  "trail":     {"enabled": true, "type": "points", "value": 2}
}
```

## Portfolio

Reads come from the latest snapshots, never a broker call, so they are fast and do not spend the broker rate budget.

### GET /v1/portfolio/{kind}

Scope: `read`

`kind` is orders, positions, holdings, trades or margins.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `string` | yes | Which data: `orders`, `positions`, `holdings`, `trades` or `margins`. One of: `orders`, `positions`, `holdings`, `trades`, `margins`. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | Only this linked account (its id). |

```bash
curl "https://api.cirrus.trade/v1/portfolio/positions" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].updated_at` | `string (date-time) \| null` | yes | When the data was last read from the broker (for `orders`: the latest broker update of any order); null when unknown. |
| `accounts[].data` | `object[] \| object[] \| object[] \| object[] \| MarginRow` | yes | `orders`: today's orders, newest first (`OrderData` rows); `positions`, `holdings`, `trades`: `PositionRow`, `HoldingRow`, `TradeRow` rows (`trades`: today's fills only); `margins`: one `MarginRow` object. |
| `accounts[].data.account` | `string` | yes | When `type` is `MarginRow`. |
| `accounts[].data.opening_balance` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.available` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.utilised` | `number (double)` | yes | When `type` is `MarginRow`. Share of the opening balance in use, as a percent (0-100). |
| `accounts[].data.used` | `number (double) \| null` | no | When `type` is `MarginRow`. Margin in use, in rupees; absent when the broker does not report it. |
| `accounts[].data.payin` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds added today, in rupees; absent when not reported. |
| `accounts[].data.payout` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds withdrawn today, in rupees; absent when not reported. |
| `accounts[].data.collateral` | `number (double) \| null` | no | When `type` is `MarginRow`. Collateral (pledged holdings) margin, in rupees; absent when not reported. |
| `accounts[].data.realised_pnl` | `number (double) \| null` | no | When `type` is `MarginRow`. Booked P&L for the day, in rupees; absent when not reported. |
| `missing` | `object[]` | yes |  |
| `missing[].account` | `string` | yes |  |
| `missing[].code` | `string` | yes | `no_session`: no active broker session (log in to the broker); `not_supported`: this broker is not supported yet; `stale`: the only data is from an earlier broker session (read before today's 04:00 IST), so it is not shown (log in to the broker). One of: `no_session`, `not_supported`, `stale`. |
| `missing[].reason` | `string` | yes | Why, in plain English (for `stale`: when the data was last read, e.g. `No data since 29 Sep 19:20 IST: log in to the broker`). |

**Example: Holdings of one account (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.872057Z",
        "data": [
          {
            "account": "P1",
            "tradingsymbol": "RELIANCE-EQ",
            "product": "DELIVERY",
            "quantity": 10,
            "average_price": 2400,
            "invested_amount": 24000,
            "pnl": 1000,
            "pnl_percent": 4.17,
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

**Example: Funds of one account (one object) (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.873473Z",
        "data": {
          "account": "P1",
          "opening_balance": 100000,
          "available": 90000.8,
          "utilised": 9999.2
        }
      }
    ],
    "missing": []
  },
  "error": null
}
```

**Example: Today's order book of one account (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.873796Z",
        "data": [
          {
            "broker_tag": "a1b2c3",
            "order_id": "240927000012345",
            "parent_tag": null,
            "username": "user-01M3PGPS0BTN9PZC9GTZ5JP0V5",
            "account": "P1",
            "broker": "paper",
            "tradingsymbol": "RELIANCE-EQ",
            "side": "BUY",
            "order_type": "LIMIT",
            "product": "DELIVERY",
            "quantity": 1,
            "price": 2500,
            "trigger_price": null,
            "state": "OPEN",
            "filled_qty": 0,
            "average_price": null,
            "status_message": null,
            "broker_updated_at": "2026-09-29T12:02:21.873796Z",
            "created_at": "2026-09-29T12:02:21.873796Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "OPEN",
                "at": "2026-09-29T12:02:21.873796Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3PGPS1H2ZQB73V1CP2ARKM4"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

**Example: Open positions of one account (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.869612Z",
        "data": [
          {
            "account": "P1",
            "tradingsymbol": "RELIANCE-EQ",
            "product": "MIS",
            "net_qty": 4,
            "buy_qty": 4,
            "sell_qty": 0,
            "buy_value": 9999.2,
            "sell_value": 0,
            "average_price": 2499.8,
            "buy_average_price": 2499.8,
            "sell_average_price": 0,
            "pnl": 0.8,
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

**Example: Data from an earlier broker session is not shown: missing as stale (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [],
    "missing": [
      {
        "account": "P2",
        "code": "stale",
        "reason": "No data since 29 Sep 19:20 IST: log in to the broker"
      }
    ]
  },
  "error": null
}
```

**Example: Today's fills of one account (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.872897Z",
        "data": [
          {
            "account": "P1",
            "order_id": "240927000012345",
            "tradingsymbol": "RELIANCE-EQ",
            "side": "BUY",
            "quantity": 4,
            "fill_price": 2499.8,
            "trade_value": 9999.2,
            "filled_at": "2026-09-29T12:02:21.869597Z",
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

**Example: Every account: those without data are listed under missing (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-29T12:02:21.873473Z",
        "data": {
          "account": "P1",
          "opening_balance": 100000,
          "available": 90000.8,
          "utilised": 9999.2
        }
      }
    ],
    "missing": [
      {
        "account": "P2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "Z1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "PF1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "U1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "5P1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "MO1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      },
      {
        "account": "TJ1",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: The account is not one of the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Example: An unknown kind is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "kind must be one of orders, positions, holdings, trades, margins",
    "field": "kind"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/portfolio/refresh

Scope: `read`

Ask for a fresh read from the broker. Answers 202; new data arrives on the stream.

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | Only this linked account (its id). |

```bash
curl -X POST "https://api.cirrus.trade/v1/portfolio/refresh" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `requested` | `integer` | yes | How many accounts were asked to re-read. |

**Example: One account asked to re-read (202)**

```json
{
  "status": "success",
  "data": {
    "requested": 1
  },
  "error": null
}
```

**Example: The account is not one of the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## GTT

Good-till-triggered orders held at the broker: one leg, or stop-loss + target. Zerodha, Pocketful, Tradejini. Shares the per-account order budget.

### GET /v1/gtt

Scope: `read`

Every GTT at the broker, per account, including ones made outside Cirrus (live read). Listing is available for Zerodha; other brokers report "not available".

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | Only this account; every account when absent. |

```bash
curl "https://api.cirrus.trade/v1/gtt" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].account` | `string` | yes |  |
| `[].broker` | `string` | yes | The account's broker. |
| `[].source` | `string` | yes | Where the list comes from: `broker` (a live read at the broker) or `api_records` (the broker cannot list GTTs, or its best-effort list read failed, e.g. Pocketful: the GTTs placed through this API, from its own records). |
| `[].gtts` | `object[]` | yes | Empty when the list could not be read (`error` says why). |
| `[].gtts[].trigger_id` | `string` | yes | The broker's GTT id. |
| `[].gtts[].status` | `string` | yes | Broker status, lower-case (`active`, `triggered`, `cancelled`, ...). |
| `[].gtts[].instrument_token` | `string \| null` | yes | Instrument id; null when the broker's instrument is not known here. |
| `[].gtts[].tradingsymbol` | `string` | yes |  |
| `[].gtts[].exchange` | `string` | yes |  |
| `[].gtts[].side` | `string \| null` | yes | Side of the order(s) a trigger places. One of: `BUY`, `SELL`. |
| `[].gtts[].product` | `string \| null` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `[].gtts[].quantity` | `integer (int64)` | yes | Units. |
| `[].gtts[].last_price` | `number (double) \| null` | yes | LTP when the GTT was created / last modified. |
| `[].gtts[].legs` | `object[]` | yes | One leg (single) or two (stop-loss first, then target). |
| `[].gtts[].legs[].trigger` | `number (double)` | yes | Price that fires the leg. |
| `[].gtts[].legs[].price` | `number (double)` | yes | Limit price of the order it places. |
| `[].gtts[].created_at` | `string \| null` | yes |  |
| `[].gtts[].updated_at` | `string \| null` | yes |  |
| `[].gtts[].expires_at` | `string \| null` | yes |  |
| `[].note` | `string \| null` | yes | With `api_records`: why the records are listed and that GTTs placed outside this API are not visible. Null for `broker`. |
| `[].error` | `string \| null` | yes | Why this account's list is missing (others still listed); null when it was read. |
| `[].error_code` | `string \| null` | yes | `not_supported_by_broker` when the broker offers no GTTs; null otherwise. |

**Example: The account's broker has no GTTs: `error` says so (200)**

```json
{
  "status": "success",
  "data": [
    {
      "account": "U1",
      "broker": "upstox",
      "source": "broker",
      "gtts": [],
      "note": null,
      "error": "Listing GTTs is not available for upstox accounts.",
      "error_code": "not_supported_by_broker"
    }
  ],
  "error": null
}
```

**Example: Read at the broker (Pocketful), including GTTs placed outside this API (200)**

```json
{
  "status": "success",
  "data": [
    {
      "account": "PF1",
      "broker": "pocketful",
      "source": "broker",
      "gtts": [
        {
          "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
          "status": "active",
          "tradingsymbol": "RELIANCE-EQ",
          "exchange": "NSE",
          "side": "SELL",
          "product": "DELIVERY",
          "quantity": 5,
          "last_price": null,
          "legs": [
            {
              "trigger": 2400,
              "price": 2399
            }
          ],
          "created_at": null,
          "updated_at": null,
          "expires_at": "2099-12-31",
          "instrument_token": "CT:TEST:GTT_RELIANCE"
        }
      ],
      "note": null,
      "error": null,
      "error_code": null
    }
  ],
  "error": null
}
```

**Example: The broker's list could not be read (Pocketful): the GTTs placed through this API, from its records (200)**

```json
{
  "status": "success",
  "data": [
    {
      "account": "PF1",
      "broker": "pocketful",
      "source": "api_records",
      "gtts": [
        {
          "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
          "status": "active",
          "tradingsymbol": "RELIANCE-EQ",
          "exchange": "NSE",
          "side": "SELL",
          "product": "DELIVERY",
          "quantity": 5,
          "last_price": null,
          "legs": [
            {
              "trigger": 2400,
              "price": 2399
            }
          ],
          "created_at": "2026-10-12 16:01:53",
          "updated_at": "2026-10-12 16:01:53",
          "expires_at": null,
          "instrument_token": "CT:TEST:RELIANCE"
        }
      ],
      "note": "The broker's GTT list could not be read just now, so these are the GTTs placed through this API. GTTs placed outside it (e.g. in the broker's app) are not visible here.",
      "error": null,
      "error_code": null
    }
  ],
  "error": null
}
```

**Example: No linked account with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account NOPE not found for this user",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/gtt

Scope: `orders`

Create a GTT in one account.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `side` | `string` | yes | Side of the order a trigger places (SELL to protect a long). One of: `BUY`, `SELL`. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `stop_loss` | `object \| null` | no | One GTT leg: the price that fires it and the order's limit price. |
| `stop_loss.trigger` | `number (double)` | yes | Price that fires the leg (on the tick grid). |
| `stop_loss.price` | `number (double) \| null` | no | Limit price of the order placed; defaults to the trigger. |
| `target` | `object \| null` | no | One GTT leg: the price that fires it and the order's limit price. |
| `target.trigger` | `number (double)` | yes | Price that fires the leg (on the tick grid). |
| `target.price` | `number (double) \| null` | no | Limit price of the order placed; defaults to the trigger. |

- A SELL stop-loss sits below LTP and its target above (reversed for BUY). A single leg may be either, but not at LTP.

```bash
curl -X POST https://api.cirrus.trade/v1/gtt \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account": "<account_id>", "instrument_token": "<instrument_token>", "side": "SELL",
       "product": "DELIVERY", "quantity": 10,
       "stop_loss": {"trigger": 95}, "target": {"trigger": 120, "price": 119.5}}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `trigger_id` | `string \| null` | yes | The broker's GTT id; null when creating failed. |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `error_code` | `string \| null` | yes | `not_supported_by_broker` when the account's broker does not offer this; null otherwise (branch on this, not on `message`). |
| `reason` | `string \| null` | no | With `status: rejected`: `rate_limited` (this account's order budget; retry shortly) or `no_session` (log the account in again). Nothing reached the broker. Absent otherwise. One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: Stop-loss GTT created at the broker (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "status": "accepted",
    "message": null,
    "error_code": null
  },
  "error": null
}
```

**Example: Pocketful takes no stop-loss + target GTT: refused before any broker call (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": null,
    "status": "rejected",
    "message": "stop-loss + target (OCO) GTT is not available for pocketful accounts.",
    "error_code": "not_supported_by_broker"
  },
  "error": null
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: A SELL's stop-loss must sit below LTP and its target above (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "for a Sell GTT the stop-loss and target must sit on opposite sides of LTP 2500 (stop-loss below, target above)",
    "field": "stop_loss"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v1/gtt/{trigger_id}

Scope: `orders`

Replace the legs / quantity. Same body as create.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The broker's GTT id (1-64 letters, digits, `-`, `_`, `.` or `:`). |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `side` | `string` | yes | Side of the order a trigger places (SELL to protect a long). One of: `BUY`, `SELL`. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `stop_loss` | `object \| null` | no | One GTT leg: the price that fires it and the order's limit price. |
| `stop_loss.trigger` | `number (double)` | yes | Price that fires the leg (on the tick grid). |
| `stop_loss.price` | `number (double) \| null` | no | Limit price of the order placed; defaults to the trigger. |
| `target` | `object \| null` | no | One GTT leg: the price that fires it and the order's limit price. |
| `target.trigger` | `number (double)` | yes | Price that fires the leg (on the tick grid). |
| `target.price` | `number (double) \| null` | no | Limit price of the order placed; defaults to the trigger. |

```bash
curl -X PATCH "https://api.cirrus.trade/v1/gtt/<trigger_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "PF1",
    "instrument_token": "CT:TEST:RELIANCE",
    "side": "SELL",
    "product": "DELIVERY",
    "quantity": 5,
    "stop_loss": {
      "trigger": 2420,
      "price": 2419
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `trigger_id` | `string \| null` | yes | The broker's GTT id; null when creating failed. |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `error_code` | `string \| null` | yes | `not_supported_by_broker` when the account's broker does not offer this; null otherwise (branch on this, not on `message`). |
| `reason` | `string \| null` | no | With `status: rejected`: `rate_limited` (this account's order budget; retry shortly) or `no_session` (log the account in again). Nothing reached the broker. Absent otherwise. One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: Stop-loss moved (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "status": "accepted",
    "message": null,
    "error_code": null
  },
  "error": null
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: The GTT id has characters an id cannot have (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "trigger_id must be 1-64 letters, digits, '-', '_', '.' or ':'",
    "field": "trigger_id"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/gtt/{trigger_id}

Scope: `orders`

Delete a GTT.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The broker's GTT id (1-64 letters, digits, `-`, `_`, `.` or `:`). |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id the GTT is in. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/gtt/<trigger_id>?account=<account_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `trigger_id` | `string \| null` | yes | The broker's GTT id; null when creating failed. |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `error_code` | `string \| null` | yes | `not_supported_by_broker` when the account's broker does not offer this; null otherwise (branch on this, not on `message`). |
| `reason` | `string \| null` | no | With `status: rejected`: `rate_limited` (this account's order budget; retry shortly) or `no_session` (log the account in again). Nothing reached the broker. Absent otherwise. One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: Deleted at the broker (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "status": "accepted",
    "message": null,
    "error_code": null
  },
  "error": null
}
```

**Example: No linked account with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Positions convert

Move an open position between products (for example MIS to CARRYFORWARD). Zerodha, Pocketful, Upstox, Motilal Oswal, Tradejini.

### POST /v1/positions/convert

Scope: `orders`

Convert in one account; positions and margins refresh afterwards.

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id holding the position. |
| `instrument_token` | `string` | yes | Instrument id of the position. |
| `side` | `string` | yes | Side of the open position (BUY for long, SELL for short). One of: `BUY`, `SELL`. |
| `quantity` | `integer (int64)` | yes | Units to convert (greater than 0). |
| `from` | `string` | yes | The position's current product. One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `to` | `string` | yes | The product to move it to (different from `from`). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `overnight` | `boolean` | no | The position was carried from a previous session (not opened today). |

```bash
curl -X POST https://api.cirrus.trade/v1/positions/convert \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account": "<account_id>", "instrument_token": "<instrument_token>", "side": "BUY",
       "quantity": 75, "from": "MIS", "to": "CARRYFORWARD"}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `message` | `string \| null` | yes | Why it was rejected, or what is unknown; null when accepted. |
| `reason` | `string \| null` | no | With `status: rejected`: `rate_limited` (this account's order budget; retry shortly) or `no_session` (log the account in again). Nothing reached the broker. Absent otherwise. One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |

**Example: The account's broker cannot convert positions (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "status": "rejected",
    "message": "Position conversion is not available for paper accounts."
  },
  "error": null
}
```

**Example: Moved from MIS to DELIVERY (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "status": "accepted",
    "message": null
  },
  "error": null
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: `to` must differ from `from` (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "`to` must differ from `from`",
    "field": "to"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Margins

Margin required for an order body, per order and account, after multiplier / lot rounding and market protection.

### POST /v1/margins/orders

Scope: `read`

Same body as POST /v1/orders. Places nothing; no Idempotency-Key needed.

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `orders` | `object[]` | yes |  |
| `orders[].instrument_token` | `string` | yes | Instrument id, e.g. `CT:1:2885:RELIANCE-EQ` (see `/v1/instruments`). |
| `orders[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `orders[].order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `orders[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `orders[].quantity` | `integer (int64)` | yes | Units, or lots when `qty_is_in_lot`. Units round down to whole lots; under one lot is refused. |
| `orders[].qty_is_in_lot` | `boolean` | no | `quantity` counts lots, not units. |
| `orders[].price` | `number (double)` | no | Limit price (`LIMIT`, `SL`); 0 or absent for market orders. |
| `orders[].trigger_price` | `number (double) \| null` | no | Trigger price for `SL` / `SL_M`. |
| `orders[].accounts` | `string[]` | yes | Account ids to place this order in (see `/v1/accounts`). |
| `orders[].protection` | `object \| null` | no | Stop-loss / target / trail armed once this order fills (as a broker bracket / cover / GTT order when the broker supports it, otherwise a server-side trigger). |
| `orders[].protection.target` | `object` | no | One leg of protection. |
| `orders[].protection.target.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.target.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.target.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.stop_loss` | `object` | no | One leg of protection. |
| `orders[].protection.stop_loss.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.stop_loss.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.stop_loss.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.trail` | `object` | no | One leg of protection. |
| `orders[].protection.trail.enabled` | `boolean` | no | Off unless `true`. |
| `orders[].protection.trail.type` | `string` | no | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. One of: `percentage`, `points`, `price`. |
| `orders[].protection.trail.value` | `number (double)` | no | Greater than 0 when enabled. |
| `orders[].protection.fallback` | `string` | no | When the broker refuses a bracket / cover / GTT: `trigger` (default) or `reject`. One of: `trigger`, `reject`. |
| `orders[].protection.route` | `string` | no | Where the protection should live when it is created (placing an order, or protecting an existing position with `POST /v1/triggers`, which takes `auto`, `gtt`, `stop_order` or `trigger`): `auto` (default), `bracket`, `cover`, `gtt`, `stop_order` or `trigger`; a route that cannot be used is refused before the broker, and a broker refusal follows `fallback`. Not stored with a trigger; refused when changing one. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `orders[].market_protection` | `boolean \| null` | no | Market protection (default `true`): MARKET and LIMIT at 0 go out as a LIMIT a small band past the last price, SL-M as SL a band past its trigger. `false` sends them as they are (LIMIT at 0 as MARKET). |
| `orders[].client_tag` | `string \| null` | no | Your own reference for this order, echoed on each of its results: at most 64 letters, digits, `_`, `.`, `:` or `-`. |
| `orders[].strategy` | `string \| null` | no | The strategy this order belongs to, stored on the order and used to group positions and P&L in `GET /v1/strategies`: at most 64 letters, digits, `_`, `.`, `:` or `-`. Absent: "Unassigned". |
| `orders[].depends_on` | `integer \| null` | no | Index of an EARLIER order of this request (in `orders`) that must be FILLED in the same account before this one is placed. Waits at most `depends_on_timeout_ms` (default 30 s); if it does not fill, this order is rejected in that account (`reason: dependency_failed`), or, with `depends_on_partial: filled_qty`, sent for the share its parent filled once every parent slice there has ended or the wait is over. |
| `orders[].depends_on_partial` | `string \| null` | no | With `depends_on`: what to do when the parent filled only partly by the end of the wait. Default `reject`. One of: `reject`, `filled_qty`. |
| `orders[].depends_on_timeout_ms` | `integer (int64) \| null` | no | With `depends_on`: longest wait for the parent, in ms (1000-30000, default 30000). |
| `orders[].chase` | `object \| null` | no | Re-price a protected market order that rests unfilled, within bounds (see `Chase`). Only for MARKET / LIMIT at 0 with market protection on; 422 otherwise. |
| `orders[].chase.every_ms` | `integer (int64)` | no | Milliseconds between re-prices (200-5000, default 500). |
| `orders[].chase.attempts` | `integer (int32)` | no | Most re-prices (1-10, default 3). |
| `orders[].chase.max_slippage_pct` | `number (double)` | no | Furthest the limit may move from the first limit sent, in percent (above 0, at most 20, default 5). |
| `orders[].chase.on_give_up` | `string` | no | `rest` (default) leaves the order at its last limit; `cancel` cancels it. One of: `rest`, `cancel`. |
| `use_multiplier` | `boolean` | no | Scale quantity by each account's `Multiplier`. |

```bash
curl -X POST https://api.cirrus.trade/v1/margins/orders \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"orders": [{"instrument_token": "<instrument_token>", "side": "SELL", "order_type": "MARKET",
       "product": "CARRYFORWARD", "quantity": 1, "qty_is_in_lot": true,
       "accounts": ["<account_id>"]}]}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `results` | `object[]` | yes |  |
| `results[].order_index` | `integer` | yes | Index of the order in the request. |
| `results[].account` | `string` | yes |  |
| `results[].instrument_token` | `string` | yes | Instrument id. |
| `results[].quantity` | `integer (int64)` | yes | Units that would be sent (after lot and multiplier sizing); 0 when the account would refuse the order. |
| `results[].total_required` | `number (double) \| null` | yes | Margin required: the broker's figure, or the estimate when the broker cannot calculate margins or did not answer (`source` says which); null when there is neither (`message` says why). |
| `results[].message` | `string \| null` | yes | Why the broker gave no figure (the order would be refused, the broker cannot calculate margins, or it did not answer). Kept when `total_required` is an estimate. |
| `results[].error_code` | `string \| null` | yes | `not_supported_by_broker` when the account's broker cannot calculate margins (e.g. Pocketful); null otherwise (branch on this, not on `message`). Kept when `total_required` is an estimate. |
| `results[].source` | `string \| null` | yes | Where `total_required` comes from; null when there is no figure. One of: `broker`, `estimate`. |
| `results[].stale` | `boolean` | yes | The estimate used exchange data that is not today's (or that the exchange data load refused); always false for broker figures. |
| `results[].flags` | `string[]` | yes | Why the estimate may be low (e.g. `positions_unknown`). An open list: ignore values you do not know. Empty for broker figures. |
| `accounts` | `object[]` | yes | One total per account that has a result. |
| `accounts[].account` | `string` | yes |  |
| `accounts[].total_required` | `number (double) \| null` | yes | Margin the account's orders add together: the sum of their figures; when they hedge each other (futures and options of one underlying, or of a pair the exchange gives credit for), that sum less the estimated hedge benefit, never below the hedged estimate. Null when any of the account's orders has no figure (never a partial sum; `message` says which). |
| `accounts[].source` | `string \| null` | yes | `broker` when the total is the sum of broker figures only, `estimate` when it carries a hedge credit or includes an estimated order; null when there is no total. One of: `broker`, `estimate`. |
| `accounts[].stale` | `boolean` | yes | The estimate used exchange data that is not today's, or that the exchange data load refused. |
| `accounts[].flags` | `string[]` | yes | Why the estimate may be low (open list, as on each result). |
| `accounts[].message` | `string \| null` | yes | Why there is no total (which orders have no figure). |
| `total_required` | `number (double)` | yes | Sum over results that have a figure (broker or estimate). |

**Example: One account with the broker's figure, one estimated (its broker cannot calculate margins) (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "PF1",
        "quantity": 10,
        "total_required": 5250,
        "message": "Margin calculation is not available for pocketful accounts.",
        "error_code": "not_supported_by_broker",
        "source": "estimate",
        "stale": false,
        "flags": [],
        "instrument_token": "CT:TEST:RELIANCE"
      },
      {
        "order_index": 0,
        "account": "P2",
        "quantity": 20,
        "total_required": 50000,
        "message": null,
        "error_code": null,
        "source": "broker",
        "stale": false,
        "flags": [],
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ],
    "accounts": [
      {
        "account": "PF1",
        "total_required": 5250,
        "source": "estimate",
        "stale": false,
        "flags": [],
        "message": null
      },
      {
        "account": "P2",
        "total_required": 50000,
        "source": "broker",
        "stale": false,
        "flags": [],
        "message": null
      }
    ],
    "total_required": 55250
  },
  "error": null
}
```

**Example: An order needs at least one account (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "at least one account is required",
    "field": "orders[0].accounts"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Profile

Who you are calling as, and with which credential. Handy to check a key or partner token.

### GET /v1/user/profile

Access: any valid credential, whatever its scopes

Any valid credential may call it, whatever its scopes. Secrets are never returned.

```bash
curl https://api.cirrus.trade/v1/user/profile \
  -H "Authorization: token $CIRRUS_API_KEY"

# {"status": "success", "data": {"username": "trader@example.com",
#   "auth": {"kind": "api_key", "key_id": "ck_…", "name": "my bot",
#            "scopes": ["read", "orders"], "ip_allowlist": []}}, "error": null}
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `username` | `string` | yes | The user's id. |
| `auth` | `object \| object \| object` | yes | The credential of this request, told apart by `kind`: `session` (the signed-in app), `api_key` or `partner` (a connected partner app). |
| `auth.impersonated` | `boolean` | yes | When `type` is `Option 1`. `true` when an operator is acting as the user. |
| `auth.scopes` | `string[]` | yes | When `type` is `Option 1`. Always every scope. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `auth.kind` | `string` | yes | When `type` is `Option 1`. Always `session`. |
| `auth.key_id` | `string` | yes | When `type` is `Option 2`. The key's public id. |
| `auth.name` | `string` | yes | When `type` is `Option 2`. The user's name for the key. |
| `auth.scopes` | `string[]` | yes | When `type` is `Option 2`. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `auth.ip_allowlist` | `string[]` | yes | When `type` is `Option 2`. IP addresses the key may be used from (empty: any address). |
| `auth.kind` | `string` | yes | When `type` is `Option 2`. Always `api_key`. |
| `auth.client_id` | `string` | yes | When `type` is `Option 3`. The partner app's client id. |
| `auth.name` | `string` | yes | When `type` is `Option 3`. The partner app's name. |
| `auth.scopes` | `string[]` | yes | When `type` is `Option 3`. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `auth.kind` | `string` | yes | When `type` is `Option 3`. Always `partner`. |

**Example: Called with an IP-restricted API key (200)**

```json
{
  "status": "success",
  "data": {
    "username": "user-01M65VA95T7KHB54A65SPDCDN8",
    "auth": {
      "kind": "api_key",
      "key_id": "ck_HER43mGjt79JtHSu5YT6",
      "name": "Reporting",
      "scopes": [
        "read"
      ],
      "ip_allowlist": [
        "203.0.113.7"
      ]
    }
  },
  "error": null
}
```

**Example: Signed in to the app (200)**

```json
{
  "status": "success",
  "data": {
    "username": "user-01M65VA95T7KHB54A65SPDCDN8",
    "auth": {
      "kind": "session",
      "impersonated": false,
      "scopes": [
        "read",
        "orders",
        "triggers",
        "strategies",
        "strategies_live",
        "strategies_signal",
        "webhooks",
        "notifications",
        "watchlists"
      ]
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Activity log

Everything that happened, in plain English: order actions, fills, rejections, triggers, signals and access changes. One trading day (IST) per query; the same records arrive live as stream `activity` messages.

### GET /v1/activity

Scope: `read`

One day of the log, newest first, in pages. Filters combine; comma-separated lists match any value.

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `date` | `string` | no | Trading day `YYYY-MM-DD` (IST); today when absent. |
| `account` | `string` | no | Only records touching this account. |
| `kind` | `string` | no | Comma-separated `ActivityKind` values (e.g. `place,cancel`). |
| `category` | `string` | no | Comma-separated `ActivityCategory` values (e.g. `orders,protection`; also `signals`, `account`, `access`, `strategies`). |
| `outcome` | `string` | no | Comma-separated `ActivityOutcome` values (e.g. `failed,partial`). |
| `q` | `string` | no | Case-insensitive text in the summary or trading symbol (at most 64 characters). |
| `client_id` | `string` | no | Only records made by this partner connection (the `client_id` of the record's source). |
| `cursor` | `string` | no | `next_cursor` of the previous page. |
| `limit` | `integer (int32)` | no | Page size, at least 1 (default 50; above 200 means 200). |

```bash
curl "https://api.cirrus.trade/v1/activity?category=orders&outcome=failed&limit=50" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes | Records without `timeline` (see `GET /v1/activity/{id}`). |
| `items[].id` | `string` | yes | ULID (sorts by creation time). |
| `items[].username` | `string` | yes |  |
| `items[].at` | `string (date-time)` | yes | When the action started. For a dependent order (`depends_on`): when its wait for the parent ended and it was sent (or refused). |
| `items[].kind` | `string` | yes | What happened. Serialized snake_case (`order_filled`, `gtt_create`...). One of: `place`, `bracket`, `modify`, `cancel`, `convert`, `order_filled`, `order_partially_filled`, `order_rejected`, `order_cancelled_by_broker`, `external_order`, `order_unknown`, `gtt_create`, `gtt_modify`, `gtt_delete`, `gtt_triggered`, `trigger_create`, `trigger_modify`, `trigger_delete`, `trigger_hit`, `trigger_exit`, `protection_armed`, `protection_failed`, `signal`, `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `portfolio_refresh`, `account_setup_incomplete`, `api_key_created`, `api_key_revoked`, `partner_connected`, `partner_revoked`, `partner_app_disabled`, `partner_app_secret_rotated`, `connection_limits_changed`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`, `strategy_created`, `strategy_updated`, `strategy_archived`, `strategy_version_saved`, `deployment_started`, `deployment_paused`, `deployment_resumed`, `deployment_stopped`, `deployment_went_live`, `deployment_exit_now`, `strategies_stop_all`, `backtest_submitted`, `risk_limits_changed`, `basket_saved`, `basket_deleted`. |
| `items[].category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account`, `access` or `strategies`. One of: `orders`, `protection`, `signals`, `account`, `access`, `strategies`. |
| `items[].source` | `object` | yes | Who or what started an activity. |
| `items[].source.type` | `string` | yes | Who or what started it: `app` (signed-in app session), `api_key`, `partner`, `signal` (a signal URL), `trigger` (a stop-loss / target trigger), `broker` (the broker or exchange) or `system` (housekeeping). One of: `app`, `api_key`, `partner`, `signal`, `trigger`, `broker`, `system`. |
| `items[].source.name` | `string \| null` | yes | API key name, partner app name, "TradingView: <strategy>", "Stop-loss" / "Target" / "Trailing SL", broker name. |
| `items[].source.client_id` | `string \| null` | no | The partner connection that acted (`partner` sources only). |
| `items[].source.via` | `string \| null` | no | The channel the request came in through (`app`, `api` or `mcp`); absent on records made before it was recorded. One of: `app`, `api`, `mcp`. |
| `items[].source.agent` | `object \| null` | no | The AI agent, for `via: mcp`. |
| `items[].source.agent.name` | `string` | yes | The agent's own name, e.g. "Claude". |
| `items[].source.agent.version` | `string \| null` | no | Its version, when it said. |
| `items[].source.agent.verified` | `boolean` | yes | Whether the name was verified (otherwise it is self-reported). |
| `items[].source.tool` | `string \| null` | no | The MCP tool the agent called, for `via: mcp`. |
| `items[].request_id` | `string \| null` | yes | The `x-request-id` of the API call that started it, if any. |
| `items[].instrument` | `object \| null` | yes |  |
| `items[].instrument.instrument_token` | `string` | yes | Instrument id (also returned as `instrument_token`). |
| `items[].instrument.tradingsymbol` | `string` | yes |  |
| `items[].instrument.exchange` | `string \| null` | yes |  |
| `items[].side` | `string \| null` | yes | Order side. One of: `BUY`, `SELL`. |
| `items[].order_type` | `string \| null` | yes | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `items[].product` | `string \| null` | yes | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `items[].quantity` | `integer (int64) \| null` | yes |  |
| `items[].price` | `number (double) \| null` | yes |  |
| `items[].trigger_price` | `number (double) \| null` | yes |  |
| `items[].order_tag` | `string \| null` | no | The order this record is about, when it is about one order (a fill, a rejection, protection armed / failed for an entry). |
| `items[].summary` | `string` | yes |  |
| `items[].message` | `string \| null` | yes | Plain reason for records without a per-account result (an ignored signal, a lost session, protection that failed...). |
| `items[].outcome` | `string` | yes | `ok` (every account succeeded), `partial` or `failed` (none did). One of: `ok`, `partial`, `failed`. |
| `items[].duration_ms` | `integer (int64)` | yes | How long the action took. For a dependent order: from its send to the brokers' answer (the wait for its parent is not counted). |
| `items[].accounts` | `object[]` | yes |  |
| `items[].accounts[].account` | `string` | yes |  |
| `items[].accounts[].broker` | `string` | yes |  |
| `items[].accounts[].status` | `string` | yes | What happened in one account: `placed` (accepted by the broker and working), `done` (completed), `rejected` (refused by the broker or exchange), `failed` (could not be sent, or did not complete) or `unknown` (no answer in time; the broker may or may not have it). One of: `placed`, `done`, `rejected`, `failed`, `unknown`. |
| `items[].accounts[].order_ids` | `string[]` | yes |  |
| `items[].accounts[].message` | `string \| null` | yes | Plain-English reason, e.g. "Zerodha rejected it: not enough funds". |
| `items[].accounts[].broker_message` | `string \| null` | yes | The broker's own text, unchanged. |
| `items[].accounts[].duration_ms` | `integer (int64) \| null` | yes |  |
| `items[].accounts[].route` | `string \| null` | no | How the broker call was sent: `direct` or `static_ip`. |
| `items[].accounts[].static_ip` | `string \| null` | no | The account's static IP when used. |
| `items[].accounts[].proxy_ms` | `integer (int64) \| null` | no | Latest connect time to that static-IP proxy, in milliseconds. |
| `items[].depends_on` | `object \| null` | no | Only on records of a dependent order (`depends_on`). |
| `items[].depends_on.order_ref` | `string` | yes | `id` of the parent order's activity record (same request). |
| `items[].depends_on.tradingsymbol` | `string` | yes |  |
| `items[].depends_on.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `items[].depends_on.waited_ms` | `integer (int64) \| null` | yes | Longest wait for the parent in any account; `null` when the parent was never placed, so there was nothing to wait for. |
| `items[].depends_on.outcome` | `string` | yes | How a dependent order's wait for its parent ended: `filled` (the parent filled; this order was sent), `partial` (sent for the parent's filled share, `depends_on_partial: filled_qty`), `failed` (the parent ended unfilled or was not placed) or `timeout` (the wait's deadline passed). `failed` and `timeout` reject the order with `dependency_failed`. One of: `filled`, `partial`, `failed`, `timeout`. |
| `items[].timeline` | `object[] \| null` | no | Only on detail responses and in the archive. |
| `items[].timeline[].at` | `string (date-time)` | yes |  |
| `items[].timeline[].account` | `string` | yes |  |
| `items[].timeline[].order_id` | `string \| null` | yes |  |
| `items[].timeline[].state` | `string` | yes | `SUBMITTED`, `OPEN`, `FILLED`, `PARTIALLY_FILLED`, `CANCELLED`, ... |
| `items[].timeline[].message` | `string \| null` | yes |  |
| `items[].timeline[].filled_qty` | `integer (int64) \| null` | yes | Filled so far at this step (when the journal knows). |
| `items[].timeline[].average_price` | `number (double) \| null` | yes |  |
| `next_cursor` | `string \| null` | yes | Pass as `cursor` for the next (older) page; null on the last page. |
| `source` | `string` | yes | Where a page was read from: `hot` (the live store) or `archive` (the day's archive, for older days). One of: `hot`, `archive`. |

**Example: Only successful order records of one account (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "id": "01M660NFS333BMRSCBY7Z9KW7V",
        "username": "user-01M660NFQ3RXTAEFHAEZA14Z4W",
        "at": "2026-10-30T09:01:16.924368667Z",
        "kind": "place",
        "category": "orders",
        "source": {
          "type": "app",
          "name": null,
          "via": "app"
        },
        "request_id": "01M660NFQW7YEQ6KERHPNKY8ZF",
        "instrument": {
          "tradingsymbol": "RELIANCE-EQ",
          "exchange": "NSE",
          "instrument_token": "CT:TEST:RELIANCE"
        },
        "side": "BUY",
        "order_type": "LIMIT",
        "product": "DELIVERY",
        "quantity": 1,
        "price": 2500,
        "trigger_price": null,
        "summary": "Buy 1 RELIANCE at ₹2,500 (Limit) in 1 account",
        "message": null,
        "outcome": "ok",
        "duration_ms": 39,
        "accounts": [
          {
            "account": "P1",
            "broker": "paper",
            "status": "placed",
            "order_ids": [
              "PAPER-1"
            ],
            "message": null,
            "broker_message": null,
            "duration_ms": 1,
            "route": "direct"
          }
        ]
      }
    ],
    "next_cursor": null,
    "source": "hot"
  },
  "error": null
}
```

**Example: Today's records, newest first (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "id": "01M660NFS333BMRSCBY7Z9KW7V",
        "username": "user-01M660NFQ3RXTAEFHAEZA14Z4W",
        "at": "2026-10-30T09:01:16.924368667Z",
        "kind": "place",
        "category": "orders",
        "source": {
          "type": "app",
          "name": null,
          "via": "app"
        },
        "request_id": "01M660NFQW7YEQ6KERHPNKY8ZF",
        "instrument": {
          "tradingsymbol": "RELIANCE-EQ",
          "exchange": "NSE",
          "instrument_token": "CT:TEST:RELIANCE"
        },
        "side": "BUY",
        "order_type": "LIMIT",
        "product": "DELIVERY",
        "quantity": 1,
        "price": 2500,
        "trigger_price": null,
        "summary": "Buy 1 RELIANCE at ₹2,500 (Limit) in 1 account",
        "message": null,
        "outcome": "ok",
        "duration_ms": 39,
        "accounts": [
          {
            "account": "P1",
            "broker": "paper",
            "status": "placed",
            "order_ids": [
              "PAPER-1"
            ],
            "message": null,
            "broker_message": null,
            "duration_ms": 1,
            "route": "direct"
          }
        ]
      }
    ],
    "next_cursor": null,
    "source": "hot"
  },
  "error": null
}
```

**Example: An unknown kind is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "unknown kind `teleport`",
    "field": "kind"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `archive_day_too_large` | That day's archived activity is too large to serve at once. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/activity/{id}

Scope: `read`

One record with its `timeline`: every step of the orders it touched, oldest first.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Record id (a ULID). |

```bash
curl "https://api.cirrus.trade/v1/activity/<id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | ULID (sorts by creation time). |
| `username` | `string` | yes |  |
| `at` | `string (date-time)` | yes | When the action started. For a dependent order (`depends_on`): when its wait for the parent ended and it was sent (or refused). |
| `kind` | `string` | yes | What happened. Serialized snake_case (`order_filled`, `gtt_create`...). One of: `place`, `bracket`, `modify`, `cancel`, `convert`, `order_filled`, `order_partially_filled`, `order_rejected`, `order_cancelled_by_broker`, `external_order`, `order_unknown`, `gtt_create`, `gtt_modify`, `gtt_delete`, `gtt_triggered`, `trigger_create`, `trigger_modify`, `trigger_delete`, `trigger_hit`, `trigger_exit`, `protection_armed`, `protection_failed`, `signal`, `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `portfolio_refresh`, `account_setup_incomplete`, `api_key_created`, `api_key_revoked`, `partner_connected`, `partner_revoked`, `partner_app_disabled`, `partner_app_secret_rotated`, `connection_limits_changed`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`, `strategy_created`, `strategy_updated`, `strategy_archived`, `strategy_version_saved`, `deployment_started`, `deployment_paused`, `deployment_resumed`, `deployment_stopped`, `deployment_went_live`, `deployment_exit_now`, `strategies_stop_all`, `backtest_submitted`, `risk_limits_changed`, `basket_saved`, `basket_deleted`. |
| `category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account`, `access` or `strategies`. One of: `orders`, `protection`, `signals`, `account`, `access`, `strategies`. |
| `source` | `object` | yes | Who or what started an activity. |
| `source.type` | `string` | yes | Who or what started it: `app` (signed-in app session), `api_key`, `partner`, `signal` (a signal URL), `trigger` (a stop-loss / target trigger), `broker` (the broker or exchange) or `system` (housekeeping). One of: `app`, `api_key`, `partner`, `signal`, `trigger`, `broker`, `system`. |
| `source.name` | `string \| null` | yes | API key name, partner app name, "TradingView: <strategy>", "Stop-loss" / "Target" / "Trailing SL", broker name. |
| `source.client_id` | `string \| null` | no | The partner connection that acted (`partner` sources only). |
| `source.via` | `string \| null` | no | The channel the request came in through (`app`, `api` or `mcp`); absent on records made before it was recorded. One of: `app`, `api`, `mcp`. |
| `source.agent` | `object \| null` | no | The AI agent, for `via: mcp`. |
| `source.agent.name` | `string` | yes | The agent's own name, e.g. "Claude". |
| `source.agent.version` | `string \| null` | no | Its version, when it said. |
| `source.agent.verified` | `boolean` | yes | Whether the name was verified (otherwise it is self-reported). |
| `source.tool` | `string \| null` | no | The MCP tool the agent called, for `via: mcp`. |
| `request_id` | `string \| null` | yes | The `x-request-id` of the API call that started it, if any. |
| `instrument` | `object \| null` | yes |  |
| `instrument.instrument_token` | `string` | yes | Instrument id (also returned as `instrument_token`). |
| `instrument.tradingsymbol` | `string` | yes |  |
| `instrument.exchange` | `string \| null` | yes |  |
| `side` | `string \| null` | yes | Order side. One of: `BUY`, `SELL`. |
| `order_type` | `string \| null` | yes | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `product` | `string \| null` | yes | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `quantity` | `integer (int64) \| null` | yes |  |
| `price` | `number (double) \| null` | yes |  |
| `trigger_price` | `number (double) \| null` | yes |  |
| `order_tag` | `string \| null` | no | The order this record is about, when it is about one order (a fill, a rejection, protection armed / failed for an entry). |
| `summary` | `string` | yes |  |
| `message` | `string \| null` | yes | Plain reason for records without a per-account result (an ignored signal, a lost session, protection that failed...). |
| `outcome` | `string` | yes | `ok` (every account succeeded), `partial` or `failed` (none did). One of: `ok`, `partial`, `failed`. |
| `duration_ms` | `integer (int64)` | yes | How long the action took. For a dependent order: from its send to the brokers' answer (the wait for its parent is not counted). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].broker` | `string` | yes |  |
| `accounts[].status` | `string` | yes | What happened in one account: `placed` (accepted by the broker and working), `done` (completed), `rejected` (refused by the broker or exchange), `failed` (could not be sent, or did not complete) or `unknown` (no answer in time; the broker may or may not have it). One of: `placed`, `done`, `rejected`, `failed`, `unknown`. |
| `accounts[].order_ids` | `string[]` | yes |  |
| `accounts[].message` | `string \| null` | yes | Plain-English reason, e.g. "Zerodha rejected it: not enough funds". |
| `accounts[].broker_message` | `string \| null` | yes | The broker's own text, unchanged. |
| `accounts[].duration_ms` | `integer (int64) \| null` | yes |  |
| `accounts[].route` | `string \| null` | no | How the broker call was sent: `direct` or `static_ip`. |
| `accounts[].static_ip` | `string \| null` | no | The account's static IP when used. |
| `accounts[].proxy_ms` | `integer (int64) \| null` | no | Latest connect time to that static-IP proxy, in milliseconds. |
| `depends_on` | `object \| null` | no | Only on records of a dependent order (`depends_on`). |
| `depends_on.order_ref` | `string` | yes | `id` of the parent order's activity record (same request). |
| `depends_on.tradingsymbol` | `string` | yes |  |
| `depends_on.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `depends_on.waited_ms` | `integer (int64) \| null` | yes | Longest wait for the parent in any account; `null` when the parent was never placed, so there was nothing to wait for. |
| `depends_on.outcome` | `string` | yes | How a dependent order's wait for its parent ended: `filled` (the parent filled; this order was sent), `partial` (sent for the parent's filled share, `depends_on_partial: filled_qty`), `failed` (the parent ended unfilled or was not placed) or `timeout` (the wait's deadline passed). `failed` and `timeout` reject the order with `dependency_failed`. One of: `filled`, `partial`, `failed`, `timeout`. |
| `timeline` | `object[] \| null` | no | Only on detail responses and in the archive. |
| `timeline[].at` | `string (date-time)` | yes |  |
| `timeline[].account` | `string` | yes |  |
| `timeline[].order_id` | `string \| null` | yes |  |
| `timeline[].state` | `string` | yes | `SUBMITTED`, `OPEN`, `FILLED`, `PARTIALLY_FILLED`, `CANCELLED`, ... |
| `timeline[].message` | `string \| null` | yes |  |
| `timeline[].filled_qty` | `integer (int64) \| null` | yes | Filled so far at this step (when the journal knows). |
| `timeline[].average_price` | `number (double) \| null` | yes |  |

**Example: An order placement with its timeline (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M660NFS344PVP5P58TW43D0B",
    "username": "user-01M660NFQ3BKPQ48Q3F079H9F3",
    "at": "2026-10-30T09:01:16.924345459Z",
    "kind": "place",
    "category": "orders",
    "source": {
      "type": "app",
      "name": null,
      "via": "app"
    },
    "request_id": "01M660NFQW7RSJDWQ5JTNJ4WSG",
    "instrument": {
      "tradingsymbol": "RELIANCE-EQ",
      "exchange": "NSE",
      "instrument_token": "CT:TEST:RELIANCE"
    },
    "side": "BUY",
    "order_type": "LIMIT",
    "product": "DELIVERY",
    "quantity": 1,
    "price": 2500,
    "trigger_price": null,
    "summary": "Buy 1 RELIANCE at ₹2,500 (Limit) in 1 account",
    "message": null,
    "outcome": "ok",
    "duration_ms": 39,
    "accounts": [
      {
        "account": "P1",
        "broker": "paper",
        "status": "placed",
        "order_ids": [
          "PAPER-1"
        ],
        "message": null,
        "broker_message": null,
        "duration_ms": 1,
        "route": "direct"
      }
    ],
    "timeline": [
      {
        "at": "2026-10-30T09:01:18.070171Z",
        "account": "P1",
        "order_id": "PAPER-1",
        "state": "OPEN",
        "message": null,
        "filled_qty": 0,
        "average_price": null
      },
      {
        "at": "2026-10-30T09:01:19.070171Z",
        "account": "P1",
        "order_id": "PAPER-1",
        "state": "FILLED",
        "message": null,
        "filled_qty": 1,
        "average_price": 2499.5
      }
    ]
  },
  "error": null
}
```

**Example: No record with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "activity_not_found",
    "message": "Activity not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `activity_not_found` | No activity record with this id for the caller. |
| 422 | `archive_day_too_large` | That day's archived activity is too large to serve at once. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Orders

Place, modify and cancel orders in one or many accounts, and estimate margin first.

### GET /v1/orders/{order_id}

Scope: `read`

One order

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag` (1-64 letters, digits, `-`, `_`, `.` or `:`). |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | Only this account's book; every linked account when absent. |

```bash
curl "https://api.cirrus.trade/v1/orders/<order_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_tag` | `string` | yes | The order's id, chosen when it was placed (a ULID). |
| `broker_tag` | `string \| null` | no | Tag as sent to the broker (format depends on the broker). |
| `order_id` | `string \| null` | no | Broker-assigned order id; `null` until the broker acknowledges. |
| `parent_tag` | `string \| null` | no | `order_tag` of the order this one belongs to (a protective leg's entry), if any. |
| `username` | `string` | yes |  |
| `account` | `string` | yes |  |
| `broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `instrument_token` | `string` | yes | Instrument id. |
| `tradingsymbol` | `string` | yes |  |
| `side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `quantity` | `integer (int64)` | yes |  |
| `price` | `number (double)` | yes | Limit price; `0` for market orders. |
| `trigger_price` | `number (double) \| null` | no |  |
| `state` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `filled_qty` | `integer (int64)` | yes |  |
| `average_price` | `number (double) \| null` | no |  |
| `status_message` | `string \| null` | no | The broker's latest status text (rejection reason, ...). |
| `broker_updated_at` | `string (date-time) \| null` | no | Broker time of the last update applied. |
| `created_at` | `string (date-time)` | yes |  |
| `status_history` | `object[]` | yes | Every state change so far, oldest first. |
| `status_history[].from` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `status_history[].to` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `status_history[].at` | `string (date-time)` | yes |  |
| `status_history[].message` | `string \| null` | yes | The broker's status text at this step, if any. |
| `client_tag` | `string \| null` | no | The caller's own reference, as given in `client_tag` when the order was placed; absent when none was given (or for orders placed elsewhere, e.g. in the broker's own app). |
| `partner_client_id` | `string \| null` | no | Client id of the partner app that placed the order; absent for orders placed any other way. |
| `chase` | `object \| null` | no | The order's `chase` settings and the limit it was first sent with; absent when it asked for none. |
| `chase.every_ms` | `integer (int64)` | yes |  |
| `chase.attempts` | `integer (int32)` | yes |  |
| `chase.max_slippage_pct` | `number (double)` | yes |  |
| `chase.on_give_up` | `string` | yes | What happens to a still-open order when the chase ends unfilled. One of: `rest`, `cancel`. |
| `chase.first_price` | `number (double)` | yes | The limit the order was first sent with. |
| `leg_role` | `string \| null` | no | For an exit leg of a bracket / cover order: `stop_loss`, `target` or the broker's `square_off` (its entry is `parent_tag`); absent for every other order. One of: `stop_loss`, `target`, `square_off`. |
| `chase_progress` | `object \| null` | no | How far the order's `chase` got (attempts used, how it ended); absent before its first re-price attempt. |
| `chase_progress.steps` | `integer (int32)` | yes | Re-price attempts used so far (of the plan's `attempts`). |
| `chase_progress.outcome` | `string` | yes | `running` (still re-pricing), `filled`, `gave_up` (stopped at its attempts or slippage bound and left resting, or its cancel failed), `cancelled` (stopped and cancelled as asked) or `stopped` (the order was changed, cancelled or rejected outside the chase). One of: `running`, `filled`, `gave_up`, `cancelled`, `stopped`. |
| `strategy` | `string \| null` | no | The strategy of the v2 trigger that placed the order; absent for every other order. |
| `split_from` | `object \| null` | no | When the broker split a placed order into several orders (its freeze-quantity cap, e.g. Pocketful at 500 units or Zerodha's autoslice) and lists only those: each is its own order (its own `order_id`, quantity and fills) and this names the order as placed. The first part keeps the placed `order_tag`. Change or cancel each part by its own `order_id`; the placed id still cancels every open part, or changes their price / trigger (not their quantity). Absent for every other order. |
| `split_from.order_id` | `string \| null` | yes | The broker order id the placement returned; null when it named none. |
| `split_from.order_tag` | `string` | yes | The `order_tag` the order was placed with (its first part keeps it). |
| `split_from.quantity` | `integer (int64)` | yes | Units the order was placed for. |
| `split_from.cirrus_tag` | `string` | no | **Deprecated.** The `order_tag` the order was placed with (its first part keeps it). |
| `lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |

**Example: Found by its client_tag (200)**

```json
{
  "status": "success",
  "data": {
    "broker_tag": null,
    "order_id": "250929000101",
    "parent_tag": null,
    "username": "user-01M3PGPRXSNM34C3C6AM1B721X",
    "account": "P1",
    "broker": "paper",
    "tradingsymbol": "RELIANCE-EQ",
    "side": "BUY",
    "order_type": "LIMIT",
    "product": "MIS",
    "quantity": 5,
    "price": 2500,
    "trigger_price": null,
    "state": "OPEN",
    "filled_qty": 0,
    "average_price": null,
    "status_message": null,
    "broker_updated_at": null,
    "created_at": "2026-09-29T12:02:21.780809Z",
    "status_history": [],
    "client_tag": "strat-7:entry",
    "lot_size": 1,
    "exchange": "NSE",
    "instrument_type": "EQUITY",
    "symbol": "RELIANCE",
    "expiry": "",
    "strike": 0,
    "option_type": "",
    "ltp": 2500,
    "prev_close": 2490,
    "instrument_token": "CT:TEST:RELIANCE",
    "order_tag": "01M3PGPRYMRRAPV8MCBEA19MS1"
  },
  "error": null
}
```

**Example: Not in today's order book (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "order_not_found",
    "message": "No order with this id in today's order book",
    "field": null
  }
}
```

**Example: One broker id in two accounts: add `account` (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "ambiguous_order",
    "message": "This id names 2 orders (accounts: P1, P2); add account= to pick one, or use the order_tag",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `order_not_found` | No order with this id (broker order id or `order_tag`) in the caller's order book today. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `ambiguous_order` | The id names orders in more than one account (or several orders); `message` lists the accounts. Add `account=` to choose one. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/orders/{order_id}/protection

Scope: `read`

An order's protection

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | `string` | yes | Broker order id, `order_tag` or `client_tag`. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | Only this account's book; every linked account when absent. |

```bash
curl "https://api.cirrus.trade/v1/orders/<order_id>/protection" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order_tag` | `string` | yes | The order's `order_tag`. |
| `account` | `string` | yes |  |
| `status` | `string` | yes | `none` (no stop-loss / target recorded for it), `waiting_for_fill` (armed once the entry fills) or `armed` (protecting what filled). |
| `route` | `string \| null` | yes | Where it lives; null when `none`. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `rules` | `object \| null` | yes | The stop-loss / target / trail asked for; null when `none`. |
| `rules.stop_loss` | `object \| null` | yes | One part of the protection asked for. |
| `rules.stop_loss.type` | `string` | yes | `percentage` (of the entry price), `points` (from it) or `price`. |
| `rules.stop_loss.value` | `number (double)` | yes |  |
| `rules.target` | `object \| null` | yes | One part of the protection asked for. |
| `rules.target.type` | `string` | yes | `percentage` (of the entry price), `points` (from it) or `price`. |
| `rules.target.value` | `number (double)` | yes |  |
| `rules.trail` | `object \| null` | yes | One part of the protection asked for. |
| `rules.trail.type` | `string` | yes | `percentage` (of the entry price), `points` (from it) or `price`. |
| `rules.trail.value` | `number (double)` | yes |  |
| `broker_id` | `string \| null` | yes | The broker's bracket / cover / GTT id, when the broker holds it. |
| `triggers` | `object[]` | yes | The triggers holding it (empty while waiting, or when the broker holds it). |
| `triggers[].trigger_id` | `string` | yes |  |
| `triggers[].version` | `string` | yes | `v2`: open it at `GET /v2/triggers/{trigger_id}`; `v1`: at `GET /v1/triggers/{trigger_id}`. |
| `triggers[].quantity` | `integer (int64) \| null` | yes | Units it protects; null when not recorded (v1). |
| `triggers[].entry_price` | `number (double) \| null` | yes | The fill price its levels were measured from; null when not recorded. |

**Example: Protected by a v2 bracket after the fill (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "status": "armed",
    "route": "trigger",
    "rules": {
      "stop_loss": {
        "type": "points",
        "value": 20
      },
      "target": {
        "type": "points",
        "value": 40
      },
      "trail": null
    },
    "broker_id": null,
    "triggers": [
      {
        "trigger_id": "01JAC9X2K7QF0000000000TRG1",
        "version": "v2",
        "quantity": 5,
        "entry_price": 2499.5
      }
    ],
    "order_tag": "01JAC9X2K7QF0000000000PR01"
  },
  "error": null
}
```

**Example: Not in today's order book (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "order_not_found",
    "message": "No order with this id in today's order book",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `order_not_found` | No order with this id (broker order id or `order_tag`) in the caller's order book today. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `ambiguous_order` | The id names orders in more than one account (or several orders); `message` lists the accounts. Add `account=` to choose one. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Positions and portfolio

Read positions, holdings, trades and funds; exit or convert a position.

### POST /v1/positions/exit

Scope: `orders`

Exit a position

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | yes | A new UUID per exit (1-128 printable characters). |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id holding the position. |
| `instrument_token` | `string` | yes | Instrument id of the position. |
| `product` | `string` | yes | The position's product. One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `market_protection` | `boolean \| null` | no | Market protection on the exit order (default `true`): it is sent as a LIMIT a small band past the last traded price instead of a bare MARKET. On an illiquid contract that last trade may be stale, so the protected LIMIT can rest unfilled in the order book; send `false` to exit at MARKET. |

```bash
curl -X POST "https://api.cirrus.trade/v1/positions/exit" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "P1",
    "instrument_token": "CT:TEST:RELIANCE",
    "product": "MIS"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes |  |
| `instrument_token` | `string` | yes | Instrument id. |
| `product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `net_qty` | `integer (int64)` | yes | Net quantity open at the broker (positive long, negative short). |
| `working_exit_qty` | `integer (int64)` | yes | Units of exit orders already working, left out of this exit. |
| `exit_qty` | `integer (int64)` | yes | Units this exit sent. |
| `side` | `string` | yes | Side of the exit order (SELL closes a long). One of: `BUY`, `SELL`. |
| `cancelled_triggers` | `string[]` | yes | Ids of the triggers removed after the exit was accepted (server-side triggers, and the display triggers of broker-held GTT / bracket / cover protection, cancelled at the broker first). |
| `cancelled_brackets` | `string[]` | yes | Broker order ids of bracket / cover entries whose legs were cancelled after the exit was accepted. |
| `protection_errors` | `string[]` | yes | Protection that could NOT be removed after the exit was accepted, and why: it may still fire. Empty when all went. Remove each by hand (`DELETE /v1/triggers/{trigger_id}`, or at the broker). |
| `results` | `object[]` | yes | One result per slice of the exit order; check each `status`. When no slice was accepted nothing else changed (protection kept). |
| `results[].order_index` | `integer` | yes | Index of the order in the request. |
| `results[].account` | `string` | yes |  |
| `results[].instrument_token` | `string` | yes | Instrument id. |
| `results[].order_tag` | `string \| null` | yes | Order tag for this slice; present once a broker call was attempted. |
| `results[].quantity` | `integer (int64)` | yes | Units sent to the broker (after lot and multiplier sizing). |
| `results[].status` | `string` | yes | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). One of: `accepted`, `rejected`, `unknown`. |
| `results[].order_id` | `string \| null` | yes | Broker order id, when accepted. An accepted cover / bracket entry can have none yet (the broker's reply did not name the entry): it shows up in the order book, and `GET /v1/orders/{order_tag}` finds it once the book has it. |
| `results[].message` | `string \| null` | yes | Why it was rejected, or what is unknown. |
| `results[].order_type` | `string` | yes | Final order type/price after market protection. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `results[].price` | `number (double)` | yes |  |
| `results[].protection` | `string \| null` | no | Where this slice's protection lives; absent for unprotected orders. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `results[].protection_id` | `string \| null` | no | Broker basket id when the broker holds the legs from placement. |
| `results[].reason` | `string \| null` | no | Why it was not accepted (absent when accepted). One of: `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote`. |
| `results[].retry_after_ms` | `integer (int64) \| null` | no | With `reason: rate_limited`: when a retry can succeed, in ms. |
| `results[].client_tag` | `string \| null` | no | The order's `client_tag`, when it had one. |
| `results[].reduced_from` | `integer (int64) \| null` | no | Planned units for this slice, when `depends_on_partial: filled_qty` sent fewer. |
| `results[].trigger_v2_id` | `string \| null` | no | The v2 trigger (`/v2/triggers/{trigger_id}`) that protects this slice once it fills, when its stop-loss / target is held by the server as a v2 trigger: created on the first fill, for the filled quantity, levels measured from the fill price. Units filled later get triggers of their own (listed on `/v2/triggers`). Absent otherwise (no protection, broker-held, or a v1 trigger). |

**Example: Exited at market (200)**

```json
{
  "status": "success",
  "data": {
    "account": "P1",
    "product": "MIS",
    "net_qty": 5,
    "working_exit_qty": 0,
    "exit_qty": 5,
    "side": "SELL",
    "cancelled_triggers": [],
    "cancelled_brackets": [],
    "protection_errors": [],
    "results": [
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 5,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 2487.5,
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3PGPRYM2737PQRY9SS63E3P"
      }
    ],
    "instrument_token": "CT:TEST:RELIANCE"
  },
  "error": null
}
```

**Example: No open position in this product (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "already_flat",
    "message": "No open position to exit for this account, instrument and product",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `idempotency_key_required` | The `Idempotency-Key` header is missing (use a new UUID per order request). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `already_flat` | There is no open position to exit for this account, instrument and product. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `partial_lot_position` | The open position (less exits already working) is not a whole number of lots, so it cannot be fully exited through this route; `message` says how much remains. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Triggers

Alerts, stop-loss, targets, entries, ladders and loss limits the server watches tick by tick.

### GET /v2/triggers

Scope: `read`

List v2 triggers

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `group` | `string` | no | Only the triggers created with this `group` (exact match). |

```bash
curl "https://api.cirrus.trade/v2/triggers" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].trigger_id` | `string` | yes |  |
| `[].account` | `string` | yes |  |
| `[].strategy` | `string \| null` | yes |  |
| `[].kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `[].legs` | `object[]` | yes |  |
| `[].legs[].instrument_token` | `string` | yes |  |
| `[].legs[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `[].legs[].quantity` | `integer (int64)` | yes |  |
| `[].legs[].product` | `string \| null` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `[].spec` | `object \| null` | yes | The spec as created; null when not known here. |
| `[].valid_until` | `string \| null` | yes |  |
| `[].state` | `string` | yes | `submitted` until the engine reports, then the machine's state: `armed`, `pending`, `active`, `t1` … (ladder rungs), `paused`, and the ended ones `fired`, `done`, `target_hit`, `sl_hit`, `breached`, `expired`, `cancelled`, `errored`; `cancel_pending` on broker-held protection whose cancel the broker refused while still holding it. A protection held as a v1 record is `pending` until its entry fills, then `active` (or `cancel_pending`); ended, it shows one of the ended states above (`target_hit`, `sl_hit`, `fired`, `done` when the position was exited another way, `cancelled`, or `errored` when it stopped protecting because something failed or why is not known), with v1's reason in `reason` (e.g. `stop_loss_executed_by_broker`). Other values may appear. |
| `[].transition_seq` | `integer (int64)` | yes | Changes the engine made so far. |
| `[].reason` | `string \| null` | yes | Why it last changed, when the engine said (errors, expiry, …). A trigger the server ended on its own is `cancelled` with `day_ended` (intraday, after its session's close) or `contract_expired`. |
| `[].created_at` | `string \| null` | yes |  |
| `[].updated_at` | `string` | yes |  |
| `[].protection` | `map<string, object> \| null` | yes | What protects the position, part by part (`stop_loss`, `stop_condition`, `target`, `target_1` …, `target_condition`, `trail`): held by the broker (protects even when the server is down) or by the server. Null for alerts and risk limits, and for triggers created before protection was planned. |
| `[].protection.*` | `object` | no | One part of a v2 trigger's protection. |
| `[].protection.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `[].protection.*.broker_order_id` | `string \| null` | yes | The broker's GTT or order id, when the broker holds the part. |
| `[].protection.*.level` | `number (double) \| null` | yes | The part's price level, when it has one. |
| `[].protection.*.adjusted_by_server` | `boolean` | yes | The server moves the broker's level (trailing, ladder advance). |
| `[].protection.*.status` | `string` | yes | Where a part stands. One of: `protected`, `degraded`, `unknown`, `held_by_server`, `ended`. |
| `[].protection.*.note` | `string \| null` | yes | Why, in words. |
| `[].last_result` | `object` | no | The engine's answer to the last command it handled for this trigger (a refused modify, pause, resume or cancel shows here with its reason); absent until it answered one. |
| `[].last_result.op` | `string` | yes | `create`, `modify`, `cancel`, `pause` or `resume`. |
| `[].last_result.command_id` | `string` | yes | The command's id (`command_id` of the `202`). |
| `[].last_result.outcome` | `string` | yes | `applied`, `duplicate` (already applied) or `rejected` (other values may appear). |
| `[].last_result.reason` | `string \| null` | yes | Why it was refused; null otherwise. |
| `[].last_result.at` | `string` | yes | When the command was sent; RFC 3339, India time. |
| `[].entry` | `object` | no | The entry the server placed for this trigger (a bracket whose `entry` condition held): absent for triggers without one. `status` `outcome_unknown` means the entry may or may not be at the broker; it is never sent again (check the order book). |
| `[].entry.status` | `string` | yes | Where the entry stands: `sending` (being placed), `accepted` (at the broker, not filled yet), `filled`, `partially_filled`, `failed` (refused or ended with no fill; the trigger is cancelled), `outcome_unknown` (may or may not be at the broker; never sent again). Other values may appear. One of: `sending`, `accepted`, `filled`, `partially_filled`, `failed`, `outcome_unknown`, `other`. |
| `[].entry.filled_qty` | `integer (int64)` | yes | Units filled so far. |
| `[].entry.avg_price` | `number (double) \| null` | yes | Average fill price; null until something filled. |
| `[].entry.order_ids` | `string[]` | yes | The broker's order ids, one per slice once known. |
| `[].entry.reason` | `string \| null` | yes | Why it failed, or what is unknown; null otherwise. |
| `[].created_via` | `string` | no | How the trigger was made: `app` (the app), `api` (an API key or a partner app), `order_ticket` (the server, protecting a `POST /v1/orders` fill). Absent for triggers made before this was recorded. One of: `app`, `api`, `order_ticket`. |
| `[].route` | `string` | no | For a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): held by the broker, it protects the position even when the server is down; `trigger`: the server evaluates it (parts the broker backs show in `protection`). Absent for alerts and risk limits. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `[].route_reason` | `string` | no | Why that route, in words, when known. |
| `[].risk_progress` | `object` | no | For a `risk_limit` with `targets` or a `profit_trail`: which targets fired and where the trail stands, as of the trigger's last event. Absent for every other trigger. |
| `[].risk_progress.targets` | `integer` | yes | Targets in the spec. |
| `[].risk_progress.targets_fired` | `integer (int32)` | yes | Targets that fired: always the first ones (they fire in order, each once). |
| `[].risk_progress.trail_armed` | `boolean` | yes | The profit trail armed (MTM reached `profit_trail.arm_at`). |
| `[].risk_progress.trail_peak` | `number (double) \| null` | yes | The highest MTM since the trail armed (as of the last event: the trail's moves are reported once per trail distance); null before. |
| `[].risk_progress.trail_floor` | `number (double) \| null` | yes | The trail's floor (only ever rises); null before it armed. |
| `[].watch` | `object[]` | no | The instruments the conditions read but the trigger never trades, as created (`watch` of the create request, so a change round-trips). Absent when there are none. |
| `[].watch[].name` | `string` | yes | How conditions name it (`nifty` in `nifty.rsi(14, '5m')`). |
| `[].watch[].symbol` | `string` | yes | Its instrument token. |
| `[].group` | `string` | no | The label given at create (`group`); absent when none. |

**Example: Only the triggers of one `group` (200)**

```json
{
  "status": "success",
  "data": [
    {
      "trigger_id": "01M5YAYXAH85KQZNZ638FK6KJP",
      "account": "P1",
      "strategy": null,
      "kind": "bracket",
      "legs": [
        {
          "side": "BUY",
          "quantity": 5,
          "product": "MIS",
          "instrument_token": "CT:TEST:RELIANCE"
        }
      ],
      "spec": {
        "entry_px": 2500,
        "target": {
          "when": "ltp >= entry_px * 1.02"
        },
        "stop_loss": {
          "init_sl": "entry_px - 25"
        }
      },
      "valid_until": null,
      "state": "submitted",
      "transition_seq": 0,
      "reason": null,
      "created_at": "2026-10-27T14:57:16.052+05:30",
      "updated_at": "2026-10-27T14:57:16.052+05:30",
      "protection": {
        "stop_loss": {
          "held_by": "server",
          "broker_order_id": null,
          "level": 2475,
          "adjusted_by_server": false,
          "status": "held_by_server",
          "note": null
        },
        "target": {
          "held_by": "server",
          "broker_order_id": null,
          "level": 2550,
          "adjusted_by_server": false,
          "status": "held_by_server",
          "note": null
        }
      },
      "created_via": "app",
      "route": "trigger",
      "group": "nifty-targets-01M5YAYX93NMJZRR4MSWZR69FD"
    }
  ],
  "error": null
}
```

**Example: A bracket the engine has not reported on yet (200)**

```json
{
  "status": "success",
  "data": [
    {
      "trigger_id": "01M5VVFCDFABPK086FBZD11WZ5",
      "account": "P1",
      "strategy": null,
      "kind": "bracket",
      "legs": [
        {
          "side": "BUY",
          "quantity": 5,
          "product": "MIS",
          "instrument_token": "CT:TEST:RELIANCE"
        }
      ],
      "spec": {
        "entry_px": 2500,
        "target": {
          "when": "ltp >= entry_px * 1.02"
        },
        "stop_loss": {
          "init_sl": "entry_px - 25",
          "trail": {
            "step": "5"
          }
        }
      },
      "valid_until": null,
      "state": "submitted",
      "transition_seq": 0,
      "reason": null,
      "created_at": "2026-10-26T15:48:09.715+05:30",
      "updated_at": "2026-10-26T15:48:09.715+05:30",
      "protection": {
        "stop_loss": {
          "held_by": "server",
          "broker_order_id": null,
          "level": 2475,
          "adjusted_by_server": false,
          "status": "held_by_server",
          "note": null
        },
        "target": {
          "held_by": "server",
          "broker_order_id": null,
          "level": 2550,
          "adjusted_by_server": false,
          "status": "held_by_server",
          "note": null
        },
        "trail": {
          "held_by": "server",
          "broker_order_id": null,
          "level": 2475,
          "adjusted_by_server": false,
          "status": "held_by_server",
          "note": null
        }
      },
      "created_via": "app",
      "route": "trigger"
    }
  ],
  "error": null
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/triggers

Scope: `triggers`

Create a v2 trigger

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of creating another trigger. A `broker_outcome_unknown` answer is kept like any other: a repeat within 24 hours gets the same 504 back and creates nothing. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `strategy` | `string \| null` | no | Strategy the trigger belongs to (risk limits at strategy level). |
| `legs` | `object[]` | no |  |
| `legs[].instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `legs[].side` | `string` | yes | Side of the position's entry (`BUY` = long). One of: `BUY`, `SELL`. |
| `legs[].quantity` | `integer (int64)` | yes | Units. |
| `legs[].product` | `string \| null` | no | The position's product; required for `bracket` and `ladder`. One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `spec` | `object` | yes | Kind-specific rules, conditions written in the CEL subset (`ltp <= 120 && bid_qty > ask_qty`). Checked before anything is sent: an unknown field or a condition that does not compile is a 422. |
| `valid_until` | `string \| null` | no | RFC 3339; the trigger expires then. |
| `watch` | `object[]` | no | Instruments the conditions read but the trigger never trades (at most 3), for indicator conditions: `[{"name": "nifty", "symbol": "<instrument token>"}]` lets a condition say `nifty.rsi(14, '5m')` or `nifty.ltp`. Names are `[a-z][a-z0-9_]{0,15}`, not a variable or function name. Each counts toward the distinct-instrument limit. Fixed at create (a change keeps them). Refused (`422`) while indicator conditions are not available. |
| `watch[].name` | `string` | yes | How conditions name it: `[a-z][a-z0-9_]{0,15}` (`nifty` in `nifty.rsi(14, '5m')`). |
| `watch[].symbol` | `string` | yes | Its instrument token. |
| `route` | `string` | no | Where a `bracket` protecting a held position lives. `auto` (default) puts a simple protection (one leg already held, a plain price stop-loss and / or target, nothing else) at the broker with the rules of `POST /v1/triggers`: a broker GTT for a positional product where the broker has GTTs (a one-leg-GTT broker holds the stop-loss, a server-side trigger the target), a broker stop-loss order for an intraday (`MIS`) position where the broker's stop orders are proven; anything else is a v2 trigger the server evaluates. `trigger` always makes the v2 trigger (the earlier behaviour). `gtt` / `stop_order` ask for that route and are refused (422) where `auto` would not take it; `bracket` / `cover` are refused. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `group` | `string \| null` | no | A label of your own (1-64 characters), returned on reads and filterable on list (`?group=`): triggers made as one set (several targets as separate triggers) show as one. Free-form; nothing else reads it. |

```bash
curl -X POST "https://api.cirrus.trade/v2/triggers" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "PF1",
    "kind": "bracket",
    "legs": [
      {
        "instrument_token": "CT:TEST:RELIANCE",
        "side": "BUY",
        "quantity": 5,
        "product": "DELIVERY"
      }
    ],
    "spec": {
      "entry_px": 2500,
      "stop_loss": {
        "init_sl": "2475"
      },
      "target": {
        "when": "ltp >= 2550"
      }
    }
  }'
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `command_id` | `string` | yes | The command's id (each create / change / cancel has its own). |
| `status` | `string` | yes | Always `accepted`: the engine applies it within moments; read the trigger to see the result. |
| `route` | `string` | no | On create of a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): the broker holds it and keeps protecting the position when the server is down; it was placed before this answer and `trigger_id` names its display record. `trigger`: the server evaluates it. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words (e.g. why a broker did not take it). |
| `broker_cancel` | `string` | no | On cancel of broker-held protection: `cancelled` at the broker (or refused, and the broker shows it already ended), `pending` (the broker refused and still holds it: it stays live with `state` `cancel_pending`, re-checked until the broker ends it; the reason is in `broker_message`), or `failed` (the account is no longer linked; removed). |
| `broker_message` | `string` | no |  |

**Example: A plain stop-loss and target on a holding: placed at the broker as a GTT (here a one-leg GTT holding the stop-loss, a server-side trigger the target) (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M61186WVMVS2FEVEY51B7H76",
    "command_id": "01M61186WXQ1K6HPS3HD7CJNAE",
    "status": "accepted",
    "route": "gtt",
    "route_reason": "broker GTT holds the stop-loss, a server-side trigger the target (one-leg GTT broker)"
  },
  "error": null
}
```

**Example: A bracket on a long of 5: target +2%, stop-loss 25 below, trailing by 5 (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M65Q5EWB0KC0D6ZG7PT7QVB3",
    "command_id": "01M65Q5EWKP63YC0162BADKHRE",
    "status": "accepted",
    "route": "stop_order",
    "route_reason": "broker stop-loss order holds the stop-loss, a server-side trigger the target"
  },
  "error": null
}
```

**Example: A trigger of a set, labelled with `group` (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M5YAYXAH85KQZNZ638FK6KJP",
    "command_id": "01M5YAYXAN76K7W5EPJQP5WZSH",
    "status": "accepted",
    "route": "trigger",
    "route_reason": "trigger requested"
  },
  "error": null
}
```

**Example: Ids are made by the server: a request carrying one is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `id`, expected one of `account`, `kind`, `strategy`, `legs`, `spec`, `valid_until`, `watch`, `route`, `group`",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An account that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: A condition that does not compile is refused before anything is sent (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "target.when: condition ends too early (at byte 17)",
    "field": "spec"
  }
}
```

**Example: The trigger engine did not take the command; nothing was created (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Triggers are temporarily unavailable; nothing was changed. Retry shortly",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |
| 504 | `broker_outcome_unknown` | The broker did not answer in time: it may or may not have applied the change. Check before retrying. |

### GET /v2/triggers/{trigger_id}

Scope: `read`

One v2 trigger

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

```bash
curl "https://api.cirrus.trade/v2/triggers/<trigger_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `account` | `string` | yes |  |
| `strategy` | `string \| null` | yes |  |
| `kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `legs` | `object[]` | yes |  |
| `legs[].instrument_token` | `string` | yes |  |
| `legs[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `legs[].quantity` | `integer (int64)` | yes |  |
| `legs[].product` | `string \| null` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `spec` | `object \| null` | yes | The spec as created; null when not known here. |
| `valid_until` | `string \| null` | yes |  |
| `state` | `string` | yes | `submitted` until the engine reports, then the machine's state: `armed`, `pending`, `active`, `t1` … (ladder rungs), `paused`, and the ended ones `fired`, `done`, `target_hit`, `sl_hit`, `breached`, `expired`, `cancelled`, `errored`; `cancel_pending` on broker-held protection whose cancel the broker refused while still holding it. A protection held as a v1 record is `pending` until its entry fills, then `active` (or `cancel_pending`); ended, it shows one of the ended states above (`target_hit`, `sl_hit`, `fired`, `done` when the position was exited another way, `cancelled`, or `errored` when it stopped protecting because something failed or why is not known), with v1's reason in `reason` (e.g. `stop_loss_executed_by_broker`). Other values may appear. |
| `transition_seq` | `integer (int64)` | yes | Changes the engine made so far. |
| `reason` | `string \| null` | yes | Why it last changed, when the engine said (errors, expiry, …). A trigger the server ended on its own is `cancelled` with `day_ended` (intraday, after its session's close) or `contract_expired`. |
| `created_at` | `string \| null` | yes |  |
| `updated_at` | `string` | yes |  |
| `protection` | `map<string, object> \| null` | yes | What protects the position, part by part (`stop_loss`, `stop_condition`, `target`, `target_1` …, `target_condition`, `trail`): held by the broker (protects even when the server is down) or by the server. Null for alerts and risk limits, and for triggers created before protection was planned. |
| `protection.*` | `object` | no | One part of a v2 trigger's protection. |
| `protection.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `protection.*.broker_order_id` | `string \| null` | yes | The broker's GTT or order id, when the broker holds the part. |
| `protection.*.level` | `number (double) \| null` | yes | The part's price level, when it has one. |
| `protection.*.adjusted_by_server` | `boolean` | yes | The server moves the broker's level (trailing, ladder advance). |
| `protection.*.status` | `string` | yes | Where a part stands. One of: `protected`, `degraded`, `unknown`, `held_by_server`, `ended`. |
| `protection.*.note` | `string \| null` | yes | Why, in words. |
| `last_result` | `object` | no | The engine's answer to the last command it handled for this trigger (a refused modify, pause, resume or cancel shows here with its reason); absent until it answered one. |
| `last_result.op` | `string` | yes | `create`, `modify`, `cancel`, `pause` or `resume`. |
| `last_result.command_id` | `string` | yes | The command's id (`command_id` of the `202`). |
| `last_result.outcome` | `string` | yes | `applied`, `duplicate` (already applied) or `rejected` (other values may appear). |
| `last_result.reason` | `string \| null` | yes | Why it was refused; null otherwise. |
| `last_result.at` | `string` | yes | When the command was sent; RFC 3339, India time. |
| `entry` | `object` | no | The entry the server placed for this trigger (a bracket whose `entry` condition held): absent for triggers without one. `status` `outcome_unknown` means the entry may or may not be at the broker; it is never sent again (check the order book). |
| `entry.status` | `string` | yes | Where the entry stands: `sending` (being placed), `accepted` (at the broker, not filled yet), `filled`, `partially_filled`, `failed` (refused or ended with no fill; the trigger is cancelled), `outcome_unknown` (may or may not be at the broker; never sent again). Other values may appear. One of: `sending`, `accepted`, `filled`, `partially_filled`, `failed`, `outcome_unknown`, `other`. |
| `entry.filled_qty` | `integer (int64)` | yes | Units filled so far. |
| `entry.avg_price` | `number (double) \| null` | yes | Average fill price; null until something filled. |
| `entry.order_ids` | `string[]` | yes | The broker's order ids, one per slice once known. |
| `entry.reason` | `string \| null` | yes | Why it failed, or what is unknown; null otherwise. |
| `created_via` | `string` | no | How the trigger was made: `app` (the app), `api` (an API key or a partner app), `order_ticket` (the server, protecting a `POST /v1/orders` fill). Absent for triggers made before this was recorded. One of: `app`, `api`, `order_ticket`. |
| `route` | `string` | no | For a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): held by the broker, it protects the position even when the server is down; `trigger`: the server evaluates it (parts the broker backs show in `protection`). Absent for alerts and risk limits. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words, when known. |
| `risk_progress` | `object` | no | For a `risk_limit` with `targets` or a `profit_trail`: which targets fired and where the trail stands, as of the trigger's last event. Absent for every other trigger. |
| `risk_progress.targets` | `integer` | yes | Targets in the spec. |
| `risk_progress.targets_fired` | `integer (int32)` | yes | Targets that fired: always the first ones (they fire in order, each once). |
| `risk_progress.trail_armed` | `boolean` | yes | The profit trail armed (MTM reached `profit_trail.arm_at`). |
| `risk_progress.trail_peak` | `number (double) \| null` | yes | The highest MTM since the trail armed (as of the last event: the trail's moves are reported once per trail distance); null before. |
| `risk_progress.trail_floor` | `number (double) \| null` | yes | The trail's floor (only ever rises); null before it armed. |
| `watch` | `object[]` | no | The instruments the conditions read but the trigger never trades, as created (`watch` of the create request, so a change round-trips). Absent when there are none. |
| `watch[].name` | `string` | yes | How conditions name it (`nifty` in `nifty.rsi(14, '5m')`). |
| `watch[].symbol` | `string` | yes | Its instrument token. |
| `group` | `string` | no | The label given at create (`group`); absent when none. |

**Example: Protection held by a broker GTT (stop-loss) and the server (target) (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M61186WVMVS2FEVEY51B7H76",
    "account": "PF1",
    "strategy": null,
    "kind": "bracket",
    "legs": [
      {
        "side": "BUY",
        "quantity": 5,
        "product": "DELIVERY",
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ],
    "spec": {
      "entry_px": 2500,
      "stop_loss": {
        "init_sl": "2475"
      },
      "target": {
        "when": "ltp >= 2550"
      }
    },
    "valid_until": null,
    "state": "submitted",
    "transition_seq": 0,
    "reason": null,
    "created_at": "2026-10-28T16:05:18.300+05:30",
    "updated_at": "2026-10-28T16:05:18.300+05:30",
    "protection": {
      "stop_loss": {
        "held_by": "broker",
        "broker_order_id": "pf-gtt-1",
        "level": 2475,
        "adjusted_by_server": false,
        "status": "unknown",
        "note": "not read back as asked yet"
      },
      "target": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2550,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      }
    },
    "created_via": "app",
    "route": "gtt",
    "route_reason": "broker GTT holds the stop-loss, a server-side trigger the target (one-leg GTT broker)"
  },
  "error": null
}
```

**Example: A bracket whose entry the server placed and saw filled (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M5VVFCDF1Y25EXH1VBER1MEB",
    "account": "P1",
    "strategy": null,
    "kind": "bracket",
    "legs": [
      {
        "side": "BUY",
        "quantity": 5,
        "product": "MIS",
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ],
    "spec": {
      "entry_px": 2500,
      "target": {
        "when": "ltp >= entry_px * 1.02"
      },
      "stop_loss": {
        "init_sl": "entry_px - 25",
        "trail": {
          "step": "5"
        }
      }
    },
    "valid_until": null,
    "state": "active",
    "transition_seq": 1,
    "reason": null,
    "created_at": "2026-10-26T15:48:09.714+05:30",
    "updated_at": "2026-10-26T15:48:09.719+05:30",
    "protection": {
      "stop_loss": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2475,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      },
      "target": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2550,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      },
      "trail": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2475,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      }
    },
    "entry": {
      "status": "filled",
      "filled_qty": 5,
      "avg_price": 2500.5,
      "order_ids": [
        "240101000001"
      ],
      "reason": null
    },
    "created_via": "app",
    "route": "trigger"
  },
  "error": null
}
```

**Example: A risk limit after its first target: which targets fired and the profit trail (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M5YAR0RQTAEZWW84P44J9YP2",
    "account": "P1",
    "strategy": null,
    "kind": "risk_limit",
    "legs": [],
    "spec": {
      "level": "account",
      "mtm_floor": -5000,
      "profit_trail": {
        "arm_at": 3000,
        "step": 1000
      },
      "targets": [
        {
          "mtm": 4000,
          "action": "book_pct",
          "pct": 50
        },
        {
          "mtm": 8000,
          "action": "flatten"
        }
      ]
    },
    "valid_until": null,
    "state": "armed",
    "transition_seq": 2,
    "reason": "target 1 of 2 reached: mtm 4200 at or above 4000",
    "created_at": "2026-10-27T14:53:30.200+05:30",
    "updated_at": "2026-10-27T14:53:30.202+05:30",
    "protection": null,
    "risk_progress": {
      "targets": 2,
      "targets_fired": 1,
      "trail_armed": true,
      "trail_peak": 4200,
      "trail_floor": 3200
    }
  },
  "error": null
}
```

**Example: An active bracket after one change by the engine (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M5VVFCDF60NDFJ5SMKG675R2",
    "account": "P1",
    "strategy": null,
    "kind": "bracket",
    "legs": [
      {
        "side": "BUY",
        "quantity": 5,
        "product": "MIS",
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ],
    "spec": {
      "entry_px": 2500,
      "target": {
        "when": "ltp >= entry_px * 1.02"
      },
      "stop_loss": {
        "init_sl": "entry_px - 25",
        "trail": {
          "step": "5"
        }
      }
    },
    "valid_until": null,
    "state": "active",
    "transition_seq": 1,
    "reason": null,
    "created_at": "2026-10-26T15:48:09.715+05:30",
    "updated_at": "2026-10-26T15:48:09.719+05:30",
    "protection": {
      "stop_loss": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2480,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      },
      "target": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2550,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      },
      "trail": {
        "held_by": "server",
        "broker_order_id": null,
        "level": 2475,
        "adjusted_by_server": false,
        "status": "held_by_server",
        "note": null
      }
    },
    "created_via": "app",
    "route": "trigger"
  },
  "error": null
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v2/triggers/{trigger_id}

Scope: `triggers`

Change a v2 trigger

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `spec` | `object` | yes | The whole new spec (same shape as on create). |
| `valid_until` | `string \| null` | no | New expiry (RFC 3339); unchanged when absent; `null` removes it (the trigger then lives until it ends). |
| `route` | `string \| null` | no | Where the protection lives (`route` of the trigger). The route is chosen at create: the current one (or `auto`) is accepted and changes nothing; any other is refused (422 `route`) with how to move it. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |

```bash
curl -X PATCH "https://api.cirrus.trade/v2/triggers/<trigger_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "spec": {
      "entry_px": 2500,
      "target": {
        "when": "ltp >= entry_px * 1.02"
      },
      "stop_loss": {
        "init_sl": "entry_px - 25"
      }
    },
    "valid_until": null
  }'
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `command_id` | `string` | yes | The command's id (each create / change / cancel has its own). |
| `status` | `string` | yes | Always `accepted`: the engine applies it within moments; read the trigger to see the result. |
| `route` | `string` | no | On create of a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): the broker holds it and keeps protecting the position when the server is down; it was placed before this answer and `trigger_id` names its display record. `trigger`: the server evaluates it. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words (e.g. why a broker did not take it). |
| `broker_cancel` | `string` | no | On cancel of broker-held protection: `cancelled` at the broker (or refused, and the broker shows it already ended), `pending` (the broker refused and still holds it: it stays live with `state` `cancel_pending`, re-checked until the broker ends it; the reason is in `broker_message`), or `failed` (the account is no longer linked; removed). |
| `broker_message` | `string` | no |  |

**Example: `valid_until: null` removes the expiry (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M5YAYXAHWREX15A3Q3FMNGF9",
    "command_id": "01M5YAYXAXT49C0SMG4JJACVFQ",
    "status": "accepted"
  },
  "error": null
}
```

**Example: A tighter stop-loss (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M4GF38AQ39WVJZHH8YWWNNQZ",
    "command_id": "01M4GF38BEE9Y3N9T9M8EHNXKX",
    "status": "accepted"
  },
  "error": null
}
```

**Example: A field the change does not take (here an id) is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `id`, expected one of `spec`, `valid_until`, `route`",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Example: The trigger already ended (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_fired",
    "message": "This trigger already ended (sl_hit)",
    "field": null
  }
}
```

**Example: A spec with an unknown field is refused, never read as no stop-loss (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "spec: unknown field `stop_los`, expected one of `entry`, `entry_px`, `target`, `stop_loss`",
    "field": "spec"
  }
}
```

**Example: The route stays the one chosen at create (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "the route is chosen at create and stays `trigger`: moving the protection would leave the position unprotected in between; cancel the trigger and create it again with the route you want",
    "field": "route"
  }
}
```

**Example: The trigger engine did not take the command; nothing was changed (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Triggers are temporarily unavailable; nothing was changed. Retry shortly",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `trigger_fired` | The trigger already fired; its exit is being handled. |
| 409 | `broker_held` | The protection is held by the broker; change the broker order instead. |
| 409 | `trigger_busy` | The trigger changed while it was being updated; retry. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 502 | `broker_refused` | The broker refused the change. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |
| 504 | `broker_outcome_unknown` | The broker did not answer in time: it may or may not have applied the change. Check before retrying. |

### DELETE /v2/triggers/{trigger_id}

Scope: `triggers`

Cancel a v2 trigger

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v2/triggers/<trigger_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `command_id` | `string` | yes | The command's id (each create / change / cancel has its own). |
| `status` | `string` | yes | Always `accepted`: the engine applies it within moments; read the trigger to see the result. |
| `route` | `string` | no | On create of a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): the broker holds it and keeps protecting the position when the server is down; it was placed before this answer and `trigger_id` names its display record. `trigger`: the server evaluates it. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words (e.g. why a broker did not take it). |
| `broker_cancel` | `string` | no | On cancel of broker-held protection: `cancelled` at the broker (or refused, and the broker shows it already ended), `pending` (the broker refused and still holds it: it stays live with `state` `cancel_pending`, re-checked until the broker ends it; the reason is in `broker_message`), or `failed` (the account is no longer linked; removed). |
| `broker_message` | `string` | no |  |

**Example: Cancel handed to the engine (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M4GF38B4NDN7T4SDYK85MKNM",
    "command_id": "01M4GF38BK9BQ5Z9NTES6C5R31",
    "status": "accepted"
  },
  "error": null
}
```

**Example: The Idempotency-Key is not 1-128 printable characters (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_idempotency_key",
    "message": "Idempotency-Key must be 1-128 printable characters",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Example: The trigger already ended (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_fired",
    "message": "This trigger already ended (cancelled)",
    "field": null
  }
}
```

**Example: The Idempotency-Key was already used for another request (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request",
    "field": null
  }
}
```

**Example: The trigger engine did not take the command; nothing was changed (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Triggers are temporarily unavailable; nothing was changed. Retry shortly",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 409 | `trigger_fired` | The trigger already fired; its exit is being handled. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 502 | `broker_refused` | The broker refused the change. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/triggers/{trigger_id}/pause

Scope: `triggers`

Pause a v2 trigger

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X POST "https://api.cirrus.trade/v2/triggers/<trigger_id>/pause" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `command_id` | `string` | yes | The command's id (each create / change / cancel has its own). |
| `status` | `string` | yes | Always `accepted`: the engine applies it within moments; read the trigger to see the result. |
| `route` | `string` | no | On create of a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): the broker holds it and keeps protecting the position when the server is down; it was placed before this answer and `trigger_id` names its display record. `trigger`: the server evaluates it. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words (e.g. why a broker did not take it). |
| `broker_cancel` | `string` | no | On cancel of broker-held protection: `cancelled` at the broker (or refused, and the broker shows it already ended), `pending` (the broker refused and still holds it: it stays live with `state` `cancel_pending`, re-checked until the broker ends it; the reason is in `broker_message`), or `failed` (the account is no longer linked; removed). |
| `broker_message` | `string` | no |  |

**Example: Pause handed to the engine (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M4QNBZ1SPDJ4F3K9NA9H72SC",
    "command_id": "01M4QNBZ21PD87BG3EHS08AN78",
    "status": "accepted"
  },
  "error": null
}
```

**Example: The Idempotency-Key is not 1-128 printable characters (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_idempotency_key",
    "message": "Idempotency-Key must be 1-128 printable characters",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Example: The trigger already ended (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_fired",
    "message": "This trigger already ended (cancelled)",
    "field": null
  }
}
```

**Example: A protection held as a v1 record has no pause (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "this protection is a v1 record (made before 2026-10-28, or a broker bracket / cover) and cannot be paused: it keeps protecting the position; change its levels or cancel it",
    "field": "trigger_id"
  }
}
```

**Example: The Idempotency-Key was already used for another request (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request",
    "field": null
  }
}
```

**Example: The trigger engine did not take the command; nothing was changed (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Triggers are temporarily unavailable; nothing was changed. Retry shortly",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 409 | `trigger_fired` | The trigger already fired; its exit is being handled. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/triggers/{trigger_id}/resume

Scope: `triggers`

Resume a paused v2 trigger

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X POST "https://api.cirrus.trade/v2/triggers/<trigger_id>/resume" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `command_id` | `string` | yes | The command's id (each create / change / cancel has its own). |
| `status` | `string` | yes | Always `accepted`: the engine applies it within moments; read the trigger to see the result. |
| `route` | `string` | no | On create of a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): the broker holds it and keeps protecting the position when the server is down; it was placed before this answer and `trigger_id` names its display record. `trigger`: the server evaluates it. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words (e.g. why a broker did not take it). |
| `broker_cancel` | `string` | no | On cancel of broker-held protection: `cancelled` at the broker (or refused, and the broker shows it already ended), `pending` (the broker refused and still holds it: it stays live with `state` `cancel_pending`, re-checked until the broker ends it; the reason is in `broker_message`), or `failed` (the account is no longer linked; removed). |
| `broker_message` | `string` | no |  |

**Example: Resume of a paused trigger handed to the engine (202)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "01M4QNBZ1SH6BRRRMPJ7RRSVMY",
    "command_id": "01M4QNBZ1Z8KTB78CXT3BMKEBB",
    "status": "accepted"
  },
  "error": null
}
```

**Example: The Idempotency-Key is not 1-128 printable characters (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_idempotency_key",
    "message": "Idempotency-Key must be 1-128 printable characters",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Example: The trigger already ended (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_fired",
    "message": "This trigger already ended (cancelled)",
    "field": null
  }
}
```

**Example: A protection held as a v1 record has no pause (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "this protection is a v1 record (made before 2026-10-28, or a broker bracket / cover) and cannot be resumed: it keeps protecting the position; change its levels or cancel it",
    "field": "trigger_id"
  }
}
```

**Example: The Idempotency-Key was already used for another request (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request",
    "field": null
  }
}
```

**Example: The trigger engine did not take the command; nothing was changed (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Triggers are temporarily unavailable; nothing was changed. Retry shortly",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 409 | `trigger_fired` | The trigger already fired; its exit is being handled. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/triggers/validate

Scope: `triggers`

Check a v2 trigger without creating it

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `strategy` | `string \| null` | no | Strategy the trigger belongs to (risk limits at strategy level). |
| `legs` | `object[]` | no |  |
| `legs[].instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `legs[].side` | `string` | yes | Side of the position's entry (`BUY` = long). One of: `BUY`, `SELL`. |
| `legs[].quantity` | `integer (int64)` | yes | Units. |
| `legs[].product` | `string \| null` | no | The position's product; required for `bracket` and `ladder`. One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `spec` | `object` | yes | Kind-specific rules, conditions written in the CEL subset (`ltp <= 120 && bid_qty > ask_qty`). Checked before anything is sent: an unknown field or a condition that does not compile is a 422. |
| `valid_until` | `string \| null` | no | RFC 3339; the trigger expires then. |
| `watch` | `object[]` | no | Instruments the conditions read but the trigger never trades (at most 3), for indicator conditions: `[{"name": "nifty", "symbol": "<instrument token>"}]` lets a condition say `nifty.rsi(14, '5m')` or `nifty.ltp`. Names are `[a-z][a-z0-9_]{0,15}`, not a variable or function name. Each counts toward the distinct-instrument limit. Fixed at create (a change keeps them). Refused (`422`) while indicator conditions are not available. |
| `watch[].name` | `string` | yes | How conditions name it: `[a-z][a-z0-9_]{0,15}` (`nifty` in `nifty.rsi(14, '5m')`). |
| `watch[].symbol` | `string` | yes | Its instrument token. |
| `route` | `string` | no | Where a `bracket` protecting a held position lives. `auto` (default) puts a simple protection (one leg already held, a plain price stop-loss and / or target, nothing else) at the broker with the rules of `POST /v1/triggers`: a broker GTT for a positional product where the broker has GTTs (a one-leg-GTT broker holds the stop-loss, a server-side trigger the target), a broker stop-loss order for an intraday (`MIS`) position where the broker's stop orders are proven; anything else is a v2 trigger the server evaluates. `trigger` always makes the v2 trigger (the earlier behaviour). `gtt` / `stop_order` ask for that route and are refused (422) where `auto` would not take it; `bracket` / `cover` are refused. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `group` | `string \| null` | no | A label of your own (1-64 characters), returned on reads and filterable on list (`?group=`): triggers made as one set (several targets as separate triggers) show as one. Free-form; nothing else reads it. |

```bash
curl -X POST "https://api.cirrus.trade/v2/triggers/validate" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "P1",
    "kind": "bracket",
    "legs": [
      {
        "instrument_token": "CT:TEST:RELIANCE",
        "side": "BUY",
        "quantity": 5,
        "product": "MIS"
      }
    ],
    "spec": {
      "entry_px": 2500,
      "target": {
        "when": "ltp >= entry_px *"
      },
      "stop_loss": {
        "init_sl": "entry_px - 25"
      }
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `valid` | `boolean` | yes | `true` when create would accept it (no `errors`, within `limits`). |
| `errors` | `object[]` | yes |  |
| `errors[].field` | `string` | yes | The offending input path (`spec`, `legs[0].product`, ...). |
| `errors[].message` | `string` | yes |  |
| `reads` | `object` | yes | What the trigger's conditions read. |
| `reads.symbols` | `string[]` | yes | Instruments whose ticks it watches (`instrument_token`). |
| `reads.fields` | `string[]` | yes | Tick fields read, as written (`ltp`, `prev.ltp`, `legs[1].bid`). |
| `reads.variables` | `string[]` | yes | Other variables read (`entry_px`, `sl_px`, `account.mtm`, ...). |
| `limits` | `object` | yes | The trigger against the per-trigger limits. |
| `limits.ok` | `boolean` | yes |  |
| `limits.reason` | `string \| null` | yes | Which limit it passes, in words; null when within them all. |
| `limits.legs` | `integer` | yes |  |
| `limits.max_legs` | `integer` | yes |  |
| `limits.expressions` | `integer` | yes | Strings in the spec (an upper bound on its expressions). |
| `limits.max_expressions` | `integer` | yes |  |
| `limits.bytes` | `integer` | yes | The trigger's size as JSON. |
| `limits.max_bytes` | `integer` | yes |  |
| `protection` | `object \| null` | yes | Null for `alert` / `risk_limit`, or when the trigger does not compile. |
| `protection.parts` | `map<string, object>` | yes | By part (`stop_loss`, `stop_condition`, `target`, `target_1` …, `target_condition`, `trail`). |
| `protection.parts.*` | `object` | no | Who would hold one part of the protection. |
| `protection.parts.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `protection.parts.*.level` | `number (double) \| null` | yes |  |
| `protection.parts.*.adjusted_by_server` | `boolean` | yes | The server would move the broker's level (trailing, ladder advance). |
| `protection.parts.*.note` | `string \| null` | yes |  |
| `protection.reason` | `string \| null` | yes | Why nothing (or not everything) would be at the broker. |
| `conditions` | `object[]` | no | Conditions with indicators (or `on`, `hold_s`, a dead band, watched instruments) in plain English: exactly when each fires, what it reads and how much history it needs. Absent when there are none. |
| `conditions[].field` | `string` | yes | `spec.when`, `spec.entry.when`, `spec.target.when`, `spec.stop_loss.when`. |
| `conditions[].on` | `string` | yes | `close` (at each candle close) or `tick` (every price tick). |
| `conditions[].hold_s` | `integer (int32)` | yes | Tick mode: seconds it must stay true before acting. |
| `conditions[].text` | `string` | yes | Exactly when it fires. |
| `conditions[].indicators` | `object[]` | yes |  |
| `conditions[].indicators[].expr` | `string` | yes | As written: `nifty.rsi(14, '5m')`. |
| `conditions[].indicators[].label` | `string` | yes | `RSI(14) · NIFTY 50 · 5m`. |
| `conditions[].indicators[].warm_bars` | `integer (int32)` | yes | Closed candles of history needed before its value matches a chart. |
| `conditions[].warm_bars` | `integer (int32)` | yes | The most history any of its indicators needs (candles). |
| `conditions[].warm_up` | `string` | yes | Where that history comes from: `history` (loaded at once) or `live` (ready after `warm_bars` candles). |
| `risk_levels` | `object[]` | no | A `risk_limit`'s levels in plain English, in the order the engine checks them (loss floor, profit lock, profit trail, each target, `when`). Absent for other kinds, or when it does not compile. |
| `risk_levels[].field` | `string` | yes | `spec.mtm_floor`, `spec.profit_lock`, `spec.profit_trail`, `spec.targets[0]`, `spec.targets`, `spec.when`. |
| `risk_levels[].text` | `string` | yes | What happens there, in words. |
| `route` | `string` | no | For a `bracket` / `ladder`: where create would put its protection on this account, by the same decision (`gtt` or `stop_order`: held by the broker; `trigger`: the server evaluates it). Nothing is placed. Absent for other kinds, or when the trigger would be refused. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `route_reason` | `string` | no | Why that route, in words. |

**Example: A condition that does not compile: listed with create's message (200)**

```json
{
  "status": "success",
  "data": {
    "valid": false,
    "errors": [
      {
        "field": "spec",
        "message": "target.when: condition ends too early (at byte 17)"
      }
    ],
    "reads": {
      "symbols": [],
      "fields": [],
      "variables": []
    },
    "limits": {
      "ok": true,
      "reason": null,
      "legs": 1,
      "max_legs": 8,
      "expressions": 2,
      "max_expressions": 32,
      "bytes": 305,
      "max_bytes": 16384
    },
    "protection": null
  },
  "error": null
}
```

**Example: A risk limit: each level in plain English (`risk_levels`) (200)**

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "errors": [],
    "reads": {
      "symbols": [],
      "fields": [],
      "variables": [
        "account.mtm"
      ]
    },
    "limits": {
      "ok": true,
      "reason": null,
      "legs": 0,
      "max_legs": 8,
      "expressions": 1,
      "max_expressions": 32,
      "bytes": 225,
      "max_bytes": 16384
    },
    "protection": null,
    "risk_levels": [
      {
        "field": "spec.mtm_floor",
        "text": "When the account's MTM falls to ₹-5000 or lower, every position in the account is exited and the limit ends."
      },
      {
        "field": "spec.profit_lock",
        "text": "Once the account's MTM reaches ₹4000, the profit lock arms; if it then falls back to ₹2000, every position in the account is exited and the limit ends."
      }
    ]
  },
  "error": null
}
```

**Example: Risk limit targets on a server where they are not available yet (200)**

```json
{
  "status": "success",
  "data": {
    "valid": false,
    "errors": [
      {
        "field": "spec.targets",
        "message": "risk limit targets and profit trails are not available yet on this server"
      }
    ],
    "reads": {
      "symbols": [],
      "fields": [],
      "variables": []
    },
    "limits": {
      "ok": true,
      "reason": null,
      "legs": 0,
      "max_legs": 8,
      "expressions": 3,
      "max_expressions": 32,
      "bytes": 268,
      "max_bytes": 16384
    },
    "protection": null
  },
  "error": null
}
```

**Example: A bracket that would be accepted, with what it reads and its protection plan (200)**

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "errors": [],
    "reads": {
      "symbols": [
        "CT:TEST:RELIANCE"
      ],
      "fields": [
        "ltp"
      ],
      "variables": [
        "entry_px",
        "sl_px"
      ]
    },
    "limits": {
      "ok": true,
      "reason": null,
      "legs": 1,
      "max_legs": 8,
      "expressions": 2,
      "max_expressions": 32,
      "bytes": 310,
      "max_bytes": 16384
    },
    "protection": {
      "parts": {
        "stop_loss": {
          "held_by": "server",
          "level": 2475,
          "adjusted_by_server": false,
          "note": null
        },
        "target": {
          "held_by": "server",
          "level": 2550,
          "adjusted_by_server": false,
          "note": null
        }
      },
      "reason": "the position has not been checked yet"
    },
    "route": "stop_order",
    "route_reason": "broker stop-loss order holds the stop-loss, a server-side trigger the target"
  },
  "error": null
}
```

**Example: Ids are made by the server: a request carrying one is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `id`, expected one of `account`, `kind`, `strategy`, `legs`, `spec`, `valid_until`, `watch`, `route`, `group`",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An account that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/triggers/{trigger_id}/validate

Scope: `triggers`

Check a change to a v2 trigger without making it

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `spec` | `object` | yes | The whole new spec (same shape as on create). |
| `valid_until` | `string \| null` | no | New expiry (RFC 3339); unchanged when absent; `null` removes it (the trigger then lives until it ends). |
| `route` | `string \| null` | no | Where the protection lives (`route` of the trigger). The route is chosen at create: the current one (or `auto`) is accepted and changes nothing; any other is refused (422 `route`) with how to move it. One of: `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |

```bash
curl -X POST "https://api.cirrus.trade/v2/triggers/<trigger_id>/validate" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "spec": {
      "entry_px": 2500,
      "target": {
        "when": "ltp >= entry_px *"
      },
      "stop_loss": {
        "init_sl": "entry_px - 20"
      }
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `valid` | `boolean` | yes | `true` when the change would be accepted (no `errors`). The engine still applies only what the trigger's state allows (a stop-loss is never loosened). |
| `errors` | `object[]` | yes | Every reason the change would be refused, with `PATCH`'s `field` and `message`. |
| `errors[].field` | `string` | yes | The offending input path (`spec`, `legs[0].product`, ...). |
| `errors[].message` | `string` | yes |  |
| `diff` | `object[]` | yes | What changes in the spec: `[{"path", "from", "to"}]`. |
| `valid_until` | `string \| null` | yes | The expiry the trigger would have (RFC 3339); null: none. |

**Example: A change whose condition does not compile (200)**

```json
{
  "status": "success",
  "data": {
    "valid": false,
    "errors": [
      {
        "field": "spec",
        "message": "target.when: condition ends too early (at byte 17)"
      }
    ],
    "diff": [
      {
        "path": "stop_loss.init_sl",
        "from": "entry_px - 25",
        "to": "entry_px - 20"
      },
      {
        "path": "target.when",
        "from": "ltp >= entry_px * 1.02",
        "to": "ltp >= entry_px *"
      }
    ],
    "valid_until": "2026-10-27T13:27:16.003960+00:00"
  },
  "error": null
}
```

**Example: A change that would be accepted, with its diff (200)**

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "errors": [],
    "diff": [
      {
        "path": "stop_loss.init_sl",
        "from": "entry_px - 25",
        "to": "entry_px - 20"
      }
    ],
    "valid_until": null
  },
  "error": null
}
```

**Example: An unknown field (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `id`, expected one of `spec`, `valid_until`, `route`",
    "field": null
  }
}
```

**Example: An id that is not one of the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `trigger_fired` | The trigger already fired; its exit is being handled. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v2/triggers/capabilities

Scope: `read`

What v2 triggers can do

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account` | `string` | no | One of the caller's accounts: adds what its broker can hold. |

```bash
curl "https://api.cirrus.trade/v2/triggers/capabilities" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kinds` | `object[]` | yes |  |
| `kinds[].kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `kinds[].description` | `string` | yes |  |
| `kinds[].legs` | `string` | yes | Legs: `none`, `one` or `one or more`. |
| `kinds[].spec_fields` | `object[]` | yes |  |
| `kinds[].spec_fields[].name` | `string` | yes | Path in `spec` (`stop_loss.trail.step`, `targets[].price`). |
| `kinds[].spec_fields[].type` | `string` | yes | `condition` (true/false expression), `price` (number expression), `number`, `string` or `object`. |
| `kinds[].spec_fields[].required` | `boolean` | yes |  |
| `kinds[].spec_fields[].description` | `string` | yes |  |
| `expressions` | `object` | yes | The expression language (a strict subset of CEL; numbers are doubles). |
| `expressions.variables` | `object[]` | yes |  |
| `expressions.variables[].name` | `string` | yes |  |
| `expressions.variables[].description` | `string` | yes |  |
| `expressions.functions` | `object[]` | yes |  |
| `expressions.functions[].name` | `string` | yes |  |
| `expressions.functions[].description` | `string` | yes |  |
| `expressions.operators` | `string[]` | yes |  |
| `expressions.max_source_bytes` | `integer` | yes |  |
| `expressions.max_depth` | `integer` | yes |  |
| `expressions.max_nodes` | `integer` | yes |  |
| `limits` | `object` | yes | Limits this deployment's engine runs with. |
| `limits.max_triggers` | `integer` | yes | Live triggers per user. |
| `limits.max_legs` | `integer` | yes |  |
| `limits.max_expressions` | `integer` | yes | Strings in one spec (an upper bound on its expressions). |
| `limits.max_symbols` | `integer` | yes | Distinct instruments across a user's live triggers. |
| `limits.max_trigger_bytes` | `integer` | yes | One trigger's size as JSON. |
| `limits.max_rungs` | `integer` | yes | Targets of one ladder. |
| `entries_enabled` | `boolean` | yes | A `bracket` with `entry` places its entry order for this user when it holds. `false`: a bracket with `entry` is refused (422 `spec.entry`); give `entry_px` instead. |
| `risk_targets_enabled` | `boolean` | yes | Risk limit `targets` and `profit_trail` are accepted on this server. `false`: a spec with either is refused (422 `spec.targets` / `spec.profit_trail`). |
| `products` | `object[]` | yes | The products a leg may have, by market segment (`legs[].product`). |
| `products[].segment` | `string` | yes | `equity` (cash market) or `derivatives` (futures and options, commodities, currencies). |
| `products[].exchanges` | `string[]` | yes | The instrument master's exchange codes in the segment. |
| `products[].products` | `string[]` | yes | `MIS` (intraday), `DELIVERY` / `MTF` (equity held overnight, MTF on margin), `CARRYFORWARD` (derivatives held overnight). A broker may still refuse one it does not offer (MTF on some brokers). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `account` | `object \| null` | yes | With `?account=`; null otherwise. |
| `account.account` | `string` | yes |  |
| `account.broker_protection_enabled` | `boolean` | yes | Whether the server places protection at the broker at all; when off, the server holds every part. |
| `account.holds` | `object` | yes | Broker order types that can hold protection. |
| `account.holds.bracket` | `boolean` | yes |  |
| `account.holds.cover` | `boolean` | yes |  |
| `account.holds.gtt` | `boolean` | yes |  |
| `account.holds.gtt_two_leg` | `boolean` | yes |  |
| `account.facts` | `map<string, object>` | yes | The broker's protection facts by name. |
| `account.facts.*` | `object` | no | One protection fact about the broker: proven by a recording or not (an unverified fact is treated as the unsafe case). |
| `account.facts.*.status` | `string` | yes | `verified` or `unverified`. |
| `account.facts.*.value` | `object \| null` | yes | The proven value; null when unverified. |
| `indicators` | `object \| null` | no | Indicator conditions: functions with their parameters, presets, timeframes, exchanges and limits. Absent while they are not available. |
| `indicators.functions` | `object[]` | yes |  |
| `indicators.functions[].function` | `string` | yes | As written in a condition (`rsi`, `bb_upper`). |
| `indicators.functions[].indicator` | `string` | yes | The indicator it belongs to (`RSI`, `Bollinger Bands`). |
| `indicators.functions[].group` | `string` | yes |  |
| `indicators.functions[].description` | `string` | yes |  |
| `indicators.functions[].label` | `string` | yes | Label template; `{0}` … are the parameters (`RSI({0})`). |
| `indicators.functions[].unit` | `string` | yes | `price`, `oscillator`, `price_units`, `ratio`, `direction`, `volume`. |
| `indicators.functions[].verbs` | `string[]` | yes | Verbs the form offers (`rises_above`, `turns_up`, ...). |
| `indicators.functions[].params` | `object[]` | yes | Parameters in order; the timeframe comes last. |
| `indicators.functions[].params[].name` | `string` | yes |  |
| `indicators.functions[].params[].label` | `string` | yes | As the form says it (`RSI length`). |
| `indicators.functions[].params[].type` | `string` | yes | `int` or `float`. |
| `indicators.functions[].params[].min` | `number (double)` | yes |  |
| `indicators.functions[].params[].max` | `number (double)` | yes |  |
| `indicators.functions[].params[].default` | `number (double)` | yes |  |
| `indicators.functions[].needs_volume` | `boolean` | yes | Needs traded volume (not on an index). |
| `indicators.functions[].arguments_schema` | `object` | yes | JSON Schema of the call's arguments (parameters, then the timeframe). |
| `indicators.patterns` | `object[]` | yes | Candlestick patterns, read with `bullish_pattern`, `bearish_pattern` or `pattern` (`'name'`, then the timeframe) at the candle close. |
| `indicators.patterns[].name` | `string` | yes | As written in a condition (`engulfing`). |
| `indicators.patterns[].label` | `string` | yes | In plain English (`engulfing`, `morning star`). |
| `indicators.patterns[].directions` | `string` | yes | `both`, `bullish`, `bearish` or `neither` (no direction, like a doji). |
| `indicators.patterns[].candles` | `integer (int32)` | yes | Candles the pattern spans; the last one completes it. |
| `indicators.patterns[].functions` | `string[]` | yes | Functions that accept it (`bullish_pattern`, `bearish_pattern`, `pattern`). |
| `indicators.presets` | `object[]` | yes |  |
| `indicators.presets[].name` | `string` | yes |  |
| `indicators.presets[].when` | `string` | yes | The condition it builds on leg 0. |
| `indicators.presets[].on` | `string` | yes | `close` or `tick`. |
| `indicators.presets[].interval` | `string` | yes |  |
| `indicators.intervals` | `string[]` | yes | `1m` … `60m`. |
| `indicators.segments` | `string[]` | yes | Exchanges with indicator conditions now. |
| `indicators.limits` | `object` | yes | Indicator limits. |
| `indicators.limits.max_calls_per_condition` | `integer` | yes |  |
| `indicators.limits.max_timeframes_per_trigger` | `integer` | yes |  |
| `indicators.limits.max_watch` | `integer` | yes |  |
| `indicators.limits.max_indicators` | `integer` | yes | Indicator instances across the user's live triggers. |
| `indicators.limits.max_series` | `integer` | yes | (instrument, timeframe) series across the user's live triggers. |
| `indicators.limits.hold_s_max` | `integer (int32)` | yes |  |
| `indicators.limits.order_hold_s_default` | `integer (int32)` | yes | Order-placing tick-mode conditions: the default and least hold. |
| `indicators.limits.order_hold_s_min` | `integer (int32)` | yes |  |
| `indicators.limits.repeat_cooldown_bars_max` | `integer (int32)` | yes |  |
| `indicators.limits.repeat_max_per_day_max` | `integer (int32)` | yes |  |

**Example: Kinds, the expression language and the limits (200)**

```json
{
  "status": "success",
  "data": {
    "kinds": [
      {
        "kind": "alert",
        "description": "Notifies once `when` holds, then ends (`fired`).",
        "legs": "one or more",
        "spec_fields": [
          {
            "name": "when",
            "type": "condition",
            "required": true,
            "description": "Tick fields of every leg, risk variables, `now_ist`."
          },
          {
            "name": "on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "repeat.cooldown_bars",
            "type": "number",
            "required": false,
            "description": "A repeating alert: notifies each time (the crossing must re-arm), with at least this many candles of its smallest timeframe between alerts (1 to 100, default 1). Needs a condition on candles (an indicator or `bar_close`)."
          },
          {
            "name": "repeat.max_per_day",
            "type": "number",
            "required": false,
            "description": "A repeating alert: at most this many alerts a day (1 to 50, default 10)."
          }
        ]
      },
      {
        "kind": "bracket",
        "description": "Entry (a condition, or a known `entry_px`), target, stop-loss and trailing stop on one position.",
        "legs": "one",
        "spec_fields": [
          {
            "name": "entry.when",
            "type": "condition",
            "required": false,
            "description": "Waits (`pending`) until it holds; `entry_px` becomes that tick's LTP. Exclusive with `entry_px`."
          },
          {
            "name": "entry.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "entry.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "entry_px",
            "type": "number",
            "required": false,
            "description": "The entry price of a position already held (starts `active`). Exclusive with `entry`."
          },
          {
            "name": "target.when",
            "type": "condition",
            "required": false,
            "description": "Exits everything when it holds (`target_hit`). Reads ticks, `entry_px`, `state`, risk variables."
          },
          {
            "name": "target.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "target.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "stop_loss.when",
            "type": "condition",
            "required": false,
            "description": "Exits everything when it holds (`sl_hit`); default `ltp <= sl_px` (BUY) / `ltp >= sl_px` (SELL)."
          },
          {
            "name": "stop_loss.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "stop_loss.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "stop_loss.init_sl",
            "type": "price",
            "required": false,
            "description": "The first stop level (`sl_px`); reads `entry_px` and literals, fixed at entry."
          },
          {
            "name": "stop_loss.trail.step",
            "type": "price",
            "required": false,
            "description": "Trailing step; reads `entry_px` and literals. The stop only tightens."
          }
        ]
      },
      {
        "kind": "ladder",
        "description": "Several targets on a position held at `entry_px`; each rung moves the stop-loss up.",
        "legs": "one",
        "spec_fields": [
          {
            "name": "entry_px",
            "type": "number",
            "required": true,
            "description": "The position's entry price."
          },
          {
            "name": "targets[].price",
            "type": "price",
            "required": true,
            "description": "Rung level; reads `entry_px` and literals; strictly moving away from the entry in the profit direction."
          },
          {
            "name": "targets[].qty",
            "type": "number",
            "required": false,
            "description": "Units an intermediate rung exits (without it the rung only notifies); the last rung exits everything."
          },
          {
            "name": "stop_loss.init_sl",
            "type": "price",
            "required": false,
            "description": "The first stop level; reads `entry_px` and literals."
          },
          {
            "name": "stop_loss.trail.step",
            "type": "price",
            "required": false,
            "description": "Trailing step while no rung is hit."
          }
        ]
      },
      {
        "kind": "risk_limit",
        "description": "Loss / profit limits on the account or the user (strategy level is not supported yet): a loss floor, a fixed or trailing profit floor, and profit targets that notify, book part of every position or exit everything.",
        "legs": "none",
        "spec_fields": [
          {
            "name": "level",
            "type": "string",
            "required": true,
            "description": "`account` or `user`."
          },
          {
            "name": "mtm_floor",
            "type": "number",
            "required": false,
            "description": "Breaches when MTM is at or below it."
          },
          {
            "name": "profit_lock.arm_at",
            "type": "number",
            "required": false,
            "description": "Arms once MTM reaches it."
          },
          {
            "name": "profit_lock.floor",
            "type": "number",
            "required": false,
            "description": "Once armed, breaches when MTM falls to it."
          },
          {
            "name": "profit_trail.arm_at",
            "type": "number",
            "required": false,
            "description": "A trailing profit floor: arms once MTM reaches it (above 0). Exclusive with `profit_lock`. Needs `risk_targets_enabled`."
          },
          {
            "name": "profit_trail.step",
            "type": "number",
            "required": false,
            "description": "Once armed, the floor is the peak MTM minus this many rupees (above 0, below `arm_at`); it only ever rises, and MTM at or below it does `on_breach`. Exactly one of `step` or `pct`."
          },
          {
            "name": "profit_trail.pct",
            "type": "number",
            "required": false,
            "description": "Once armed, the floor is the peak MTM less this percent of it (above 0, below 100); it only ever rises. Exactly one of `step` or `pct`."
          },
          {
            "name": "targets[].mtm",
            "type": "number",
            "required": false,
            "description": "A profit target: fires once MTM is at or above it (above 0). Levels strictly increase; each fires once, in order (the trigger's `risk_progress.targets_fired`). At most 10. Needs `risk_targets_enabled`."
          },
          {
            "name": "targets[].action",
            "type": "string",
            "required": false,
            "description": "`notify` (only notifies), `book_pct` (exits `pct`% of every open position in the scope, each rounded down to its lot size; a position under one lot is skipped and reported) or `flatten` (exits everything and ends the limit, reason `target`; must be the last target). The loss floor still applies after a booking."
          },
          {
            "name": "targets[].pct",
            "type": "number",
            "required": false,
            "description": "`book_pct` only: the share of each position to exit, 1 to 100."
          },
          {
            "name": "when",
            "type": "condition",
            "required": false,
            "description": "Breaches when it holds; reads risk variables and `now_ist`."
          },
          {
            "name": "on_breach",
            "type": "string",
            "required": false,
            "description": "`flatten` (default), `pause` or `notify`: what a breach of the loss floor, the profit lock, the profit trail or `when` does."
          }
        ]
      }
    ],
    "expressions": {
      "variables": [
        {
          "name": "ltp",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "bid",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "ask",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "bid_qty",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "ask_qty",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "volume",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "oi",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "atp",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "open",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "high",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "low",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "prev.ltp",
          "description": "Any tick field of leg 0 on the previous tick (`prev.bid`, ...)."
        },
        {
          "name": "legs[i].ltp",
          "description": "Any tick field of leg i (`legs[1].bid`, `legs[1].prev.oi`)."
        },
        {
          "name": "entry_px",
          "description": "The entry price (bracket, ladder)."
        },
        {
          "name": "sl_px",
          "description": "The current stop-loss level (bracket stop condition)."
        },
        {
          "name": "state",
          "description": "The trigger's state name, a string (bracket target / stop)."
        },
        {
          "name": "account.mtm",
          "description": "The account's mark-to-market."
        },
        {
          "name": "user.mtm",
          "description": "The user's mark-to-market across accounts."
        },
        {
          "name": "strategy.mtm",
          "description": "The strategy's mark-to-market (triggers with a strategy)."
        },
        {
          "name": "position.qty",
          "description": "Net quantity held in leg 0's instrument on the account."
        },
        {
          "name": "now_ist",
          "description": "Minutes since midnight IST (09:15 is 555)."
        }
      ],
      "functions": [
        {
          "name": "crosses_above",
          "description": "crosses_above(a, b[, band]): a was at or below b on the previous tick and is above it now. Over an indicator, or with a dead band `band` (a number 0 or more), it fires once per real crossing: values inside b ± band keep the last side, and the first side seen only arms. Tick mode on a bracket defaults the band to 1 point (RSI, Stochastic) or 0.05 % of the level (prices)."
        },
        {
          "name": "crosses_below",
          "description": "crosses_below(a, b[, band]): a was at or above b on the previous tick and is below it now; `band` as for crosses_above."
        },
        {
          "name": "min",
          "description": "min(a, b, ...): the smallest number."
        },
        {
          "name": "max",
          "description": "max(a, b, ...): the largest number."
        },
        {
          "name": "abs",
          "description": "abs(a): the absolute value."
        }
      ],
      "operators": [
        "||",
        "&&",
        "!",
        "==",
        "!=",
        "<",
        "<=",
        ">",
        ">=",
        "+",
        "-",
        "*",
        "/",
        "? :"
      ],
      "max_source_bytes": 2048,
      "max_depth": 32,
      "max_nodes": 256
    },
    "limits": {
      "max_triggers": 500,
      "max_legs": 8,
      "max_expressions": 32,
      "max_symbols": 100,
      "max_trigger_bytes": 16384,
      "max_rungs": 20
    },
    "entries_enabled": false,
    "risk_targets_enabled": false,
    "products": [
      {
        "segment": "equity",
        "exchanges": [
          "NSE",
          "BSE"
        ],
        "products": [
          "MIS",
          "DELIVERY",
          "MTF"
        ]
      },
      {
        "segment": "derivatives",
        "exchanges": [
          "NFO",
          "BFO",
          "MCX",
          "CDS",
          "BCD",
          "NCDEX"
        ],
        "products": [
          "MIS",
          "CARRYFORWARD"
        ]
      }
    ],
    "account": null
  },
  "error": null
}
```

**Example: Also what the account's broker can hold (200)**

```json
{
  "status": "success",
  "data": {
    "kinds": [
      {
        "kind": "alert",
        "description": "Notifies once `when` holds, then ends (`fired`).",
        "legs": "one or more",
        "spec_fields": [
          {
            "name": "when",
            "type": "condition",
            "required": true,
            "description": "Tick fields of every leg, risk variables, `now_ist`."
          },
          {
            "name": "on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "repeat.cooldown_bars",
            "type": "number",
            "required": false,
            "description": "A repeating alert: notifies each time (the crossing must re-arm), with at least this many candles of its smallest timeframe between alerts (1 to 100, default 1). Needs a condition on candles (an indicator or `bar_close`)."
          },
          {
            "name": "repeat.max_per_day",
            "type": "number",
            "required": false,
            "description": "A repeating alert: at most this many alerts a day (1 to 50, default 10)."
          }
        ]
      },
      {
        "kind": "bracket",
        "description": "Entry (a condition, or a known `entry_px`), target, stop-loss and trailing stop on one position.",
        "legs": "one",
        "spec_fields": [
          {
            "name": "entry.when",
            "type": "condition",
            "required": false,
            "description": "Waits (`pending`) until it holds; `entry_px` becomes that tick's LTP. Exclusive with `entry_px`."
          },
          {
            "name": "entry.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "entry.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "entry_px",
            "type": "number",
            "required": false,
            "description": "The entry price of a position already held (starts `active`). Exclusive with `entry`."
          },
          {
            "name": "target.when",
            "type": "condition",
            "required": false,
            "description": "Exits everything when it holds (`target_hit`). Reads ticks, `entry_px`, `state`, risk variables."
          },
          {
            "name": "target.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "target.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "stop_loss.when",
            "type": "condition",
            "required": false,
            "description": "Exits everything when it holds (`sl_hit`); default `ltp <= sl_px` (BUY) / `ltp >= sl_px` (SELL)."
          },
          {
            "name": "stop_loss.on",
            "type": "string",
            "required": false,
            "description": "When the condition next to it is checked: `close` (default when it reads an indicator: at each candle close, with closed values) or `tick` (every price tick, with the forming candle)."
          },
          {
            "name": "stop_loss.hold_s",
            "type": "number",
            "required": false,
            "description": "Tick mode: seconds the condition must stay true before acting (0 to 300). A bracket in tick mode waits 10 s by default and at least 5 s."
          },
          {
            "name": "stop_loss.init_sl",
            "type": "price",
            "required": false,
            "description": "The first stop level (`sl_px`); reads `entry_px` and literals, fixed at entry."
          },
          {
            "name": "stop_loss.trail.step",
            "type": "price",
            "required": false,
            "description": "Trailing step; reads `entry_px` and literals. The stop only tightens."
          }
        ]
      },
      {
        "kind": "ladder",
        "description": "Several targets on a position held at `entry_px`; each rung moves the stop-loss up.",
        "legs": "one",
        "spec_fields": [
          {
            "name": "entry_px",
            "type": "number",
            "required": true,
            "description": "The position's entry price."
          },
          {
            "name": "targets[].price",
            "type": "price",
            "required": true,
            "description": "Rung level; reads `entry_px` and literals; strictly moving away from the entry in the profit direction."
          },
          {
            "name": "targets[].qty",
            "type": "number",
            "required": false,
            "description": "Units an intermediate rung exits (without it the rung only notifies); the last rung exits everything."
          },
          {
            "name": "stop_loss.init_sl",
            "type": "price",
            "required": false,
            "description": "The first stop level; reads `entry_px` and literals."
          },
          {
            "name": "stop_loss.trail.step",
            "type": "price",
            "required": false,
            "description": "Trailing step while no rung is hit."
          }
        ]
      },
      {
        "kind": "risk_limit",
        "description": "Loss / profit limits on the account or the user (strategy level is not supported yet): a loss floor, a fixed or trailing profit floor, and profit targets that notify, book part of every position or exit everything.",
        "legs": "none",
        "spec_fields": [
          {
            "name": "level",
            "type": "string",
            "required": true,
            "description": "`account` or `user`."
          },
          {
            "name": "mtm_floor",
            "type": "number",
            "required": false,
            "description": "Breaches when MTM is at or below it."
          },
          {
            "name": "profit_lock.arm_at",
            "type": "number",
            "required": false,
            "description": "Arms once MTM reaches it."
          },
          {
            "name": "profit_lock.floor",
            "type": "number",
            "required": false,
            "description": "Once armed, breaches when MTM falls to it."
          },
          {
            "name": "profit_trail.arm_at",
            "type": "number",
            "required": false,
            "description": "A trailing profit floor: arms once MTM reaches it (above 0). Exclusive with `profit_lock`. Needs `risk_targets_enabled`."
          },
          {
            "name": "profit_trail.step",
            "type": "number",
            "required": false,
            "description": "Once armed, the floor is the peak MTM minus this many rupees (above 0, below `arm_at`); it only ever rises, and MTM at or below it does `on_breach`. Exactly one of `step` or `pct`."
          },
          {
            "name": "profit_trail.pct",
            "type": "number",
            "required": false,
            "description": "Once armed, the floor is the peak MTM less this percent of it (above 0, below 100); it only ever rises. Exactly one of `step` or `pct`."
          },
          {
            "name": "targets[].mtm",
            "type": "number",
            "required": false,
            "description": "A profit target: fires once MTM is at or above it (above 0). Levels strictly increase; each fires once, in order (the trigger's `risk_progress.targets_fired`). At most 10. Needs `risk_targets_enabled`."
          },
          {
            "name": "targets[].action",
            "type": "string",
            "required": false,
            "description": "`notify` (only notifies), `book_pct` (exits `pct`% of every open position in the scope, each rounded down to its lot size; a position under one lot is skipped and reported) or `flatten` (exits everything and ends the limit, reason `target`; must be the last target). The loss floor still applies after a booking."
          },
          {
            "name": "targets[].pct",
            "type": "number",
            "required": false,
            "description": "`book_pct` only: the share of each position to exit, 1 to 100."
          },
          {
            "name": "when",
            "type": "condition",
            "required": false,
            "description": "Breaches when it holds; reads risk variables and `now_ist`."
          },
          {
            "name": "on_breach",
            "type": "string",
            "required": false,
            "description": "`flatten` (default), `pause` or `notify`: what a breach of the loss floor, the profit lock, the profit trail or `when` does."
          }
        ]
      }
    ],
    "expressions": {
      "variables": [
        {
          "name": "ltp",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "bid",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "ask",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "bid_qty",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "ask_qty",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "volume",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "oi",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "atp",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "open",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "high",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "low",
          "description": "Leg 0's tick field; `prev.<field>` the previous tick's, `legs[i].<field>` leg i's."
        },
        {
          "name": "prev.ltp",
          "description": "Any tick field of leg 0 on the previous tick (`prev.bid`, ...)."
        },
        {
          "name": "legs[i].ltp",
          "description": "Any tick field of leg i (`legs[1].bid`, `legs[1].prev.oi`)."
        },
        {
          "name": "entry_px",
          "description": "The entry price (bracket, ladder)."
        },
        {
          "name": "sl_px",
          "description": "The current stop-loss level (bracket stop condition)."
        },
        {
          "name": "state",
          "description": "The trigger's state name, a string (bracket target / stop)."
        },
        {
          "name": "account.mtm",
          "description": "The account's mark-to-market."
        },
        {
          "name": "user.mtm",
          "description": "The user's mark-to-market across accounts."
        },
        {
          "name": "strategy.mtm",
          "description": "The strategy's mark-to-market (triggers with a strategy)."
        },
        {
          "name": "position.qty",
          "description": "Net quantity held in leg 0's instrument on the account."
        },
        {
          "name": "now_ist",
          "description": "Minutes since midnight IST (09:15 is 555)."
        }
      ],
      "functions": [
        {
          "name": "crosses_above",
          "description": "crosses_above(a, b[, band]): a was at or below b on the previous tick and is above it now. Over an indicator, or with a dead band `band` (a number 0 or more), it fires once per real crossing: values inside b ± band keep the last side, and the first side seen only arms. Tick mode on a bracket defaults the band to 1 point (RSI, Stochastic) or 0.05 % of the level (prices)."
        },
        {
          "name": "crosses_below",
          "description": "crosses_below(a, b[, band]): a was at or above b on the previous tick and is below it now; `band` as for crosses_above."
        },
        {
          "name": "min",
          "description": "min(a, b, ...): the smallest number."
        },
        {
          "name": "max",
          "description": "max(a, b, ...): the largest number."
        },
        {
          "name": "abs",
          "description": "abs(a): the absolute value."
        }
      ],
      "operators": [
        "||",
        "&&",
        "!",
        "==",
        "!=",
        "<",
        "<=",
        ">",
        ">=",
        "+",
        "-",
        "*",
        "/",
        "? :"
      ],
      "max_source_bytes": 2048,
      "max_depth": 32,
      "max_nodes": 256
    },
    "limits": {
      "max_triggers": 500,
      "max_legs": 8,
      "max_expressions": 32,
      "max_symbols": 100,
      "max_trigger_bytes": 16384,
      "max_rungs": 20
    },
    "entries_enabled": false,
    "risk_targets_enabled": false,
    "products": [
      {
        "segment": "equity",
        "exchanges": [
          "NSE",
          "BSE"
        ],
        "products": [
          "MIS",
          "DELIVERY",
          "MTF"
        ]
      },
      {
        "segment": "derivatives",
        "exchanges": [
          "NFO",
          "BFO",
          "MCX",
          "CDS",
          "BCD",
          "NCDEX"
        ],
        "products": [
          "MIS",
          "CARRYFORWARD"
        ]
      }
    ],
    "account": {
      "account": "Z1",
      "broker_protection_enabled": false,
      "holds": {
        "bracket": false,
        "cover": false,
        "gtt": true,
        "gtt_two_leg": true
      },
      "facts": {
        "gtt_modify": {
          "status": "unverified",
          "value": null
        },
        "leg_identity": {
          "status": "unverified",
          "value": null
        },
        "leg_modify": {
          "status": "unverified",
          "value": null
        },
        "leg_units": {
          "status": "unverified",
          "value": null
        },
        "modify_limit": {
          "status": "unverified",
          "value": null
        },
        "modify_qty_basis": {
          "status": "unverified",
          "value": null
        },
        "native_trail": {
          "status": "unverified",
          "value": null
        },
        "qty_unit": {
          "status": "unverified",
          "value": null
        },
        "stop_type_after_modify": {
          "status": "unverified",
          "value": null
        },
        "stop_type_after_trigger": {
          "status": "unverified",
          "value": null
        },
        "trigger_price_after_modify": {
          "status": "unverified",
          "value": null
        }
      }
    }
  },
  "error": null
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An account that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/triggers/{trigger_id}/journey

Scope: `read`

A v2 trigger's journey

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | no | Entries per page, 1 to 200 (default 50). |
| `cursor` | `string` | no | `next_cursor` of the previous page. |
| `source` | `string` | no | Only entries from `api`, `engine`, `result`, `exit` or `protection`. |

```bash
curl "https://api.cirrus.trade/v2/triggers/<trigger_id>/journey" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `entries` | `object[]` | yes |  |
| `entries[].entry_id` | `string` | yes |  |
| `entries[].at` | `string` | yes | RFC 3339, India time. |
| `entries[].seq` | `integer (int64)` | yes | The trigger's change counter this entry goes with (0 when none). |
| `entries[].source` | `string` | yes | `api`, `engine`, `result`, `exit` or `protection` (other values may appear). |
| `entries[].kind` | `string` | yes | What happened: `created`, `modified`, `paused`, `resumed`, `cancelled` (api); `transition`, `note` (engine); `rejected` (result); `exit_placed`, `exit_partly_filled`, `exit_filled`, `exit_refused`, `exit_unfilled`, `exit_repriced`, `exit_retried` (an action of the event not done yet), `exit_failed` (given up, dead-lettered) (exit); `gtt_placed`, `gtt_modified`, `gtt_cancelled`, `gtt_triggered`, `readback_matched`, `readback_mismatch`, `readback_unknown`, `degraded` (protection). Other values may appear. |
| `entries[].summary` | `string` | yes |  |
| `entries[].actor` | `object \| null` | yes | Who asked, as the owner sees it. |
| `entries[].actor.user` | `string` | yes | The trigger's owner (the caller). |
| `entries[].actor.credential` | `string` | yes | `app`, `api_key`, `partner` or `support` (an operator acting for the user; never named). |
| `entries[].actor.api_key_id` | `string` | no | The API key's id (never its secret), when an API key was used. |
| `entries[].actor.partner_client_id` | `string` | no | The partner app's client id, when a partner acted. |
| `entries[].actor.ip` | `string` | no | The caller's own IP (app sessions and the user's API keys only). |
| `entries[].details` | `object` | yes | Kind-specific detail (only the fields listed for the kind). |
| `next_cursor` | `string \| null` | yes | Pass as `cursor` for the next (older) page; null on the last. |

**Example: Created in the app, then the engine trailed the stop-loss (200)**

```json
{
  "status": "success",
  "data": {
    "entries": [
      {
        "entry_id": "evt:01M4QRFDBAQC442FN7H0ATGR3A:1",
        "at": "2026-10-12T15:23:05.396+05:30",
        "seq": 1,
        "source": "engine",
        "kind": "transition",
        "summary": "active -> active",
        "actor": null,
        "details": {
          "from": "active",
          "to": "active",
          "actions": [
            {
              "type": "move_sl",
              "legs": [
                0
              ],
              "price": 2480
            }
          ],
          "tick": {
            "symbol": "CT:TEST:RELIANCE",
            "ltp": 2551
          },
          "reason": null,
          "explain": null
        }
      },
      {
        "entry_id": "api:01M4QRFDBBMKYVDBYQ05TTGVX2",
        "at": "2026-10-12T15:23:05.386+05:30",
        "seq": 0,
        "source": "api",
        "kind": "created",
        "summary": "created in the app (command 01M4QRFDBBMKYVDBYQ05TTGVX2)",
        "actor": {
          "user": "user-01M4QRFD9EACA9SER6K3EHY0BQ",
          "credential": "app"
        },
        "details": {
          "kind": "bracket",
          "legs": [
            {
              "symbol": "CT:TEST:RELIANCE",
              "side": "BUY",
              "qty": 5,
              "product": "MIS"
            }
          ],
          "spec": {
            "entry_px": 2500,
            "target": {
              "when": "ltp >= entry_px * 1.02"
            },
            "stop_loss": {
              "init_sl": "entry_px - 25"
            }
          },
          "valid_until": null
        }
      }
    ],
    "next_cursor": null
  },
  "error": null
}
```

**Example: A page size outside 1 to 200 (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_query",
    "message": "limit must be 1 to 200",
    "field": null
  }
}
```

**Example: v2 triggers are not enabled for this user (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Triggers v2 are not enabled for this user",
    "field": null
  }
}
```

**Example: An id that is not the caller's (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "trigger_not_found",
    "message": "Trigger not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v2/triggers/{trigger_id}/indicators

Scope: `read`

A v2 trigger's indicator values now

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes | The trigger's id. |

```bash
curl "https://api.cirrus.trade/v2/triggers/<trigger_id>/indicators" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `status` | `string` | yes | `ok`, or `unavailable` (the engine did not answer in time; poll again). |
| `at` | `integer (int64) \| null` | yes | Epoch ms the values were read at. |
| `values` | `object[]` | yes |  |
| `values[].label` | `string` | yes |  |
| `values[].expr` | `string` | yes |  |
| `values[].condition` | `string` | yes |  |
| `values[].status` | `string` | yes | `warming`, `ready` or `fallback`. |
| `values[].closed` | `number (double) \| null` | yes | The last closed candle's value. |
| `values[].live` | `number (double) \| null` | yes | The forming candle's value. |
| `values[].bar_start` | `integer (int64) \| null` | yes | Epoch ms of the forming candle's start. |
| `values[].warm_bars_left` | `integer (int32)` | yes |  |
| `values[].gap` | `boolean` | yes | The last candle was built across a gap in prices. |
| `values[].compare` | `object \| null` | yes | How a value compares with its threshold. |
| `values[].compare.op` | `string` | yes |  |
| `values[].compare.to` | `number (double) \| null` | yes |  |
| `values[].compare.band` | `number (double) \| null` | yes |  |
| `values[].compare.distance` | `number (double) \| null` | yes |  |
| `values[].hold` | `object \| null` | yes | A tick-mode hold in progress. |
| `values[].hold.since` | `integer (int64) \| null` | yes |  |
| `values[].hold.need_s` | `integer (int32)` | yes |  |

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `trigger_not_found` | No trigger with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Risk limits

Your overall daily loss limit and exposure cap for automated strategies.

### GET /v2/strategies/risk-limits

Scope: `read`

Your overall risk limits

```bash
curl "https://api.cirrus.trade/v2/strategies/risk-limits" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `max_daily_loss` | `number (double) \| null` | no | ₹; `null`: no daily loss limit. |
| `max_open_exposure` | `number (double) \| null` | no | ₹; `null`: no open exposure limit. |
| `close_on_breach` | `boolean` | yes | A breached daily loss also closes open trades. |
| `updated_at` | `string \| null` | no | When they were last changed; `null`: never set. |
| `today` | `object` | no | Today across every automated strategy: on `GET` only, and absent when the strategy ledgers can't be read now. |
| `today.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `today.pnl` | `number (double)` | yes | Realised plus open at our latest prices (₹). |
| `today.ltp_stale` | `boolean` | yes | An open position's price is not current: `pnl` may be behind (the engine then waits rather than guess). |
| `today.daily_loss_reached` | `boolean` | yes | `pnl` is at or below minus `max_daily_loss`: no new trades today. |

**Example: No limits set yet (200)**

```json
{
  "status": "success",
  "data": {
    "max_daily_loss": null,
    "max_open_exposure": null,
    "close_on_breach": false,
    "updated_at": null,
    "today": {
      "trading_day": "2026-10-22",
      "pnl": 0,
      "ltp_stale": false,
      "daily_loss_reached": false
    }
  },
  "error": null
}
```

**Example: Your limits (200)**

```json
{
  "status": "success",
  "data": {
    "max_daily_loss": 15000,
    "max_open_exposure": null,
    "close_on_breach": true,
    "updated_at": "2026-10-22T15:22:21.171Z",
    "today": {
      "trading_day": "2026-10-22",
      "pnl": 0,
      "ltp_stale": false,
      "daily_loss_reached": false
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PUT /v2/strategies/risk-limits

Scope: `strategies`

Set your overall risk limits

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `max_daily_loss` | `number (double) \| null` | no | Today's loss across every strategy (₹, realised plus open) at which no new trades start. `null` removes it; left out keeps it. |
| `max_open_exposure` | `number (double) \| null` | no | Open exposure across every strategy (₹) at which no new trades start: per trade, the most it can lose where that is bounded (spreads, bought options), else the broker's margin, else the entry value. `null` removes it; left out keeps it. |
| `close_on_breach` | `boolean \| null` | no | Also close every open trade of your strategies when the daily loss limit is reached (off by default; needs `max_daily_loss`). Left out keeps it. |

```bash
curl -X PUT "https://api.cirrus.trade/v2/strategies/risk-limits" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "max_open_exposure": null
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `max_daily_loss` | `number (double) \| null` | no | ₹; `null`: no daily loss limit. |
| `max_open_exposure` | `number (double) \| null` | no | ₹; `null`: no open exposure limit. |
| `close_on_breach` | `boolean` | yes | A breached daily loss also closes open trades. |
| `updated_at` | `string \| null` | no | When they were last changed; `null`: never set. |
| `today` | `object` | no | Today across every automated strategy: on `GET` only, and absent when the strategy ledgers can't be read now. |
| `today.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `today.pnl` | `number (double)` | yes | Realised plus open at our latest prices (₹). |
| `today.ltp_stale` | `boolean` | yes | An open position's price is not current: `pnl` may be behind (the engine then waits rather than guess). |
| `today.daily_loss_reached` | `boolean` | yes | `pnl` is at or below minus `max_daily_loss`: no new trades today. |

**Example: Removing the exposure cap keeps the daily loss limit (200)**

```json
{
  "status": "success",
  "data": {
    "max_daily_loss": 15000,
    "max_open_exposure": null,
    "close_on_breach": true,
    "updated_at": "2026-10-22T15:22:21.171Z"
  },
  "error": null
}
```

**Example: A daily loss limit that also closes open trades, and an exposure cap (200)**

```json
{
  "status": "success",
  "data": {
    "max_daily_loss": 15000,
    "max_open_exposure": 300000,
    "close_on_breach": true,
    "updated_at": "2026-10-22T15:22:21.147Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: Closing on a breach needs a daily loss limit (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "closing open trades on a breach needs a daily loss limit",
    "field": "close_on_breach"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Backtests

Run a strategy version over past days and read its trades.

### GET /v2/backtests

Scope: `read`

List backtests

```bash
curl "https://api.cirrus.trade/v2/backtests" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes |  |
| `[].strategy_id` | `string` | yes |  |
| `[].version` | `integer (int32)` | yes |  |
| `[].status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `[].from` | `string` | yes |  |
| `[].to` | `string` | yes |  |
| `[].capital` | `number (double)` | yes |  |
| `[].costs` | `object` | yes | Costs a backtest applies. |
| `[].costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `[].costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `[].costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `[].progress` | `object` | yes | Days replayed so far. |
| `[].progress.done` | `integer (int32)` | yes |  |
| `[].progress.total` | `integer (int32)` | yes |  |
| `[].cancel_requested` | `boolean` | yes |  |
| `[].created_at` | `string (date-time)` | yes |  |
| `[].started_at` | `string (date-time) \| null` | no |  |
| `[].finished_at` | `string (date-time) \| null` | no |  |
| `[].expires_at` | `string (date-time)` | yes | When the result is deleted. |
| `[].error` | `string \| null` | no | Why it failed, in words. |
| `[].summary` | `object \| null` | no | Once done. |
| `[].summary.capital` | `number (double)` | yes |  |
| `[].summary.final_equity` | `number (double)` | yes |  |
| `[].summary.net_pnl` | `number (double)` | yes | After charges. |
| `[].summary.charges` | `number (double)` | yes |  |
| `[].summary.cycles` | `integer (int32)` | yes |  |
| `[].summary.trades` | `integer (int32)` | yes |  |
| `[].summary.wins` | `integer (int32)` | yes |  |
| `[].summary.losses` | `integer (int32)` | yes |  |
| `[].summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `[].summary.best_cycle` | `number (double) \| null` | no |  |
| `[].summary.worst_cycle` | `number (double) \| null` | no |  |
| `[].summary.avg_cycle` | `number (double) \| null` | no |  |
| `[].summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `[].summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `[].summary.days` | `integer (int32)` | yes |  |
| `[].summary.days_traded` | `integer (int32)` | yes |  |
| `[].summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `[].summary.metrics.total_return` | `number (double) \| null` | no |  |
| `[].summary.metrics.cagr` | `number (double) \| null` | no |  |
| `[].summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `[].summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `[].summary.metrics.sortino` | `number (double) \| null` | no |  |
| `[].summary.metrics.calmar` | `number (double) \| null` | no |  |
| `[].summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `[].summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `[].summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `[].summary.band.p5` | `number (double)` | yes |  |
| `[].summary.band.p50` | `number (double)` | yes |  |
| `[].summary.band.p95` | `number (double)` | yes |  |
| `[].labels` | `object \| null` | no | How to read the result (always shown with it). |
| `[].labels.estimated` | `boolean` | yes | Some price was modelled, not traded. |
| `[].labels.text` | `string` | yes | The sentence shown with every result. |
| `[].labels.notes` | `string[]` | yes | Further plain-English caveats. |
| `[].sources` | `object[]` | yes | Where every series came from. |
| `[].sources[].series` | `string` | yes | `underlying`, `vix`, `options`, `futures` or a watched name. |
| `[].sources[].symbol` | `string` | yes |  |
| `[].sources[].source` | `string` | yes | The history source id, or `estimator`. |
| `[].sources[].estimated` | `boolean` | yes |  |
| `[].sources[].note` | `string \| null` | no |  |
| `[].equity` | `object[] \| null` | no | Equity at each day's close (`GET /v2/backtests/{id}` only). |
| `[].equity[].day` | `string` | yes |  |
| `[].equity[].equity` | `number (double)` | yes |  |
| `[].equity[].pnl` | `number (double)` | yes |  |
| `[].equity[].drawdown` | `number (double)` | yes | Below the running peak (₹, positive). |
| `[].counts` | `object` | yes | Rows served by `.../trades` and `.../cycles`. |
| `[].counts.trades` | `integer (int32)` | yes |  |
| `[].counts.cycles` | `integer (int32)` | yes |  |

**Example: The caller's backtests (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "01M66QA2YJGRWYHB9VJRAW6BRW",
      "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
      "version": 1,
      "status": "cancelled",
      "from": "2026-10-14",
      "to": "2026-10-16",
      "capital": 500000,
      "costs": {
        "charges": "india",
        "slippage_bps": 2,
        "spread_proxy_bps": 10
      },
      "progress": {
        "done": 0,
        "total": 0
      },
      "cancel_requested": true,
      "created_at": "2026-10-30T15:37:00.627Z",
      "started_at": null,
      "finished_at": "2026-10-30T15:37:00.640Z",
      "expires_at": "2026-11-29T15:37:00.627Z",
      "error": null,
      "summary": null,
      "labels": null,
      "sources": [],
      "counts": {
        "trades": 0,
        "cycles": 0
      }
    },
    {
      "id": "01M66QA2X382CT2C47RR5XN0FY",
      "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
      "version": 1,
      "status": "done",
      "from": "2026-10-14",
      "to": "2026-10-16",
      "capital": 500000,
      "costs": {
        "charges": "india",
        "slippage_bps": 2,
        "spread_proxy_bps": 10
      },
      "progress": {
        "done": 3,
        "total": 3
      },
      "cancel_requested": false,
      "created_at": "2026-10-30T15:37:00.588Z",
      "started_at": "2026-10-30T15:37:00.752Z",
      "finished_at": "2026-10-30T15:37:02.016Z",
      "expires_at": "2026-11-29T15:37:00.588Z",
      "error": null,
      "summary": {
        "capital": 500000,
        "final_equity": 496635.79,
        "net_pnl": -3364.21,
        "charges": 307.96,
        "cycles": 3,
        "trades": 12,
        "wins": 0,
        "losses": 3,
        "win_rate": 0,
        "best_cycle": -54.99,
        "worst_cycle": -3176.51,
        "avg_cycle": -1121.4,
        "max_drawdown": 3592.73,
        "max_drawdown_pct": 0.0072,
        "days": 3,
        "days_traded": 3,
        "metrics": {
          "total_return": -0.0066,
          "cagr": -0.4276,
          "annualized_vol": 0.0462,
          "sharpe": -12.247,
          "sortino": -9.6967,
          "calmar": -64.5952,
          "max_drawdown": 0.0066,
          "profit_factor": 0
        },
        "band": null
      },
      "labels": {
        "estimated": true,
        "text": "Estimated: option prices are modelled from NIFTY and India VIX, not real trades",
        "notes": [
          "Expiries follow the exchange's weekday rules, strikes are 50 points apart and one lot is 75 units (today's lot size).",
          "Every strike uses the same volatility: India VIX × 1; real prices have a skew.",
          "Fills follow the forward test's rules: protected limits on entry, crossing the spread on exit, 2 bps slippage."
        ]
      },
      "sources": [
        {
          "series": "underlying",
          "symbol": "CT:EX:BTNIFTY50",
          "source": "chart_api",
          "estimated": false
        },
        {
          "series": "vix",
          "symbol": "CT:EX:BTINDIAVIX",
          "source": "chart_api",
          "estimated": false
        },
        {
          "series": "options",
          "symbol": "NFO:NIFTY",
          "source": "estimator",
          "estimated": true,
          "note": "no option history yet: prices are modelled"
        },
        {
          "series": "futures",
          "symbol": "NFO:NIFTY",
          "source": "estimator",
          "estimated": true,
          "note": "no option history yet: prices are modelled"
        }
      ],
      "counts": {
        "trades": 12,
        "cycles": 3
      }
    }
  ],
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/backtests

Scope: `strategies`

Start a backtest

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32) \| null` | no | The version to test; the latest saved one when absent. |
| `from` | `string` | yes | First day (`YYYY-MM-DD`, IST). |
| `to` | `string` | yes | Last day, inclusive; before today. `from` to `to` covers at most 1098 days (3 years), both included. |
| `capital` | `number (double) \| null` | no | Virtual capital (₹); default 5,00,000. |
| `costs` | `object \| null` | no | Costs to apply; defaults: Indian charges, 2 bps slippage. |
| `costs.charges` | `string \| null` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double) \| null` | no | Slippage against every fill, basis points (0 to 200). |

```bash
curl -X POST "https://api.cirrus.trade/v2/backtests" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
    "version": 1,
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2
    }
  }'
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes |  |
| `status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `progress` | `object` | yes | Days replayed so far. |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.total` | `integer (int32)` | yes |  |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `started_at` | `string (date-time) \| null` | no |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the result is deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `summary` | `object \| null` | no | Once done. |
| `summary.capital` | `number (double)` | yes |  |
| `summary.final_equity` | `number (double)` | yes |  |
| `summary.net_pnl` | `number (double)` | yes | After charges. |
| `summary.charges` | `number (double)` | yes |  |
| `summary.cycles` | `integer (int32)` | yes |  |
| `summary.trades` | `integer (int32)` | yes |  |
| `summary.wins` | `integer (int32)` | yes |  |
| `summary.losses` | `integer (int32)` | yes |  |
| `summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `summary.best_cycle` | `number (double) \| null` | no |  |
| `summary.worst_cycle` | `number (double) \| null` | no |  |
| `summary.avg_cycle` | `number (double) \| null` | no |  |
| `summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `summary.days` | `integer (int32)` | yes |  |
| `summary.days_traded` | `integer (int32)` | yes |  |
| `summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `summary.metrics.total_return` | `number (double) \| null` | no |  |
| `summary.metrics.cagr` | `number (double) \| null` | no |  |
| `summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `summary.metrics.sortino` | `number (double) \| null` | no |  |
| `summary.metrics.calmar` | `number (double) \| null` | no |  |
| `summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `summary.band.p5` | `number (double)` | yes |  |
| `summary.band.p50` | `number (double)` | yes |  |
| `summary.band.p95` | `number (double)` | yes |  |
| `labels` | `object \| null` | no | How to read the result (always shown with it). |
| `labels.estimated` | `boolean` | yes | Some price was modelled, not traded. |
| `labels.text` | `string` | yes | The sentence shown with every result. |
| `labels.notes` | `string[]` | yes | Further plain-English caveats. |
| `sources` | `object[]` | yes | Where every series came from. |
| `sources[].series` | `string` | yes | `underlying`, `vix`, `options`, `futures` or a watched name. |
| `sources[].symbol` | `string` | yes |  |
| `sources[].source` | `string` | yes | The history source id, or `estimator`. |
| `sources[].estimated` | `boolean` | yes |  |
| `sources[].note` | `string \| null` | no |  |
| `equity` | `object[] \| null` | no | Equity at each day's close (`GET /v2/backtests/{id}` only). |
| `equity[].day` | `string` | yes |  |
| `equity[].equity` | `number (double)` | yes |  |
| `equity[].pnl` | `number (double)` | yes |  |
| `equity[].drawdown` | `number (double)` | yes | Below the running peak (₹, positive). |
| `counts` | `object` | yes | Rows served by `.../trades` and `.../cycles`. |
| `counts.trades` | `integer (int32)` | yes |  |
| `counts.cycles` | `integer (int32)` | yes |  |

**Example: Queued for a worker (202)**

```json
{
  "status": "success",
  "data": {
    "id": "01M66QA2X382CT2C47RR5XN0FY",
    "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
    "version": 1,
    "status": "queued",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "progress": {
      "done": 0,
      "total": 0
    },
    "cancel_requested": false,
    "created_at": "2026-10-30T15:37:00.588Z",
    "started_at": null,
    "finished_at": null,
    "expires_at": "2026-11-29T15:37:00.588Z",
    "error": null,
    "summary": null,
    "labels": null,
    "sources": [],
    "counts": {
      "trades": 0,
      "cycles": 0
    }
  },
  "error": null
}
```

**Example: Three are already queued or running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "backtest_limit_reached",
    "message": "At most 3 backtests may run at once; wait for one to finish or cancel one",
    "field": null
  }
}
```

**Example: from after to (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "from must be on or before to",
    "field": "from"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `version_not_found` | No such version of the strategy. |
| 409 | `backtest_limit_reached` | The most backtests that may be queued or running at once, or started today, are reached. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/backtests/{backtest_id}

Scope: `read`

Get a backtest

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `backtest_id` | `string` | yes | Backtest id. |

```bash
curl "https://api.cirrus.trade/v2/backtests/<backtest_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes |  |
| `status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `progress` | `object` | yes | Days replayed so far. |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.total` | `integer (int32)` | yes |  |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `started_at` | `string (date-time) \| null` | no |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the result is deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `summary` | `object \| null` | no | Once done. |
| `summary.capital` | `number (double)` | yes |  |
| `summary.final_equity` | `number (double)` | yes |  |
| `summary.net_pnl` | `number (double)` | yes | After charges. |
| `summary.charges` | `number (double)` | yes |  |
| `summary.cycles` | `integer (int32)` | yes |  |
| `summary.trades` | `integer (int32)` | yes |  |
| `summary.wins` | `integer (int32)` | yes |  |
| `summary.losses` | `integer (int32)` | yes |  |
| `summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `summary.best_cycle` | `number (double) \| null` | no |  |
| `summary.worst_cycle` | `number (double) \| null` | no |  |
| `summary.avg_cycle` | `number (double) \| null` | no |  |
| `summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `summary.days` | `integer (int32)` | yes |  |
| `summary.days_traded` | `integer (int32)` | yes |  |
| `summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `summary.metrics.total_return` | `number (double) \| null` | no |  |
| `summary.metrics.cagr` | `number (double) \| null` | no |  |
| `summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `summary.metrics.sortino` | `number (double) \| null` | no |  |
| `summary.metrics.calmar` | `number (double) \| null` | no |  |
| `summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `summary.band.p5` | `number (double)` | yes |  |
| `summary.band.p50` | `number (double)` | yes |  |
| `summary.band.p95` | `number (double)` | yes |  |
| `labels` | `object \| null` | no | How to read the result (always shown with it). |
| `labels.estimated` | `boolean` | yes | Some price was modelled, not traded. |
| `labels.text` | `string` | yes | The sentence shown with every result. |
| `labels.notes` | `string[]` | yes | Further plain-English caveats. |
| `sources` | `object[]` | yes | Where every series came from. |
| `sources[].series` | `string` | yes | `underlying`, `vix`, `options`, `futures` or a watched name. |
| `sources[].symbol` | `string` | yes |  |
| `sources[].source` | `string` | yes | The history source id, or `estimator`. |
| `sources[].estimated` | `boolean` | yes |  |
| `sources[].note` | `string \| null` | no |  |
| `equity` | `object[] \| null` | no | Equity at each day's close (`GET /v2/backtests/{id}` only). |
| `equity[].day` | `string` | yes |  |
| `equity[].equity` | `number (double)` | yes |  |
| `equity[].pnl` | `number (double)` | yes |  |
| `equity[].drawdown` | `number (double)` | yes | Below the running peak (₹, positive). |
| `counts` | `object` | yes | Rows served by `.../trades` and `.../cycles`. |
| `counts.trades` | `integer (int32)` | yes |  |
| `counts.cycles` | `integer (int32)` | yes |  |

**Example: A finished backtest on modelled option prices (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M66QA2X382CT2C47RR5XN0FY",
    "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
    "version": 1,
    "status": "done",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "progress": {
      "done": 3,
      "total": 3
    },
    "cancel_requested": false,
    "created_at": "2026-10-30T15:37:00.588Z",
    "started_at": "2026-10-30T15:37:00.752Z",
    "finished_at": "2026-10-30T15:37:02.016Z",
    "expires_at": "2026-11-29T15:37:00.588Z",
    "error": null,
    "summary": {
      "capital": 500000,
      "final_equity": 496635.79,
      "net_pnl": -3364.21,
      "charges": 307.96,
      "cycles": 3,
      "trades": 12,
      "wins": 0,
      "losses": 3,
      "win_rate": 0,
      "best_cycle": -54.99,
      "worst_cycle": -3176.51,
      "avg_cycle": -1121.4,
      "max_drawdown": 3592.73,
      "max_drawdown_pct": 0.0072,
      "days": 3,
      "days_traded": 3,
      "metrics": {
        "total_return": -0.0066,
        "cagr": -0.4276,
        "annualized_vol": 0.0462,
        "sharpe": -12.247,
        "sortino": -9.6967,
        "calmar": -64.5952,
        "max_drawdown": 0.0066,
        "profit_factor": 0
      },
      "band": null
    },
    "labels": {
      "estimated": true,
      "text": "Estimated: option prices are modelled from NIFTY and India VIX, not real trades",
      "notes": [
        "Expiries follow the exchange's weekday rules, strikes are 50 points apart and one lot is 75 units (today's lot size).",
        "Every strike uses the same volatility: India VIX × 1; real prices have a skew.",
        "Fills follow the forward test's rules: protected limits on entry, crossing the spread on exit, 2 bps slippage."
      ]
    },
    "sources": [
      {
        "series": "underlying",
        "symbol": "CT:EX:BTNIFTY50",
        "source": "chart_api",
        "estimated": false
      },
      {
        "series": "vix",
        "symbol": "CT:EX:BTINDIAVIX",
        "source": "chart_api",
        "estimated": false
      },
      {
        "series": "options",
        "symbol": "NFO:NIFTY",
        "source": "estimator",
        "estimated": true,
        "note": "no option history yet: prices are modelled"
      },
      {
        "series": "futures",
        "symbol": "NFO:NIFTY",
        "source": "estimator",
        "estimated": true,
        "note": "no option history yet: prices are modelled"
      }
    ],
    "equity": [
      {
        "day": "2026-10-14",
        "equity": 499945.01,
        "pnl": -54.99,
        "drawdown": 283.51
      },
      {
        "day": "2026-10-15",
        "equity": 499812.3,
        "pnl": -132.71,
        "drawdown": 416.22
      },
      {
        "day": "2026-10-16",
        "equity": 496635.79,
        "pnl": -3176.51,
        "drawdown": 3592.73
      }
    ],
    "counts": {
      "trades": 12,
      "cycles": 3
    }
  },
  "error": null
}
```

**Example: No such backtest (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "backtest_not_found",
    "message": "Backtest not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/backtests/{backtest_id}/trades

Scope: `read`

List a backtest's trades

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `backtest_id` | `string` | yes | Backtest id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `offset` | `integer (int32)` | no |  |
| `limit` | `integer (int32)` | no |  |

```bash
curl "https://api.cirrus.trade/v2/backtests/<backtest_id>/trades" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes |  |
| `items[].n` | `integer (int32)` | yes | 1-based, in time order. |
| `items[].day` | `string` | yes |  |
| `items[].cycle` | `integer (int32)` | yes |  |
| `items[].leg_id` | `string` | yes |  |
| `items[].symbol` | `string` | yes |  |
| `items[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `items[].qty` | `integer (int64)` | yes |  |
| `items[].price` | `number (double)` | yes |  |
| `items[].charges` | `number (double)` | yes |  |
| `items[].at_ms` | `integer (int64)` | yes |  |
| `items[].purpose` | `string` | yes | What a fill was for. One of: `entry`, `exit`. |
| `items[].estimated` | `boolean` | yes | The price is modelled (no history of this contract). |
| `offset` | `integer (int32)` | yes |  |
| `limit` | `integer (int32)` | yes |  |
| `total` | `integer (int32)` | yes |  |

**Example: The first two fills (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "n": 1,
        "day": "2026-10-14",
        "cycle": 1,
        "leg_id": "sc",
        "symbol": "BT:NFO:NIFTY:20261019:24450:CE",
        "side": "SELL",
        "qty": 75,
        "price": 36.6,
        "charges": 27.74,
        "at_ms": 1791950400000,
        "purpose": "entry",
        "estimated": true
      },
      {
        "n": 2,
        "day": "2026-10-14",
        "cycle": 1,
        "leg_id": "sp",
        "symbol": "BT:NFO:NIFTY:20261019:23850:PE",
        "side": "SELL",
        "qty": 75,
        "price": 45.5,
        "charges": 28.74,
        "at_ms": 1791950400000,
        "purpose": "entry",
        "estimated": true
      }
    ],
    "offset": 0,
    "limit": 2,
    "total": 12
  },
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/backtests/{backtest_id}/cycles

Scope: `read`

List a backtest's cycles

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `backtest_id` | `string` | yes | Backtest id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `offset` | `integer (int32)` | no |  |
| `limit` | `integer (int32)` | no |  |

```bash
curl "https://api.cirrus.trade/v2/backtests/<backtest_id>/cycles" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes |  |
| `items[].n` | `integer (int32)` | yes | 1-based, in time order. |
| `items[].day` | `string` | yes |  |
| `items[].cycle` | `integer (int32)` | yes | The cycle number of its day. |
| `items[].opened_at_ms` | `integer (int64) \| null` | no |  |
| `items[].closed_at_ms` | `integer (int64) \| null` | no |  |
| `items[].exit_reason` | `string \| null` | no |  |
| `items[].pnl` | `number (double) \| null` | no | Net of charges; absent while the cycle never filled. |
| `items[].charges` | `number (double)` | yes |  |
| `items[].steps` | `object[]` | yes |  |
| `items[].steps[].at_ms` | `integer (int64)` | yes |  |
| `items[].steps[].code` | `string` | yes |  |
| `items[].steps[].phase` | `string` | yes |  |
| `items[].steps[].text` | `string` | yes | One plain-English sentence. |
| `offset` | `integer (int32)` | yes |  |
| `limit` | `integer (int32)` | yes |  |
| `total` | `integer (int32)` | yes |  |

**Example: The first cycle and its journey (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "n": 1,
        "day": "2026-10-14",
        "cycle": 1,
        "opened_at_ms": 1791950400000,
        "closed_at_ms": 1791971100000,
        "exit_reason": "square_off",
        "pnl": -54.99,
        "charges": 103.74,
        "steps": [
          {
            "at_ms": 1791950400000,
            "code": "entry_sent",
            "phase": "entering",
            "text": "The entry window is open (time-based entry). Entering: sell 1 lot call, sell 1 lot put."
          },
          {
            "at_ms": 1791950400000,
            "code": "entered",
            "phase": "managing",
            "text": "Entered: sold 75 × BT:NFO:NIFTY:20261019:24450:CE at ₹36.60, sold 75 × BT:NFO:NIFTY:20261019:23850:PE at ₹45.50. Protected: combined stop at -₹3,000, target at +₹2,000, square-off at 15:15."
          },
          {
            "at_ms": 1791971100000,
            "code": "exit_sent",
            "phase": "exiting",
            "text": "Square-off time: P&L +₹75. Closing the trade."
          },
          {
            "at_ms": 1791971100000,
            "code": "cycle_closed",
            "phase": "cooldown",
            "text": "Closed every leg. Trade P&L +₹49 (charges excluded). Today: +₹49 · 1 of 3 trades."
          },
          {
            "at_ms": 1791971100000,
            "code": "finished",
            "phase": "finished",
            "text": "No more trades today (re-entry rules)."
          }
        ]
      }
    ],
    "offset": 0,
    "limit": 1,
    "total": 3
  },
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/backtests/{backtest_id}/cancel

Scope: `strategies`

Cancel a backtest

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `backtest_id` | `string` | yes | Backtest id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X POST "https://api.cirrus.trade/v2/backtests/<backtest_id>/cancel" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes |  |
| `status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `progress` | `object` | yes | Days replayed so far. |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.total` | `integer (int32)` | yes |  |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `started_at` | `string (date-time) \| null` | no |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the result is deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `summary` | `object \| null` | no | Once done. |
| `summary.capital` | `number (double)` | yes |  |
| `summary.final_equity` | `number (double)` | yes |  |
| `summary.net_pnl` | `number (double)` | yes | After charges. |
| `summary.charges` | `number (double)` | yes |  |
| `summary.cycles` | `integer (int32)` | yes |  |
| `summary.trades` | `integer (int32)` | yes |  |
| `summary.wins` | `integer (int32)` | yes |  |
| `summary.losses` | `integer (int32)` | yes |  |
| `summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `summary.best_cycle` | `number (double) \| null` | no |  |
| `summary.worst_cycle` | `number (double) \| null` | no |  |
| `summary.avg_cycle` | `number (double) \| null` | no |  |
| `summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `summary.days` | `integer (int32)` | yes |  |
| `summary.days_traded` | `integer (int32)` | yes |  |
| `summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `summary.metrics.total_return` | `number (double) \| null` | no |  |
| `summary.metrics.cagr` | `number (double) \| null` | no |  |
| `summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `summary.metrics.sortino` | `number (double) \| null` | no |  |
| `summary.metrics.calmar` | `number (double) \| null` | no |  |
| `summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `summary.band.p5` | `number (double)` | yes |  |
| `summary.band.p50` | `number (double)` | yes |  |
| `summary.band.p95` | `number (double)` | yes |  |
| `labels` | `object \| null` | no | How to read the result (always shown with it). |
| `labels.estimated` | `boolean` | yes | Some price was modelled, not traded. |
| `labels.text` | `string` | yes | The sentence shown with every result. |
| `labels.notes` | `string[]` | yes | Further plain-English caveats. |
| `sources` | `object[]` | yes | Where every series came from. |
| `sources[].series` | `string` | yes | `underlying`, `vix`, `options`, `futures` or a watched name. |
| `sources[].symbol` | `string` | yes |  |
| `sources[].source` | `string` | yes | The history source id, or `estimator`. |
| `sources[].estimated` | `boolean` | yes |  |
| `sources[].note` | `string \| null` | no |  |
| `equity` | `object[] \| null` | no | Equity at each day's close (`GET /v2/backtests/{id}` only). |
| `equity[].day` | `string` | yes |  |
| `equity[].equity` | `number (double)` | yes |  |
| `equity[].pnl` | `number (double)` | yes |  |
| `equity[].drawdown` | `number (double)` | yes | Below the running peak (₹, positive). |
| `counts` | `object` | yes | Rows served by `.../trades` and `.../cycles`. |
| `counts.trades` | `integer (int32)` | yes |  |
| `counts.cycles` | `integer (int32)` | yes |  |

**Example: A queued backtest is cancelled at once (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M66QA2YJGRWYHB9VJRAW6BRW",
    "strategy_id": "01M66QA2V4VGYTC47SA6EEBXZM",
    "version": 1,
    "status": "cancelled",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "progress": {
      "done": 0,
      "total": 0
    },
    "cancel_requested": true,
    "created_at": "2026-10-30T15:37:00.627Z",
    "started_at": null,
    "finished_at": "2026-10-30T15:37:00.640Z",
    "expires_at": "2026-11-29T15:37:00.627Z",
    "error": null,
    "summary": null,
    "labels": null,
    "sources": [],
    "counts": {
      "trades": 0,
      "cycles": 0
    }
  },
  "error": null
}
```

**Example: Nothing to cancel (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "backtest_finished",
    "message": "This backtest has already finished",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 409 | `backtest_finished` | The backtest has already finished (done, failed or cancelled); nothing to cancel. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/backtests/sweeps

Scope: `read`

List sweeps

```bash
curl "https://api.cirrus.trade/v2/backtests/sweeps" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes |  |
| `[].strategy_id` | `string` | yes |  |
| `[].version` | `integer (int32)` | yes | The saved version the sweep started from. |
| `[].status` | `string` | yes | `running` until every version ended, then `done`, `failed` (fewer than two versions finished) or `cancelled`. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `[].from` | `string` | yes |  |
| `[].to` | `string` | yes |  |
| `[].capital` | `number (double)` | yes |  |
| `[].costs` | `object` | yes | Costs a backtest applies. |
| `[].costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `[].costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `[].costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `[].axes` | `object[]` | yes |  |
| `[].axes[].path` | `string` | yes | JSON pointer to a number in the strategy document, e.g. `/spec/legs/items/0/strike/delta`. |
| `[].axes[].values` | `(number (double))[]` | yes | Values to try, smallest first. |
| `[].progress` | `object` | yes | The sweep's versions by where they are. |
| `[].progress.total` | `integer (int32)` | yes |  |
| `[].progress.queued` | `integer (int32)` | yes |  |
| `[].progress.running` | `integer (int32)` | yes |  |
| `[].progress.done` | `integer (int32)` | yes |  |
| `[].progress.failed` | `integer (int32)` | yes |  |
| `[].progress.cancelled` | `integer (int32)` | yes |  |
| `[].cost` | `integer (int64)` | yes | What it counts against the daily backtest limit. |
| `[].cancel_requested` | `boolean` | yes |  |
| `[].created_at` | `string (date-time)` | yes |  |
| `[].finished_at` | `string (date-time) \| null` | no |  |
| `[].expires_at` | `string (date-time)` | yes | When the sweep and its backtests are deleted. |
| `[].error` | `string \| null` | no | Why it failed, in words. |
| `[].recommended` | `integer (int32) \| null` | no | The recommended version's `n`: the best plateau, not the best point. |
| `[].warnings` | `string[]` | yes | Plain words, shown before deploy. |
| `[].notes` | `string[]` | yes | Checks that did not run and why, and versions left out. |
| `[].deflated_sharpe` | `number (double) \| null` | no | Deflated Sharpe ratio of the recommended version (0 to 1). |
| `[].pbo` | `number (double) \| null` | no | Probability of backtest overfitting (0 to 1), from 16 versions. |
| `[].walk_forward` | `object \| null` | no |  |
| `[].walk_forward.tests` | `object[]` | yes |  |
| `[].walk_forward.tests[].train` | `(integer (int32))[]` | yes | Day indexes (from 0), end exclusive. |
| `[].walk_forward.tests[].test` | `(integer (int32))[]` | yes |  |
| `[].walk_forward.tests[].chosen` | `integer (int32)` | yes | The version chosen on the training days (`n`). |
| `[].walk_forward.tests[].test_return` | `number (double)` | yes | Its compounded return on the test days (0.01 = 1%). |
| `[].walk_forward.lost` | `integer (int32)` | yes | Tests whose chosen version lost money. |
| `[].versions` | `object[] \| null` | no | Every version (`GET /v2/backtests/sweeps/{sweep_id}` only). |
| `[].versions[].n` | `integer (int32)` | yes | Its number in the sweep (from 0; the last setting varies fastest). |
| `[].versions[].values` | `(number (double))[]` | yes | One value per axis, in the order of `axes`. |
| `[].versions[].backtest_id` | `string` | yes | Its backtest (`GET /v2/backtests/{backtest_id}` for trades and cycles). |
| `[].versions[].status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `[].versions[].error` | `string \| null` | no | Why it failed, in words. |
| `[].versions[].summary` | `object \| null` | no | The headline numbers. |
| `[].versions[].summary.capital` | `number (double)` | yes |  |
| `[].versions[].summary.final_equity` | `number (double)` | yes |  |
| `[].versions[].summary.net_pnl` | `number (double)` | yes | After charges. |
| `[].versions[].summary.charges` | `number (double)` | yes |  |
| `[].versions[].summary.cycles` | `integer (int32)` | yes |  |
| `[].versions[].summary.trades` | `integer (int32)` | yes |  |
| `[].versions[].summary.wins` | `integer (int32)` | yes |  |
| `[].versions[].summary.losses` | `integer (int32)` | yes |  |
| `[].versions[].summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `[].versions[].summary.best_cycle` | `number (double) \| null` | no |  |
| `[].versions[].summary.worst_cycle` | `number (double) \| null` | no |  |
| `[].versions[].summary.avg_cycle` | `number (double) \| null` | no |  |
| `[].versions[].summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `[].versions[].summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `[].versions[].summary.days` | `integer (int32)` | yes |  |
| `[].versions[].summary.days_traded` | `integer (int32)` | yes |  |
| `[].versions[].summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `[].versions[].summary.metrics.total_return` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.cagr` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.sortino` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.calmar` | `number (double) \| null` | no |  |
| `[].versions[].summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `[].versions[].summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `[].versions[].summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `[].versions[].summary.band.p5` | `number (double)` | yes |  |
| `[].versions[].summary.band.p50` | `number (double)` | yes |  |
| `[].versions[].summary.band.p95` | `number (double)` | yes |  |
| `[].versions[].sharpe` | `number (double) \| null` | no | Per-day Sharpe ratio, once the sweep is done (absent when undefined). |
| `[].versions[].plateau` | `number (double) \| null` | no | Mean Sharpe over its grid neighbours: the recommended version has the best. |
| `[].versions[].recommended` | `boolean` | yes |  |

**Example: The caller's sweeps (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "01M6729QKEQ99A4TXHNMZAA7NG",
      "strategy_id": "01M6729PTSDV47N0JD25NBZQVX",
      "version": 1,
      "status": "done",
      "from": "2026-10-14",
      "to": "2026-10-16",
      "capital": 500000,
      "costs": {
        "charges": "india",
        "slippage_bps": 2,
        "spread_proxy_bps": 10
      },
      "axes": [
        {
          "path": "/spec/legs/items/0/strike/delta",
          "values": [
            0.15,
            0.2
          ]
        },
        {
          "path": "/spec/risk/combined/stop_loss/rupees",
          "values": [
            2000,
            3000
          ]
        }
      ],
      "progress": {
        "total": 4,
        "queued": 0,
        "running": 0,
        "done": 3,
        "failed": 1,
        "cancelled": 0
      },
      "cost": 1,
      "cancel_requested": false,
      "created_at": "2026-10-30T18:49:03.347Z",
      "finished_at": "2026-10-30T18:49:04.277Z",
      "expires_at": "2026-11-29T18:49:03.347Z",
      "error": null,
      "recommended": 0,
      "warnings": [
        "Best result may be luck: after testing 3 versions, the chance the best one is really better than average is low (deflated Sharpe 0.17).",
        "Fewer than 30 trades: too few to judge."
      ],
      "notes": [
        "Walk-forward test skipped: it needs at least 100 trading days of history (20 per window), and this sweep has 3.",
        "Overfitting check (PBO) skipped: it needs at least 16 versions, and this sweep has 3.",
        "1 of 4 versions did not finish (failed or cancelled) and are left out of the checks."
      ],
      "deflated_sharpe": 0.1691982360279356,
      "pbo": null,
      "walk_forward": null
    }
  ],
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/backtests/sweeps

Scope: `strategies`

Start a sweep

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32) \| null` | no | The saved version the sweep starts from; the latest when absent. |
| `axes` | `object[]` | yes | 1 to 3 settings; at most 200 versions in all. |
| `axes[].path` | `string` | yes | JSON pointer to a number in the strategy document, e.g. `/spec/legs/items/0/strike/delta`. |
| `axes[].values` | `(number (double))[]` | yes | Values to try, smallest first. |
| `from` | `string` | yes | First day (`YYYY-MM-DD`, IST). |
| `to` | `string` | yes | Last day, inclusive; before today. `from` to `to` covers at most 1098 days (3 years), both included. |
| `capital` | `number (double) \| null` | no | Virtual capital (₹); default 5,00,000. |
| `costs` | `object \| null` | no | Costs to apply; defaults: Indian charges, 2 bps slippage. |
| `costs.charges` | `string \| null` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double) \| null` | no | Slippage against every fill, basis points (0 to 200). |

```bash
curl -X POST "https://api.cirrus.trade/v2/backtests/sweeps" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "strategy_id": "01M66YJSSC5QFRPRW0AF5Y9HXM",
    "version": 1,
    "from": "2026-10-14",
    "to": "2026-10-16",
    "axes": [
      {
        "path": "/spec/legs/items/0/strike/delta",
        "values": [
          0.15,
          0.2
        ]
      },
      {
        "path": "/spec/risk/combined/stop_loss/rupees",
        "values": [
          2000,
          3000
        ]
      }
    ]
  }'
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes | The saved version the sweep started from. |
| `status` | `string` | yes | `running` until every version ended, then `done`, `failed` (fewer than two versions finished) or `cancelled`. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `axes` | `object[]` | yes |  |
| `axes[].path` | `string` | yes | JSON pointer to a number in the strategy document, e.g. `/spec/legs/items/0/strike/delta`. |
| `axes[].values` | `(number (double))[]` | yes | Values to try, smallest first. |
| `progress` | `object` | yes | The sweep's versions by where they are. |
| `progress.total` | `integer (int32)` | yes |  |
| `progress.queued` | `integer (int32)` | yes |  |
| `progress.running` | `integer (int32)` | yes |  |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.failed` | `integer (int32)` | yes |  |
| `progress.cancelled` | `integer (int32)` | yes |  |
| `cost` | `integer (int64)` | yes | What it counts against the daily backtest limit. |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the sweep and its backtests are deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `recommended` | `integer (int32) \| null` | no | The recommended version's `n`: the best plateau, not the best point. |
| `warnings` | `string[]` | yes | Plain words, shown before deploy. |
| `notes` | `string[]` | yes | Checks that did not run and why, and versions left out. |
| `deflated_sharpe` | `number (double) \| null` | no | Deflated Sharpe ratio of the recommended version (0 to 1). |
| `pbo` | `number (double) \| null` | no | Probability of backtest overfitting (0 to 1), from 16 versions. |
| `walk_forward` | `object \| null` | no |  |
| `walk_forward.tests` | `object[]` | yes |  |
| `walk_forward.tests[].train` | `(integer (int32))[]` | yes | Day indexes (from 0), end exclusive. |
| `walk_forward.tests[].test` | `(integer (int32))[]` | yes |  |
| `walk_forward.tests[].chosen` | `integer (int32)` | yes | The version chosen on the training days (`n`). |
| `walk_forward.tests[].test_return` | `number (double)` | yes | Its compounded return on the test days (0.01 = 1%). |
| `walk_forward.lost` | `integer (int32)` | yes | Tests whose chosen version lost money. |
| `versions` | `object[] \| null` | no | Every version (`GET /v2/backtests/sweeps/{sweep_id}` only). |
| `versions[].n` | `integer (int32)` | yes | Its number in the sweep (from 0; the last setting varies fastest). |
| `versions[].values` | `(number (double))[]` | yes | One value per axis, in the order of `axes`. |
| `versions[].backtest_id` | `string` | yes | Its backtest (`GET /v2/backtests/{backtest_id}` for trades and cycles). |
| `versions[].status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `versions[].error` | `string \| null` | no | Why it failed, in words. |
| `versions[].summary` | `object \| null` | no | The headline numbers. |
| `versions[].summary.capital` | `number (double)` | yes |  |
| `versions[].summary.final_equity` | `number (double)` | yes |  |
| `versions[].summary.net_pnl` | `number (double)` | yes | After charges. |
| `versions[].summary.charges` | `number (double)` | yes |  |
| `versions[].summary.cycles` | `integer (int32)` | yes |  |
| `versions[].summary.trades` | `integer (int32)` | yes |  |
| `versions[].summary.wins` | `integer (int32)` | yes |  |
| `versions[].summary.losses` | `integer (int32)` | yes |  |
| `versions[].summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `versions[].summary.best_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.worst_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.avg_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `versions[].summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `versions[].summary.days` | `integer (int32)` | yes |  |
| `versions[].summary.days_traded` | `integer (int32)` | yes |  |
| `versions[].summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `versions[].summary.metrics.total_return` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.cagr` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sortino` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.calmar` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `versions[].summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `versions[].summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `versions[].summary.band.p5` | `number (double)` | yes |  |
| `versions[].summary.band.p50` | `number (double)` | yes |  |
| `versions[].summary.band.p95` | `number (double)` | yes |  |
| `versions[].sharpe` | `number (double) \| null` | no | Per-day Sharpe ratio, once the sweep is done (absent when undefined). |
| `versions[].plateau` | `number (double) \| null` | no | Mean Sharpe over its grid neighbours: the recommended version has the best. |
| `versions[].recommended` | `boolean` | yes |  |

**Example: Four versions queued (202)**

```json
{
  "status": "success",
  "data": {
    "id": "01M66YJTX11JWSA4K90RGYJPMA",
    "strategy_id": "01M66YJSSC5QFRPRW0AF5Y9HXM",
    "version": 1,
    "status": "running",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "axes": [
      {
        "path": "/spec/legs/items/0/strike/delta",
        "values": [
          0.15,
          0.2
        ]
      },
      {
        "path": "/spec/risk/combined/stop_loss/rupees",
        "values": [
          2000,
          3000
        ]
      }
    ],
    "progress": {
      "total": 4,
      "queued": 4,
      "running": 0,
      "done": 0,
      "failed": 0,
      "cancelled": 0
    },
    "cost": 1,
    "cancel_requested": false,
    "created_at": "2026-10-30T17:44:07.342Z",
    "finished_at": null,
    "expires_at": "2026-11-29T17:44:07.342Z",
    "error": null,
    "recommended": null,
    "warnings": [],
    "notes": [],
    "deflated_sharpe": null,
    "pbo": null,
    "walk_forward": null
  },
  "error": null
}
```

**Example: A path that is not a setting (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "/spec/legs/items/0/strike/gamma is not a setting of this strategy (a JSON pointer such as /spec/legs/items/0/strike/delta)",
    "field": "axes"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `version_not_found` | No such version of the strategy. |
| 409 | `backtest_limit_reached` | The most backtests that may be queued or running at once, or started today, are reached. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/backtests/sweeps/{sweep_id}

Scope: `read`

Get a sweep

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sweep_id` | `string` | yes | Sweep id. |

```bash
curl "https://api.cirrus.trade/v2/backtests/sweeps/<sweep_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes | The saved version the sweep started from. |
| `status` | `string` | yes | `running` until every version ended, then `done`, `failed` (fewer than two versions finished) or `cancelled`. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `axes` | `object[]` | yes |  |
| `axes[].path` | `string` | yes | JSON pointer to a number in the strategy document, e.g. `/spec/legs/items/0/strike/delta`. |
| `axes[].values` | `(number (double))[]` | yes | Values to try, smallest first. |
| `progress` | `object` | yes | The sweep's versions by where they are. |
| `progress.total` | `integer (int32)` | yes |  |
| `progress.queued` | `integer (int32)` | yes |  |
| `progress.running` | `integer (int32)` | yes |  |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.failed` | `integer (int32)` | yes |  |
| `progress.cancelled` | `integer (int32)` | yes |  |
| `cost` | `integer (int64)` | yes | What it counts against the daily backtest limit. |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the sweep and its backtests are deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `recommended` | `integer (int32) \| null` | no | The recommended version's `n`: the best plateau, not the best point. |
| `warnings` | `string[]` | yes | Plain words, shown before deploy. |
| `notes` | `string[]` | yes | Checks that did not run and why, and versions left out. |
| `deflated_sharpe` | `number (double) \| null` | no | Deflated Sharpe ratio of the recommended version (0 to 1). |
| `pbo` | `number (double) \| null` | no | Probability of backtest overfitting (0 to 1), from 16 versions. |
| `walk_forward` | `object \| null` | no |  |
| `walk_forward.tests` | `object[]` | yes |  |
| `walk_forward.tests[].train` | `(integer (int32))[]` | yes | Day indexes (from 0), end exclusive. |
| `walk_forward.tests[].test` | `(integer (int32))[]` | yes |  |
| `walk_forward.tests[].chosen` | `integer (int32)` | yes | The version chosen on the training days (`n`). |
| `walk_forward.tests[].test_return` | `number (double)` | yes | Its compounded return on the test days (0.01 = 1%). |
| `walk_forward.lost` | `integer (int32)` | yes | Tests whose chosen version lost money. |
| `versions` | `object[] \| null` | no | Every version (`GET /v2/backtests/sweeps/{sweep_id}` only). |
| `versions[].n` | `integer (int32)` | yes | Its number in the sweep (from 0; the last setting varies fastest). |
| `versions[].values` | `(number (double))[]` | yes | One value per axis, in the order of `axes`. |
| `versions[].backtest_id` | `string` | yes | Its backtest (`GET /v2/backtests/{backtest_id}` for trades and cycles). |
| `versions[].status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `versions[].error` | `string \| null` | no | Why it failed, in words. |
| `versions[].summary` | `object \| null` | no | The headline numbers. |
| `versions[].summary.capital` | `number (double)` | yes |  |
| `versions[].summary.final_equity` | `number (double)` | yes |  |
| `versions[].summary.net_pnl` | `number (double)` | yes | After charges. |
| `versions[].summary.charges` | `number (double)` | yes |  |
| `versions[].summary.cycles` | `integer (int32)` | yes |  |
| `versions[].summary.trades` | `integer (int32)` | yes |  |
| `versions[].summary.wins` | `integer (int32)` | yes |  |
| `versions[].summary.losses` | `integer (int32)` | yes |  |
| `versions[].summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `versions[].summary.best_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.worst_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.avg_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `versions[].summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `versions[].summary.days` | `integer (int32)` | yes |  |
| `versions[].summary.days_traded` | `integer (int32)` | yes |  |
| `versions[].summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `versions[].summary.metrics.total_return` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.cagr` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sortino` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.calmar` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `versions[].summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `versions[].summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `versions[].summary.band.p5` | `number (double)` | yes |  |
| `versions[].summary.band.p50` | `number (double)` | yes |  |
| `versions[].summary.band.p95` | `number (double)` | yes |  |
| `versions[].sharpe` | `number (double) \| null` | no | Per-day Sharpe ratio, once the sweep is done (absent when undefined). |
| `versions[].plateau` | `number (double) \| null` | no | Mean Sharpe over its grid neighbours: the recommended version has the best. |
| `versions[].recommended` | `boolean` | yes |  |

**Example: Done with one version failed: recommended version, warnings and notes (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M6729QKEQ99A4TXHNMZAA7NG",
    "strategy_id": "01M6729PTSDV47N0JD25NBZQVX",
    "version": 1,
    "status": "done",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "axes": [
      {
        "path": "/spec/legs/items/0/strike/delta",
        "values": [
          0.15,
          0.2
        ]
      },
      {
        "path": "/spec/risk/combined/stop_loss/rupees",
        "values": [
          2000,
          3000
        ]
      }
    ],
    "progress": {
      "total": 4,
      "queued": 0,
      "running": 0,
      "done": 3,
      "failed": 1,
      "cancelled": 0
    },
    "cost": 1,
    "cancel_requested": false,
    "created_at": "2026-10-30T18:49:03.347Z",
    "finished_at": "2026-10-30T18:49:04.277Z",
    "expires_at": "2026-11-29T18:49:03.347Z",
    "error": null,
    "recommended": 0,
    "warnings": [
      "Best result may be luck: after testing 3 versions, the chance the best one is really better than average is low (deflated Sharpe 0.17).",
      "Fewer than 30 trades: too few to judge."
    ],
    "notes": [
      "Walk-forward test skipped: it needs at least 100 trading days of history (20 per window), and this sweep has 3.",
      "Overfitting check (PBO) skipped: it needs at least 16 versions, and this sweep has 3.",
      "1 of 4 versions did not finish (failed or cancelled) and are left out of the checks."
    ],
    "deflated_sharpe": 0.1691982360279356,
    "pbo": null,
    "walk_forward": null,
    "versions": [
      {
        "n": 0,
        "values": [
          0.15,
          2000
        ],
        "backtest_id": "01M6729QKFE6A2XEC6Q2PBZZ26",
        "status": "done",
        "error": null,
        "summary": {
          "capital": 500000,
          "final_equity": 498079.58,
          "net_pnl": -1920.42,
          "charges": 304.17,
          "cycles": 3,
          "trades": 12,
          "wins": 2,
          "losses": 1,
          "win_rate": 0.67,
          "best_cycle": 182.21,
          "worst_cycle": -2193.13,
          "avg_cycle": -640.14,
          "max_drawdown": 2394.76,
          "max_drawdown_pct": 0.0048,
          "days": 3,
          "days_traded": 3,
          "metrics": {
            "total_return": -0.0042,
            "cagr": -0.298,
            "annualized_vol": 0.0349,
            "sharpe": -9.2477,
            "sortino": -8.025,
            "calmar": -67.9822,
            "max_drawdown": 0.0044,
            "profit_factor": 0
          },
          "band": null
        },
        "sharpe": -0.475648339981058,
        "plateau": -0.5498070234280426,
        "recommended": true
      },
      {
        "n": 1,
        "values": [
          0.15,
          3000
        ],
        "backtest_id": "01M6729QKG9GG46QSCPWZ7S1Q8",
        "status": "done",
        "error": null,
        "summary": {
          "capital": 500000,
          "final_equity": 497097.07,
          "net_pnl": -2902.93,
          "charges": 304.18,
          "cycles": 3,
          "trades": 12,
          "wins": 2,
          "losses": 1,
          "win_rate": 0.67,
          "best_cycle": 182.21,
          "worst_cycle": -3175.64,
          "avg_cycle": -967.64,
          "max_drawdown": 3377.27,
          "max_drawdown_pct": 0.0067,
          "days": 3,
          "days_traded": 3,
          "metrics": {
            "total_return": -0.0062,
            "cagr": -0.4053,
            "annualized_vol": 0.0496,
            "sharpe": -9.8352,
            "sortino": -8.3778,
            "calmar": -63.8507,
            "max_drawdown": 0.0063,
            "profit_factor": 0
          },
          "band": null
        },
        "sharpe": -0.5058669394394733,
        "plateau": -0.5498070234280426,
        "recommended": false
      },
      {
        "n": 2,
        "values": [
          0.2,
          2000
        ],
        "backtest_id": "01M6729QKHZPHC8KN8E4K30RKP",
        "status": "done",
        "error": null,
        "summary": {
          "capital": 500000,
          "final_equity": 497933.3,
          "net_pnl": -2066.7,
          "charges": 307.95,
          "cycles": 3,
          "trades": 12,
          "wins": 0,
          "losses": 3,
          "win_rate": 0,
          "best_cycle": -54.99,
          "worst_cycle": -1879,
          "avg_cycle": -688.9,
          "max_drawdown": 2397.95,
          "max_drawdown_pct": 0.0048,
          "days": 3,
          "days_traded": 3,
          "metrics": {
            "total_return": -0.004,
            "cagr": -0.2873,
            "annualized_vol": 0.0267,
            "sharpe": -12.9856,
            "sortino": -10.0511,
            "calmar": -71.3976,
            "max_drawdown": 0.004,
            "profit_factor": 0
          },
          "band": null
        },
        "sharpe": -0.6679057908635965,
        "plateau": -0.5498070234280426,
        "recommended": false
      },
      {
        "n": 3,
        "values": [
          0.2,
          3000
        ],
        "backtest_id": "01M6729QKJ72C8CF01BZ1ND5C9",
        "status": "failed",
        "error": "History for NIFTY 50 is missing on 2026-10-15",
        "summary": null,
        "sharpe": null,
        "plateau": null,
        "recommended": false
      }
    ]
  },
  "error": null
}
```

**Example: No such sweep (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "backtest_not_found",
    "message": "Sweep not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### DELETE /v2/backtests/sweeps/{sweep_id}

Scope: `strategies`

Cancel a sweep

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sweep_id` | `string` | yes | Sweep id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v2/backtests/sweeps/<sweep_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes | The saved version the sweep started from. |
| `status` | `string` | yes | `running` until every version ended, then `done`, `failed` (fewer than two versions finished) or `cancelled`. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `capital` | `number (double)` | yes |  |
| `costs` | `object` | yes | Costs a backtest applies. |
| `costs.charges` | `string` | no | Which charges a backtest applies. One of: `india`, `zero`. |
| `costs.slippage_bps` | `number (double)` | no | Against every fill (basis points). |
| `costs.spread_proxy_bps` | `number (double)` | no | Spread assumed for a series without bid / ask (basis points). |
| `axes` | `object[]` | yes |  |
| `axes[].path` | `string` | yes | JSON pointer to a number in the strategy document, e.g. `/spec/legs/items/0/strike/delta`. |
| `axes[].values` | `(number (double))[]` | yes | Values to try, smallest first. |
| `progress` | `object` | yes | The sweep's versions by where they are. |
| `progress.total` | `integer (int32)` | yes |  |
| `progress.queued` | `integer (int32)` | yes |  |
| `progress.running` | `integer (int32)` | yes |  |
| `progress.done` | `integer (int32)` | yes |  |
| `progress.failed` | `integer (int32)` | yes |  |
| `progress.cancelled` | `integer (int32)` | yes |  |
| `cost` | `integer (int64)` | yes | What it counts against the daily backtest limit. |
| `cancel_requested` | `boolean` | yes |  |
| `created_at` | `string (date-time)` | yes |  |
| `finished_at` | `string (date-time) \| null` | no |  |
| `expires_at` | `string (date-time)` | yes | When the sweep and its backtests are deleted. |
| `error` | `string \| null` | no | Why it failed, in words. |
| `recommended` | `integer (int32) \| null` | no | The recommended version's `n`: the best plateau, not the best point. |
| `warnings` | `string[]` | yes | Plain words, shown before deploy. |
| `notes` | `string[]` | yes | Checks that did not run and why, and versions left out. |
| `deflated_sharpe` | `number (double) \| null` | no | Deflated Sharpe ratio of the recommended version (0 to 1). |
| `pbo` | `number (double) \| null` | no | Probability of backtest overfitting (0 to 1), from 16 versions. |
| `walk_forward` | `object \| null` | no |  |
| `walk_forward.tests` | `object[]` | yes |  |
| `walk_forward.tests[].train` | `(integer (int32))[]` | yes | Day indexes (from 0), end exclusive. |
| `walk_forward.tests[].test` | `(integer (int32))[]` | yes |  |
| `walk_forward.tests[].chosen` | `integer (int32)` | yes | The version chosen on the training days (`n`). |
| `walk_forward.tests[].test_return` | `number (double)` | yes | Its compounded return on the test days (0.01 = 1%). |
| `walk_forward.lost` | `integer (int32)` | yes | Tests whose chosen version lost money. |
| `versions` | `object[] \| null` | no | Every version (`GET /v2/backtests/sweeps/{sweep_id}` only). |
| `versions[].n` | `integer (int32)` | yes | Its number in the sweep (from 0; the last setting varies fastest). |
| `versions[].values` | `(number (double))[]` | yes | One value per axis, in the order of `axes`. |
| `versions[].backtest_id` | `string` | yes | Its backtest (`GET /v2/backtests/{backtest_id}` for trades and cycles). |
| `versions[].status` | `string` | yes | Where a backtest is. One of: `queued`, `running`, `done`, `failed`, `cancelled`, `unknown`. |
| `versions[].error` | `string \| null` | no | Why it failed, in words. |
| `versions[].summary` | `object \| null` | no | The headline numbers. |
| `versions[].summary.capital` | `number (double)` | yes |  |
| `versions[].summary.final_equity` | `number (double)` | yes |  |
| `versions[].summary.net_pnl` | `number (double)` | yes | After charges. |
| `versions[].summary.charges` | `number (double)` | yes |  |
| `versions[].summary.cycles` | `integer (int32)` | yes |  |
| `versions[].summary.trades` | `integer (int32)` | yes |  |
| `versions[].summary.wins` | `integer (int32)` | yes |  |
| `versions[].summary.losses` | `integer (int32)` | yes |  |
| `versions[].summary.win_rate` | `number (double) \| null` | no | Winning cycles over closed cycles (0 to 1); absent with none. |
| `versions[].summary.best_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.worst_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.avg_cycle` | `number (double) \| null` | no |  |
| `versions[].summary.max_drawdown` | `number (double)` | yes | Worst fall from a peak of equity, minute by minute (₹, positive). |
| `versions[].summary.max_drawdown_pct` | `number (double)` | yes | The same as a fraction of that peak. |
| `versions[].summary.days` | `integer (int32)` | yes |  |
| `versions[].summary.days_traded` | `integer (int32)` | yes |  |
| `versions[].summary.metrics` | `object` | no | Performance metrics from ferro over daily equity (absent when the run has fewer than two days, or a value is undefined). |
| `versions[].summary.metrics.total_return` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.cagr` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.annualized_vol` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sharpe` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.sortino` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.calmar` | `number (double) \| null` | no |  |
| `versions[].summary.metrics.max_drawdown` | `number (double) \| null` | no | Fraction (0.08 = 8 %), from daily closes. |
| `versions[].summary.metrics.profit_factor` | `number (double) \| null` | no |  |
| `versions[].summary.band` | `object \| null` | no | 5–95 % band of final equity from a bootstrap of daily returns. |
| `versions[].summary.band.p5` | `number (double)` | yes |  |
| `versions[].summary.band.p50` | `number (double)` | yes |  |
| `versions[].summary.band.p95` | `number (double)` | yes |  |
| `versions[].sharpe` | `number (double) \| null` | no | Per-day Sharpe ratio, once the sweep is done (absent when undefined). |
| `versions[].plateau` | `number (double) \| null` | no | Mean Sharpe over its grid neighbours: the recommended version has the best. |
| `versions[].recommended` | `boolean` | yes |  |

**Example: Every queued version cancelled at once (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M66YJW74ZVAWVXYG8XH6E9KA",
    "strategy_id": "01M66YJSSC5QFRPRW0AF5Y9HXM",
    "version": 1,
    "status": "cancelled",
    "from": "2026-10-14",
    "to": "2026-10-16",
    "capital": 500000,
    "costs": {
      "charges": "india",
      "slippage_bps": 2,
      "spread_proxy_bps": 10
    },
    "axes": [
      {
        "path": "/spec/legs/items/0/strike/delta",
        "values": [
          0.15,
          0.2
        ]
      },
      {
        "path": "/spec/risk/combined/stop_loss/rupees",
        "values": [
          2000,
          3000
        ]
      }
    ],
    "progress": {
      "total": 4,
      "queued": 0,
      "running": 0,
      "done": 0,
      "failed": 0,
      "cancelled": 4
    },
    "cost": 1,
    "cancel_requested": true,
    "created_at": "2026-10-30T17:44:08.683Z",
    "finished_at": "2026-10-30T17:44:08.693Z",
    "expires_at": "2026-11-29T17:44:08.683Z",
    "error": null,
    "recommended": null,
    "warnings": [],
    "notes": [],
    "deflated_sharpe": null,
    "pbo": null,
    "walk_forward": null,
    "versions": [
      {
        "n": 0,
        "values": [
          0.15,
          2000
        ],
        "backtest_id": "01M66YJW76BYE5CF5QP9WSQYKM",
        "status": "cancelled",
        "error": null,
        "summary": null,
        "sharpe": null,
        "plateau": null,
        "recommended": false
      },
      {
        "n": 1,
        "values": [
          0.15,
          3000
        ],
        "backtest_id": "01M66YJW78S2SFS8FPXBTX3GWA",
        "status": "cancelled",
        "error": null,
        "summary": null,
        "sharpe": null,
        "plateau": null,
        "recommended": false
      },
      {
        "n": 2,
        "values": [
          0.2,
          2000
        ],
        "backtest_id": "01M66YJW79PPVQMH0ERFK033DJ",
        "status": "cancelled",
        "error": null,
        "summary": null,
        "sharpe": null,
        "plateau": null,
        "recommended": false
      },
      {
        "n": 3,
        "values": [
          0.2,
          3000
        ],
        "backtest_id": "01M66YJW7BQT1DP0N3MPMV006A",
        "status": "cancelled",
        "error": null,
        "summary": null,
        "sharpe": null,
        "plateau": null,
        "recommended": false
      }
    ]
  },
  "error": null
}
```

**Example: Nothing to cancel (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "backtest_finished",
    "message": "This sweep has already finished",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `backtest_not_found` | No backtest with this id for the caller (results are kept 30 days). |
| 409 | `backtest_finished` | The backtest has already finished (done, failed or cancelled); nothing to cancel. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

## Deployments

Run a strategy on paper or live accounts, then pause, stop or take it live.

### GET /v2/strategies/{strategy_id}/deployments

Scope: `read`

List a strategy's deployments

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>/deployments" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes |  |
| `[].strategy_id` | `string` | yes |  |
| `[].strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `[].version` | `integer (int32)` | yes |  |
| `[].mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `[].status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `[].status_reason` | `string \| null` | no |  |
| `[].strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `[].accounts` | `object[]` | yes |  |
| `[].accounts[].account` | `string` | yes |  |
| `[].accounts[].multiplier` | `number (double)` | yes |  |
| `[].units` | `object[]` | yes |  |
| `[].units[].account` | `string` | yes |  |
| `[].units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `[].units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `[].units[].status_reason` | `string \| null` | no |  |
| `[].units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `[].units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `[].universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `[].universe.watchlist_id` | `string` | yes |  |
| `[].universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `[].universe.skipped` | `object[]` | yes | Members that got no unit. |
| `[].universe.skipped[].instrument_token` | `string` | yes |  |
| `[].universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `[].universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `[].start_on` | `string \| null` | no |  |
| `[].end_on` | `string \| null` | no |  |
| `[].acknowledged` | `string[]` | yes |  |
| `[].compiled_hash` | `string` | yes |  |
| `[].last_intent` | `object \| null` | no |  |
| `[].last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `[].last_intent.exit_now` | `boolean` | yes |  |
| `[].last_intent.at` | `string` | yes |  |
| `[].last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `[].engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `[].created_at` | `string` | yes |  |
| `[].updated_at` | `string` | yes |  |
| `[].stopped_at` | `string \| null` | no |  |
| `[].pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `[].pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `[].pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `[].pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `[].pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `[].pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `[].safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `[].safety_pauses[].account` | `string` | yes |  |
| `[].safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `[].safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `[].safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `[].safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `[].safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: The strategy's deployments (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "01M59F60KTGB8Z8KZN7YD3NPGJ",
      "strategy_id": "01M59F60KBWPK7TJJEQJXB3CME",
      "strategy_name": "NIFTY manual straddle",
      "version": 1,
      "mode": "paper",
      "status": "active",
      "status_reason": null,
      "strategy_tag": "auto.01M59F60KTGB",
      "accounts": [
        {
          "account": "P1",
          "multiplier": 1
        },
        {
          "account": "P2",
          "multiplier": 2
        }
      ],
      "units": [
        {
          "account": "P1",
          "unit_id": "01M59F60KT01MG5H5YK26WE5MN",
          "status": "active",
          "status_reason": null
        },
        {
          "account": "P2",
          "unit_id": "01M59F60KTKBMX8KK1GDA493S3",
          "status": "active",
          "status_reason": null
        }
      ],
      "start_on": null,
      "end_on": null,
      "acknowledged": [],
      "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
      "last_intent": {
        "action": "deploy",
        "exit_now": false,
        "at": "2026-10-19T06:57:00.028Z",
        "delivered": false
      },
      "engine_connected": false,
      "created_at": "2026-10-19T06:57:00.026Z",
      "updated_at": "2026-10-19T06:57:00.028Z",
      "stopped_at": null,
      "pnl": {
        "trading_day": "2026-10-19",
        "realized": 0,
        "unrealized": 0,
        "total": 0,
        "ltp_stale": false
      }
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/{strategy_id}/deployments

Scope: `strategies`

Deploy a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `version` | `integer (int32) \| null` | no | The version to run (default: the latest, saving the draft as a new version first when it changed). |
| `mode` | `string` | yes | One of: `paper`, `live`. |
| `accounts` | `object[]` | yes | 1 to 4 accounts; all paper (forward test) or all live. |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double) \| null` | no | 0.1 to 10 in steps of 0.1 (default 1). |
| `start_on` | `string \| null` | no | First trading day (`YYYY-MM-DD`). |
| `end_on` | `string \| null` | no | Last trading day (`YYYY-MM-DD`). |
| `bindings` | `object \| null` | no |  |
| `bindings.signal_endpoint_id` | `string \| null` | no | Signal URL bound to a `webhook@1` source. |
| `bindings.notify_webhook_id` | `string \| null` | no | Webhook bound to a `webhook@1` notifier. |
| `acknowledge` | `string[]` | no | Warning codes the user acknowledged (`unlimited_loss`). |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/<strategy_id>/deployments" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "paper",
    "accounts": [
      {
        "account": "P1"
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ]
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: A forward test on two paper accounts (201)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKAX6MG5BY1HWXCDNNDJ",
    "strategy_id": "01M574VKA1TTA2BFREM70ZEB9D",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "paper",
    "status": "active",
    "status_reason": null,
    "strategy_tag": "auto.01M574VKAX6M",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M574VKAXNYV06E6HEBZ5VV48",
        "status": "active",
        "status_reason": null
      },
      {
        "account": "P2",
        "unit_id": "01M574VKAXHVS1Y56YFHHSRT9D",
        "status": "active",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "deploy",
      "exit_now": false,
      "at": "2026-10-18T09:18:04.130Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:04.125Z",
    "updated_at": "2026-10-18T09:18:04.125Z",
    "stopped_at": null
  },
  "error": null
}
```

**Example: An unknown mode (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown variant `demo`, expected `paper` or `live`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: The broker is not open for live strategies (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "live_not_enabled",
    "message": "Live deployments aren't available for pocketful accounts yet",
    "field": null
  }
}
```

**Example: Not one of the user's accounts (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "account_not_found",
    "message": "Account not found for this user",
    "field": "accounts[0].account"
  }
}
```

**Example: Three deployments already run (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_limit_reached",
    "message": "At most 3 deployments may run at once; stop one first",
    "field": null
  }
}
```

**Example: Unlimited loss must be acknowledged (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "acknowledgement_required",
    "message": "Acknowledge to deploy: Protect · Combined protection: Combined stop is missing for a short option: your loss would be unlimited. (unlimited_loss)",
    "field": "acknowledge"
  }
}
```

**Example: Indicator conditions are not on this server yet (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "not_deployable",
    "message": "This version can't be deployed yet:\nWhen · Entry: `rsi(14, '5m') > 60` uses rsi, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
    "field": "version"
  }
}
```

**Example: Forward tests use paper accounts only (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "Forward tests (paper deployments) use paper accounts only",
    "field": "accounts[0].account"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `deployment_limit_reached` | The most deployments that may run at once are running; stop one first. |
| 422 | `not_deployable` | The strategy version cannot be deployed yet (a component or option not released, or an underlying not open); `message` says why. |
| 422 | `acknowledgement_required` | A warning must be acknowledged to deploy (e.g. `unlimited_loss`): add its code to `acknowledge`. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/deployments/{deployment_id}

Scope: `read`

Get a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

```bash
curl "https://api.cirrus.trade/v2/deployments/<deployment_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: One deployment with its units (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M59F60KTGB8Z8KZN7YD3NPGJ",
    "strategy_id": "01M59F60KBWPK7TJJEQJXB3CME",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "paper",
    "status": "active",
    "status_reason": null,
    "strategy_tag": "auto.01M59F60KTGB",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M59F60KT01MG5H5YK26WE5MN",
        "status": "active",
        "status_reason": null
      },
      {
        "account": "P2",
        "unit_id": "01M59F60KTKBMX8KK1GDA493S3",
        "status": "active",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "deploy",
      "exit_now": false,
      "at": "2026-10-19T06:57:00.028Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-19T06:57:00.026Z",
    "updated_at": "2026-10-19T06:57:00.028Z",
    "stopped_at": null,
    "pnl": {
      "trading_day": "2026-10-19",
      "realized": 0,
      "unrealized": 0,
      "total": 0,
      "ltp_stale": false
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/deployments/{deployment_id}/pause

Scope: `strategies`

Pause a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/pause" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: No new cycles; open trades stay managed (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKAX6MG5BY1HWXCDNNDJ",
    "strategy_id": "01M574VKA1TTA2BFREM70ZEB9D",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "paper",
    "status": "paused",
    "status_reason": "paused_by_user",
    "strategy_tag": "auto.01M574VKAX6M",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M574VKAXNYV06E6HEBZ5VV48",
        "status": "paused",
        "status_reason": null
      },
      {
        "account": "P2",
        "unit_id": "01M574VKAXHVS1Y56YFHHSRT9D",
        "status": "paused",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "pause",
      "exit_now": false,
      "at": "2026-10-18T09:18:07.185Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:04.125Z",
    "updated_at": "2026-10-18T09:18:07.185Z",
    "stopped_at": null
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The deployment is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/deployments/{deployment_id}/resume

Scope: `strategies`

Resume a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `acknowledged` | `boolean` | no | Required (true) when the deployment was paused for safety (the circuit breaker tripped): you have looked at what happened. |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/resume" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: New cycles again (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKAX6MG5BY1HWXCDNNDJ",
    "strategy_id": "01M574VKA1TTA2BFREM70ZEB9D",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "paper",
    "status": "active",
    "status_reason": null,
    "strategy_tag": "auto.01M574VKAX6M",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M574VKAXNYV06E6HEBZ5VV48",
        "status": "active",
        "status_reason": null
      },
      {
        "account": "P2",
        "unit_id": "01M574VKAXHVS1Y56YFHHSRT9D",
        "status": "active",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "resume",
      "exit_now": false,
      "at": "2026-10-18T09:18:07.257Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:04.125Z",
    "updated_at": "2026-10-18T09:18:07.257Z",
    "stopped_at": null
  },
  "error": null
}
```

**Example: acknowledged must be true or false (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: invalid type: string \"yes\", expected a boolean",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The deployment is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 422 | `acknowledgement_required` | A warning must be acknowledged to deploy (e.g. `unlimited_loss`): add its code to `acknowledge`. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/deployments/{deployment_id}/journey

Scope: `read`

A deployment's journey

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | `string` | no | `next_cursor` of the previous page. |
| `limit` | `integer` | no | 1 to 200 (default 50). |

```bash
curl "https://api.cirrus.trade/v2/deployments/<deployment_id>/journey" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `entries` | `object[]` | yes |  |
| `entries[].id` | `string` | yes | Opaque, stable. |
| `entries[].at` | `integer (int64)` | yes | Epoch ms. |
| `entries[].account` | `string` | yes |  |
| `entries[].day` | `string \| null` | no | Trading day of the cycle (`YYYY-MM-DD`), when the step belongs to one. |
| `entries[].cycle` | `integer (int32) \| null` | no | Cycle number that day (0: before the first). |
| `entries[].kind` | `string` | yes | What happened (`armed`, `entered`, `exit_sent`, `cycle_closed`, `circuit_breaker`, ...; `api` for your own requests, `refused` for a request the engine refused; other values may appear). |
| `entries[].what_happened` | `string` | yes |  |
| `entries[].what_we_did` | `string` | yes |  |
| `entries[].what_you_should_do` | `string` | yes | Empty when nothing is needed. |
| `entries[].broker_message` | `string \| null` | no | The broker's own words, when a broker answered. |
| `next_cursor` | `string \| null` | no | Pass as `cursor` for the next (older) page; null at the end. |

**Example: Nothing happened yet: an empty page (200)**

```json
{
  "status": "success",
  "data": {
    "entries": [],
    "next_cursor": null
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: limit out of range (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "limit must be 1 to 200",
    "field": "limit"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v2/deployments/{deployment_id}/stop

Scope: `strategies`

Stop a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `exit_now` | `boolean` | no | Also close the open trades now, mains first and hedges last. |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/stop" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "exit_now": false
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: Stopped; open trades run to their own exits (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VNTJ98VMM7TRKT5JCC0T",
    "strategy_id": "01M574VNQJB3KC6HMDBG6QDEHV",
    "strategy_name": "Timed",
    "version": 1,
    "mode": "paper",
    "status": "stopped",
    "status_reason": "stopped_by_user",
    "strategy_tag": "auto.01M574VNTJ98",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M574VNTJF2Z0CBAWNB2BBX14",
        "status": "stopped",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:c030cdecce518bdf50067237a17de0b5508d3e962e743a87dd41e7df3ef01ff9",
    "last_intent": {
      "action": "stop",
      "exit_now": false,
      "at": "2026-10-18T09:18:08.708Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:06.674Z",
    "updated_at": "2026-10-18T09:18:08.708Z",
    "stopped_at": "2026-10-18T09:18:08.708Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The deployment is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/deployments/{deployment_id}/exit-now

Scope: `strategies`

Exit open trades now

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/exit-now" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: Close open trades now, keep running (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKAX6MG5BY1HWXCDNNDJ",
    "strategy_id": "01M574VKA1TTA2BFREM70ZEB9D",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "paper",
    "status": "active",
    "status_reason": null,
    "strategy_tag": "auto.01M574VKAX6M",
    "accounts": [
      {
        "account": "P1",
        "multiplier": 1
      },
      {
        "account": "P2",
        "multiplier": 2
      }
    ],
    "units": [
      {
        "account": "P1",
        "unit_id": "01M574VKAXNYV06E6HEBZ5VV48",
        "status": "active",
        "status_reason": null
      },
      {
        "account": "P2",
        "unit_id": "01M574VKAXHVS1Y56YFHHSRT9D",
        "status": "active",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "exit_now",
      "exit_now": true,
      "at": "2026-10-18T09:18:07.334Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:04.125Z",
    "updated_at": "2026-10-18T09:18:07.257Z",
    "stopped_at": null
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The deployment is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/deployments/{deployment_id}/go-live

Scope: `strategies`

Take a forward test live

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | The paper deployment. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double) \| null` | no | 0.1 to 10 in steps of 0.1 (default 1). |
| `acknowledge` | `string[]` | no |  |
| `keep_paper` | `boolean \| null` | no | Keep the forward test running alongside (default true). |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/go-live" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accounts": [
      {
        "account": "Z1"
      }
    ],
    "keep_paper": false
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `strategy_id` | `string` | yes |  |
| `strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `version` | `integer (int32)` | yes |  |
| `mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `status_reason` | `string \| null` | no |  |
| `strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double)` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `units[].status_reason` | `string \| null` | no |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `universe.watchlist_id` | `string` | yes |  |
| `universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `universe.skipped` | `object[]` | yes | Members that got no unit. |
| `universe.skipped[].instrument_token` | `string` | yes |  |
| `universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `start_on` | `string \| null` | no |  |
| `end_on` | `string \| null` | no |  |
| `acknowledged` | `string[]` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `last_intent` | `object \| null` | no |  |
| `last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `last_intent.exit_now` | `boolean` | yes |  |
| `last_intent.at` | `string` | yes |  |
| `last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `stopped_at` | `string \| null` | no |  |
| `pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `safety_pauses[].account` | `string` | yes |  |
| `safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: The same version live; the forward test stopped (201)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VPR4P8457ZBFGSHJ4CRP",
    "strategy_id": "01M574VKA1TTA2BFREM70ZEB9D",
    "strategy_name": "NIFTY manual straddle",
    "version": 1,
    "mode": "live",
    "status": "active",
    "status_reason": null,
    "strategy_tag": "auto.01M574VPR4P8",
    "accounts": [
      {
        "account": "Z1",
        "multiplier": 1
      }
    ],
    "units": [
      {
        "account": "Z1",
        "unit_id": "01M574VPR4YP5XCWSC2X6YPR5R",
        "status": "active",
        "status_reason": null
      }
    ],
    "start_on": null,
    "end_on": null,
    "acknowledged": [],
    "compiled_hash": "sha256:93332f0feac143d79d9430ca8c247082ad8af7ca2820ba1492bc4af7a3c61937",
    "last_intent": {
      "action": "deploy",
      "exit_now": false,
      "at": "2026-10-18T09:18:07.623Z",
      "delivered": false
    },
    "engine_connected": false,
    "created_at": "2026-10-18T09:18:07.620Z",
    "updated_at": "2026-10-18T09:18:07.620Z",
    "stopped_at": null
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The forward test is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Example: Live deployments use live accounts only (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "Live deployments use live accounts only",
    "field": "accounts[0].account"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 404 | `account_not_found` | No linked broker account with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 409 | `deployment_limit_reached` | The most deployments that may run at once are running; stop one first. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `not_deployable` | The strategy version cannot be deployed yet (a component or option not released, or an underlying not open); `message` says why. |
| 422 | `acknowledgement_required` | A warning must be acknowledged to deploy (e.g. `unlimited_loss`): add its code to `acknowledge`. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/deployments/{deployment_id}/live

Scope: `read`

Live values of a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

```bash
curl "https://api.cirrus.trade/v2/deployments/<deployment_id>/live" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes |  |
| `engine_connected` | `boolean` | yes |  |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades; absent otherwise. |
| `units[].phase` | `string` | yes | Cycle phase (`waiting`, `armed`, ...), or `unknown`. |
| `units[].now` | `string` | yes | What it is doing now, in plain words. |
| `units[].values` | `object` | yes | Indicator values, legs, greeks and P&L as the engine reports them. |
| `units[].unit` | `object \| null` | no | The engine's numbers, typed (absent when the engine did not answer or is not connected). |
| `units[].unit.state` | `string` | yes | Engine state (`armed`, `paused`, `done`, ...). |
| `units[].unit.cycle` | `integer (int32)` | yes | Cycle number today (0 before the first). |
| `units[].unit.pnl` | `number (double) \| null` | no | Open trade P&L (₹); null when no trade is open or a price is stale. |
| `units[].unit.day_pnl` | `number (double)` | yes | Realized P&L today (₹). |
| `units[].unit.trades_today` | `integer (int32)` | yes | Trades started today. |
| `units[].unit.floor` | `number (double) \| null` | no | Combined stop floor (₹), once raised. |
| `units[].unit.legs` | `object[]` | yes |  |
| `units[].unit.legs[].leg_id` | `string` | yes |  |
| `units[].unit.legs[].symbol` | `string` | yes | The contract (instrument token). |
| `units[].unit.legs[].side` | `string` | yes | `buy` or `sell`. |
| `units[].unit.legs[].qty` | `integer (int64)` | yes |  |
| `units[].unit.legs[].avg_price` | `number (double)` | yes |  |
| `units[].unit.legs[].ltp` | `number (double) \| null` | no |  |
| `units[].unit.legs[].pnl` | `number (double) \| null` | no | Realized plus open (₹). |
| `units[].unit.legs[].stop` | `number (double) \| null` | no |  |
| `units[].unit.legs[].target` | `number (double) \| null` | no |  |
| `units[].unit.legs[].iv_pct` | `number (double) \| null` | no | Implied volatility from `ltp`, in percent (options only; absent when it does not solve). |
| `units[].unit.legs[].greeks` | `object \| null` | no | Of the whole leg (`qty` units); absent without an IV. |
| `units[].unit.legs[].greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `units[].unit.legs[].greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `units[].unit.legs[].greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `units[].unit.legs[].greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `units[].unit.conditions` | `object[]` | yes | Values the conditions read now. |
| `units[].unit.conditions[].label` | `string` | yes | `RSI(14) · NIFTY 50 · 5m`. |
| `units[].unit.conditions[].expr` | `string` | yes | As written (`u.rsi(14, '5m')`). |
| `units[].unit.conditions[].condition` | `string` | yes | `entry` or `exit`. |
| `units[].unit.conditions[].status` | `string` | yes | `warming`, `ready`, `fallback`. |
| `units[].unit.conditions[].value` | `number (double) \| null` | no | The forming candle's value, else the last closed one. |
| `units[].unit.conditions[].op` | `string \| null` | no | `<`, `>=`, `crosses_above`, ... |
| `units[].unit.conditions[].threshold` | `number (double) \| null` | no |  |
| `units[].unit.conditions[].distance` | `number (double) \| null` | no | How far the value is from the threshold. |
| `units[].unit.pnl_series` | `object[]` | yes | The open trade's P&L samples, oldest first. |
| `units[].unit.pnl_series[].at` | `integer (int64)` | yes | Epoch ms. |
| `units[].unit.pnl_series[].pnl` | `number (double)` | yes |  |
| `units[].unit.breaker` | `object \| null` | no | The safety pause, when tripped. |
| `units[].unit.breaker.detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `units[].unit.breaker.evidence` | `string` | yes | What it saw, in words and numbers. |
| `units[].unit.breaker.at` | `integer (int64)` | yes | Epoch ms. |
| `units[].unit.breaker.trips_today` | `integer (int32)` | yes | Trips today (two lock resume until the next trading day). |
| `units[].unit.blocked` | `string \| null` | no | Why new entries are blocked, when they are. |
| `units[].unit.greeks` | `object \| null` | no | Net greeks of the open legs (`delta`: the net delta), from their last prices and the underlying's (Black-Scholes, as `GET /v1/strategies/{name}`); absent with `analytics_note` when they cannot be computed, or when no leg is open. |
| `units[].unit.greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `units[].unit.greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `units[].unit.greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `units[].unit.greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `units[].unit.analytics_note` | `string \| null` | no | Why `greeks` (or a leg's `iv_pct`) is missing, in plain English. |
| `units[].unit.trades` | `object[]` | no | Each open trade, when the strategy holds several at once (`max_open_trades` above 1; absent otherwise). `legs` and `pnl` are then every open trade's together. |
| `units[].unit.trades[].trade` | `string` | yes | `{day}:{cycle}`: the trade's id. |
| `units[].unit.trades[].cycle` | `integer (int32)` | yes | Its cycle number on `day` (as in the journey and the cycles list). |
| `units[].unit.trades[].day` | `string` | yes | The trading day it opened (`YYYY-MM-DD`, IST). |
| `units[].unit.trades[].phase` | `string` | yes | `entering`, `managing`, `adjusting`, `exiting`, ... |
| `units[].unit.trades[].pnl` | `number (double) \| null` | no | Trade P&L (₹); null while a price is stale. |
| `units[].unit.trades[].floor` | `number (double) \| null` | no | Its combined stop floor (₹), once raised. |
| `units[].unit.trades[].entered_at` | `integer (int64) \| null` | no | When it entered (epoch ms). |
| `units[].unit.trades[].exit_reason` | `string \| null` | no | Why it is closing, once it is. |
| `units[].unit.trades[].legs` | `object[]` | yes |  |
| `units[].unit.trades[].legs[].leg_id` | `string` | yes |  |
| `units[].unit.trades[].legs[].symbol` | `string` | yes | The contract (instrument token). |
| `units[].unit.trades[].legs[].side` | `string` | yes | `buy` or `sell`. |
| `units[].unit.trades[].legs[].qty` | `integer (int64)` | yes |  |
| `units[].unit.trades[].legs[].avg_price` | `number (double)` | yes |  |
| `units[].unit.trades[].legs[].ltp` | `number (double) \| null` | no |  |
| `units[].unit.trades[].legs[].pnl` | `number (double) \| null` | no | Realized plus open (₹). |
| `units[].unit.trades[].legs[].stop` | `number (double) \| null` | no |  |
| `units[].unit.trades[].legs[].target` | `number (double) \| null` | no |  |
| `units[].unit.trades[].legs[].iv_pct` | `number (double) \| null` | no | Implied volatility from `ltp`, in percent (options only; absent when it does not solve). |
| `units[].unit.trades[].legs[].greeks` | `object \| null` | no | Of the whole leg (`qty` units); absent without an IV. |
| `units[].unit.trades[].legs[].greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `units[].unit.trades[].legs[].greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `units[].unit.trades[].legs[].greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `units[].unit.trades[].legs[].greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |

**Example: Live values (engine not connected yet) (200)**

```json
{
  "status": "success",
  "data": {
    "deployment_id": "01M574VKAX6MG5BY1HWXCDNNDJ",
    "engine_connected": false,
    "units": [
      {
        "account": "P1",
        "phase": "unknown",
        "now": "The strategy engine isn't connected yet: this was saved and will run once it is.",
        "values": {}
      },
      {
        "account": "P2",
        "phase": "unknown",
        "now": "The strategy engine isn't connected yet: this was saved and will run once it is.",
        "values": {}
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/deployments/{deployment_id}/cycles

Scope: `read`

List a deployment's cycles

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

```bash
curl "https://api.cirrus.trade/v2/deployments/<deployment_id>/cycles" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].account` | `string` | yes |  |
| `[].day` | `string` | yes |  |
| `[].n` | `integer (int32)` | yes |  |
| `[].phase` | `string` | yes | Cycle phase (§5), projected from engine events. One of: `waiting`, `armed`, `entering`, `open`, `managing`, `adjusting`, `exiting`, `cooldown`, `finished`, `unknown`. |
| `[].legs` | `object[]` | yes |  |
| `[].exit_reason` | `string \| null` | no |  |
| `[].pnl` | `object` | yes |  |
| `[].pnl.realized` | `number (double)` | yes |  |
| `[].pnl.unrealized` | `number (double)` | yes |  |
| `[].opened_at` | `string \| null` | no |  |
| `[].closed_at` | `string \| null` | no |  |

**Example: No cycles yet (200)**

```json
{
  "status": "success",
  "data": [],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/deployments/{deployment_id}/cycles/{day}/{n}

Scope: `read`

Get a cycle with its journey

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |
| `day` | `string` | yes | Trading day, `YYYY-MM-DD`. |
| `n` | `integer (int32)` | yes | Cycle number on that day, from 1. |

```bash
curl "https://api.cirrus.trade/v2/deployments/<deployment_id>/cycles/<day>/<n>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].cycle` | `object` | yes | One cycle of one account. |
| `[].cycle.account` | `string` | yes |  |
| `[].cycle.day` | `string` | yes |  |
| `[].cycle.n` | `integer (int32)` | yes |  |
| `[].cycle.phase` | `string` | yes | Cycle phase (§5), projected from engine events. One of: `waiting`, `armed`, `entering`, `open`, `managing`, `adjusting`, `exiting`, `cooldown`, `finished`, `unknown`. |
| `[].cycle.legs` | `object[]` | yes |  |
| `[].cycle.exit_reason` | `string \| null` | no |  |
| `[].cycle.pnl` | `object` | yes |  |
| `[].cycle.pnl.realized` | `number (double)` | yes |  |
| `[].cycle.pnl.unrealized` | `number (double)` | yes |  |
| `[].cycle.opened_at` | `string \| null` | no |  |
| `[].cycle.closed_at` | `string \| null` | no |  |
| `[].journey` | `object[]` | yes | Journey entries, oldest first. |

**Example: No such cycle yet: an empty list (200)**

```json
{
  "status": "success",
  "data": [],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/deployments/{deployment_id}/signals

Scope: `strategies_signal`

Send a signal to a deployment

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deployment_id` | `string` | yes | Deployment id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | `string` | yes | One of: `enter`, `exit`, `exit_legs`. |
| `hints` | `object \| null` | no | Leg or quantity hints the strategy allows. |
| `at` | `string \| null` | no | When the signal was made (RFC 3339); too old is refused. |

```bash
curl -X POST "https://api.cirrus.trade/v2/deployments/<deployment_id>/signals" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "enter"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `signal_id` | `string` | yes |  |
| `delivered` | `boolean` | yes | Whether the engine took it; false: queued. |
| `units` | `object[]` | yes |  |
| `units[].account` | `string` | yes |  |
| `units[].accepted` | `boolean \| null` | no | null: not known yet (queued). |
| `units[].reason` | `string \| null` | no |  |

**Example: Enter now, queued until the engine is connected (202)**

```json
{
  "status": "success",
  "data": {
    "signal_id": "01M574VNDXZP199NRGS1X65WK4",
    "delivered": false,
    "units": [
      {
        "account": "P1",
        "accepted": null,
        "reason": "The strategy engine isn't connected yet: this was saved and will run once it is."
      },
      {
        "account": "P2",
        "accepted": null,
        "reason": "The strategy engine isn't connected yet: this was saved and will run once it is."
      }
    ]
  },
  "error": null
}
```

**Example: Signals need an Idempotency-Key (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_required",
    "message": "Idempotency-Key header is required (use a new UUID per request)",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies_signal` scope",
    "field": null
  }
}
```

**Example: No such deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: The deployment is stopped (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_finished",
    "message": "This deployment is stopped; deploy the strategy again to run it",
    "field": null
  }
}
```

**Example: This strategy enters on its own conditions (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "This strategy doesn't take entry signals: its entry source is not manual or API",
    "field": "action"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `idempotency_key_required` | The `Idempotency-Key` header is missing (use a new UUID per order request). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/stop-all

Scope: `strategies`

Stop every strategy

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `exit_now` | `boolean` | no | Also close every strategy trade now (positions of `auto.*` strategies only), mains first, hedges last. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/stop-all" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "exit_now": true
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `stopped` | `string[]` | yes | Deployments stopped. |
| `left_running` | `integer` | no | Running deployments left alone because they act on accounts the MCP connection's limits do not allow. Left out when 0, so every other caller's answer is unchanged. |
| `exit_now` | `boolean` | yes |  |
| `delivered` | `boolean` | yes | Whether every stop reached the engine. |

**Example: Kill switch: every deployment stopped, trades closing (200)**

```json
{
  "status": "success",
  "data": {
    "stopped": [
      "01M574VPR4P8457ZBFGSHJ4CRP",
      "01M574VP34646M936XME8K18QZ"
    ],
    "exit_now": true,
    "delivered": false
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

## Strategy gallery

Browse, copy, buy and publish strategy templates.

### POST /v2/strategies/{strategy_id}/gallery

Scope: `strategies`

List a strategy in the gallery

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `display_name` | `string` | yes | The name shown as the author (3–40 letters, digits, spaces, `.`, `_`, `-`; never your username or email). |
| `badge` | `string` | yes | Whose results to publish: your `paper` or your `live` runs of this version (shown as a badge). One of: `paper`, `live`, `unknown`. |
| `capital` | `number (double)` | yes | The capital (₹) you ran it with: every published percent is of this. Seen by you and the reviewers only. |
| `price_credits` | `integer (int64)` | no | Credits to copy it (0 or absent: free). Only while paid templates are on, and within the tenant's limits (`GET /v2/gallery/pricing`). |
| `version` | `integer (int32) \| null` | no | The saved version (default: the newest). |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/<strategy_id>/gallery" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Theta Lab",
    "badge": "paper",
    "capital": 200000,
    "price_credits": 25
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing` | `object` | yes | The listing as others see it once approved. |
| `listing.listing_id` | `string` | yes |  |
| `listing.name` | `string` | yes |  |
| `listing.description` | `string` | yes |  |
| `listing.tags` | `string[]` | yes |  |
| `listing.underlying` | `string` | yes |  |
| `listing.summary` | `string` | yes | The strategy in plain English. |
| `listing.author` | `string` | yes | The author's chosen display name. |
| `listing.badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `listing.label` | `string` | yes | "Past results on paper (simulated); not advice." |
| `listing.performance` | `object` | yes | Published performance as shown: percent only. |
| `listing.performance.return_pct` | `number (double)` | yes | Total return, % of the capital it ran with. |
| `listing.performance.max_drawdown_pct` | `number (double)` | yes | Largest fall from a peak, %. |
| `listing.performance.win_rate_pct` | `number (double)` | yes | Trades with a profit, %. |
| `listing.performance.trades` | `integer (int32)` | yes | Finished trades counted. |
| `listing.performance.since` | `string` | yes | First and last day counted (`YYYY-MM-DD`). |
| `listing.performance.until` | `string` | yes |  |
| `listing.price_credits` | `integer (int64)` | yes | What it costs you now (0: free); the same as `effective_price`. |
| `listing.effective_price` | `integer (int64)` | yes | What it costs you now: 0 while paid templates are off, during a "make all free" promotion, or when staff made it free. |
| `listing.is_free` | `boolean` | yes |  |
| `listing.price_reason` | `string` | yes | Why: `paid_disabled`, `promotion`, `admin_free`, `unpriced` or `priced`. |
| `listing.non_refundable` | `boolean` | yes | True when it costs something: purchases are non-refundable. |
| `listing.copies` | `integer (int64)` | yes |  |
| `listing.listed_at` | `string` | yes |  |
| `listing.can_copy` | `boolean` | yes | You may copy it now (free, bought, or yours). |
| `listing.mine` | `boolean` | yes | It is yours. |
| `listing.document` | `object \| null` | no | The version's document (detail only, when you may copy it). |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes |  |
| `status` | `string` | yes | `pending`, `approved`, `rejected`, `taken_down` or `withdrawn`. |
| `review_notes` | `string` | yes | The reviewers' note, when they left one. |
| `capital` | `number (double)` | yes | The capital you declared (₹, seen by you and reviewers only). |
| `purchases` | `integer (int64)` | yes |  |
| `asking_price_credits` | `integer (int64)` | yes | The price you set (kept while paid templates are off or staff made it free; it applies again when they are back). |
| `admin_free` | `boolean` | yes | Staff made it free. |

**Example: Version 1 sent for review as a paid template (201)**

```json
{
  "status": "success",
  "data": {
    "listing": {
      "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "description": "Weekly ATM straddle with a combined stop",
      "tags": [
        "weekly"
      ],
      "underlying": "NIFTY",
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "author": "Theta Lab",
      "badge": "paper",
      "label": "Past results on paper (simulated); not advice.",
      "performance": {
        "return_pct": 1.75,
        "max_drawdown_pct": 0.74,
        "win_rate_pct": 66.67,
        "trades": 21,
        "since": "2026-09-01",
        "until": "2026-09-21"
      },
      "price_credits": 25,
      "effective_price": 25,
      "is_free": false,
      "price_reason": "priced",
      "non_refundable": true,
      "copies": 0,
      "listed_at": "2026-10-26T14:14:50.318Z",
      "can_copy": true,
      "mine": true
    },
    "strategy_id": "01M5W90R03C4SMJZRNMFG0AM8Q",
    "version": 1,
    "status": "pending",
    "review_notes": "",
    "capital": 200000,
    "purchases": 0,
    "asking_price_credits": 25,
    "admin_free": false
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: Someone else's strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: One listing per strategy waits for review (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "listing_pending",
    "message": "A listing of this strategy is already waiting for review; withdraw it first.",
    "field": null
  }
}
```

**Example: Fewer than 20 finished trades (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "not_enough_cycles",
    "message": "A listing needs at least 20 finished trades of this version with the chosen badge; it has 0.",
    "field": null
  }
}
```

**Example: A display name that gives away the username (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "choose a name that isn't your username or email",
    "field": "display_name"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 409 | `no_saved_version` | A strategy is shared as a saved version, never its draft: save a version first. |
| 409 | `not_enough_cycles` | A gallery listing needs at least 20 finished trades of the version in the author's runs with the chosen badge. |
| 409 | `listing_pending` | A listing of this strategy is already waiting for review; withdraw it first. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/gallery

Access: app session only (API keys and partner tokens get 403)

Browse the gallery

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | `string` | no | Words to find in the name, description, tags, underlying or author (at most 64 characters). |
| `badge` | `string` | no | `paper` or `live`. |
| `price` | `string` | no | `free` or `paid`. |
| `sort` | `string` | no | `newest` (default), `return` or `popular`. |
| `limit` | `integer (int64)` | no | 1–50 (default 20). |
| `offset` | `integer (int64)` | no | Listings to skip (at most 1,000). |

```bash
curl "https://api.cirrus.trade/v2/gallery" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].listing_id` | `string` | yes |  |
| `[].name` | `string` | yes |  |
| `[].description` | `string` | yes |  |
| `[].tags` | `string[]` | yes |  |
| `[].underlying` | `string` | yes |  |
| `[].summary` | `string` | yes | The strategy in plain English. |
| `[].author` | `string` | yes | The author's chosen display name. |
| `[].badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `[].label` | `string` | yes | "Past results on paper (simulated); not advice." |
| `[].performance` | `object` | yes | Published performance as shown: percent only. |
| `[].performance.return_pct` | `number (double)` | yes | Total return, % of the capital it ran with. |
| `[].performance.max_drawdown_pct` | `number (double)` | yes | Largest fall from a peak, %. |
| `[].performance.win_rate_pct` | `number (double)` | yes | Trades with a profit, %. |
| `[].performance.trades` | `integer (int32)` | yes | Finished trades counted. |
| `[].performance.since` | `string` | yes | First and last day counted (`YYYY-MM-DD`). |
| `[].performance.until` | `string` | yes |  |
| `[].price_credits` | `integer (int64)` | yes | What it costs you now (0: free); the same as `effective_price`. |
| `[].effective_price` | `integer (int64)` | yes | What it costs you now: 0 while paid templates are off, during a "make all free" promotion, or when staff made it free. |
| `[].is_free` | `boolean` | yes |  |
| `[].price_reason` | `string` | yes | Why: `paid_disabled`, `promotion`, `admin_free`, `unpriced` or `priced`. |
| `[].non_refundable` | `boolean` | yes | True when it costs something: purchases are non-refundable. |
| `[].copies` | `integer (int64)` | yes |  |
| `[].listed_at` | `string` | yes |  |
| `[].can_copy` | `boolean` | yes | You may copy it now (free, bought, or yours). |
| `[].mine` | `boolean` | yes | It is yours. |
| `[].document` | `object \| null` | no | The version's document (detail only, when you may copy it). |

**Example: Approved listings matching a search (200)**

```json
{
  "status": "success",
  "data": [
    {
      "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "description": "Weekly ATM straddle with a combined stop",
      "tags": [
        "weekly"
      ],
      "underlying": "NIFTY",
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "author": "Theta Lab",
      "badge": "paper",
      "label": "Past results on paper (simulated); not advice.",
      "performance": {
        "return_pct": 1.75,
        "max_drawdown_pct": 0.74,
        "win_rate_pct": 66.67,
        "trades": 21,
        "since": "2026-09-01",
        "until": "2026-09-21"
      },
      "price_credits": 25,
      "effective_price": 25,
      "is_free": false,
      "price_reason": "priced",
      "non_refundable": true,
      "copies": 0,
      "listed_at": "2026-10-26T14:14:50.411Z",
      "can_copy": false,
      "mine": false
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Example: A page size above 50 (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "1 to 50",
    "field": "limit"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/gallery/mine

Access: app session only (API keys and partner tokens get 403)

Your gallery listings

```bash
curl "https://api.cirrus.trade/v2/gallery/mine" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].listing` | `object` | yes | The listing as others see it once approved. |
| `[].listing.listing_id` | `string` | yes |  |
| `[].listing.name` | `string` | yes |  |
| `[].listing.description` | `string` | yes |  |
| `[].listing.tags` | `string[]` | yes |  |
| `[].listing.underlying` | `string` | yes |  |
| `[].listing.summary` | `string` | yes | The strategy in plain English. |
| `[].listing.author` | `string` | yes | The author's chosen display name. |
| `[].listing.badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `[].listing.label` | `string` | yes | "Past results on paper (simulated); not advice." |
| `[].listing.performance` | `object` | yes | Published performance as shown: percent only. |
| `[].listing.performance.return_pct` | `number (double)` | yes | Total return, % of the capital it ran with. |
| `[].listing.performance.max_drawdown_pct` | `number (double)` | yes | Largest fall from a peak, %. |
| `[].listing.performance.win_rate_pct` | `number (double)` | yes | Trades with a profit, %. |
| `[].listing.performance.trades` | `integer (int32)` | yes | Finished trades counted. |
| `[].listing.performance.since` | `string` | yes | First and last day counted (`YYYY-MM-DD`). |
| `[].listing.performance.until` | `string` | yes |  |
| `[].listing.price_credits` | `integer (int64)` | yes | What it costs you now (0: free); the same as `effective_price`. |
| `[].listing.effective_price` | `integer (int64)` | yes | What it costs you now: 0 while paid templates are off, during a "make all free" promotion, or when staff made it free. |
| `[].listing.is_free` | `boolean` | yes |  |
| `[].listing.price_reason` | `string` | yes | Why: `paid_disabled`, `promotion`, `admin_free`, `unpriced` or `priced`. |
| `[].listing.non_refundable` | `boolean` | yes | True when it costs something: purchases are non-refundable. |
| `[].listing.copies` | `integer (int64)` | yes |  |
| `[].listing.listed_at` | `string` | yes |  |
| `[].listing.can_copy` | `boolean` | yes | You may copy it now (free, bought, or yours). |
| `[].listing.mine` | `boolean` | yes | It is yours. |
| `[].listing.document` | `object \| null` | no | The version's document (detail only, when you may copy it). |
| `[].strategy_id` | `string` | yes |  |
| `[].version` | `integer (int32)` | yes |  |
| `[].status` | `string` | yes | `pending`, `approved`, `rejected`, `taken_down` or `withdrawn`. |
| `[].review_notes` | `string` | yes | The reviewers' note, when they left one. |
| `[].capital` | `number (double)` | yes | The capital you declared (₹, seen by you and reviewers only). |
| `[].purchases` | `integer (int64)` | yes |  |
| `[].asking_price_credits` | `integer (int64)` | yes | The price you set (kept while paid templates are off or staff made it free; it applies again when they are back). |
| `[].admin_free` | `boolean` | yes | Staff made it free. |

**Example: Your listing waiting for review (200)**

```json
{
  "status": "success",
  "data": [
    {
      "listing": {
        "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
        "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
        "description": "Weekly ATM straddle with a combined stop",
        "tags": [
          "weekly"
        ],
        "underlying": "NIFTY",
        "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
        "author": "Theta Lab",
        "badge": "paper",
        "label": "Past results on paper (simulated); not advice.",
        "performance": {
          "return_pct": 1.75,
          "max_drawdown_pct": 0.74,
          "win_rate_pct": 66.67,
          "trades": 21,
          "since": "2026-09-01",
          "until": "2026-09-21"
        },
        "price_credits": 25,
        "effective_price": 25,
        "is_free": false,
        "price_reason": "priced",
        "non_refundable": true,
        "copies": 0,
        "listed_at": "2026-10-26T14:14:50.318Z",
        "can_copy": true,
        "mine": true
      },
      "strategy_id": "01M5W90R03C4SMJZRNMFG0AM8Q",
      "version": 1,
      "status": "pending",
      "review_notes": "",
      "capital": 200000,
      "purchases": 0,
      "asking_price_credits": 25,
      "admin_free": false
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/gallery/{listing_id}

Access: app session only (API keys and partner tokens get 403)

Get a gallery listing

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes | Listing id. |

```bash
curl "https://api.cirrus.trade/v2/gallery/<listing_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `underlying` | `string` | yes |  |
| `summary` | `string` | yes | The strategy in plain English. |
| `author` | `string` | yes | The author's chosen display name. |
| `badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `label` | `string` | yes | "Past results on paper (simulated); not advice." |
| `performance` | `object` | yes | Published performance as shown: percent only. |
| `performance.return_pct` | `number (double)` | yes | Total return, % of the capital it ran with. |
| `performance.max_drawdown_pct` | `number (double)` | yes | Largest fall from a peak, %. |
| `performance.win_rate_pct` | `number (double)` | yes | Trades with a profit, %. |
| `performance.trades` | `integer (int32)` | yes | Finished trades counted. |
| `performance.since` | `string` | yes | First and last day counted (`YYYY-MM-DD`). |
| `performance.until` | `string` | yes |  |
| `price_credits` | `integer (int64)` | yes | What it costs you now (0: free); the same as `effective_price`. |
| `effective_price` | `integer (int64)` | yes | What it costs you now: 0 while paid templates are off, during a "make all free" promotion, or when staff made it free. |
| `is_free` | `boolean` | yes |  |
| `price_reason` | `string` | yes | Why: `paid_disabled`, `promotion`, `admin_free`, `unpriced` or `priced`. |
| `non_refundable` | `boolean` | yes | True when it costs something: purchases are non-refundable. |
| `copies` | `integer (int64)` | yes |  |
| `listed_at` | `string` | yes |  |
| `can_copy` | `boolean` | yes | You may copy it now (free, bought, or yours). |
| `mine` | `boolean` | yes | It is yours. |
| `document` | `object \| null` | no | The version's document (detail only, when you may copy it). |

**Example: A paid listing before buying it (200)**

```json
{
  "status": "success",
  "data": {
    "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
    "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
    "description": "Weekly ATM straddle with a combined stop",
    "tags": [
      "weekly"
    ],
    "underlying": "NIFTY",
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
    "author": "Theta Lab",
    "badge": "paper",
    "label": "Past results on paper (simulated); not advice.",
    "performance": {
      "return_pct": 1.75,
      "max_drawdown_pct": 0.74,
      "win_rate_pct": 66.67,
      "trades": 21,
      "since": "2026-09-01",
      "until": "2026-09-21"
    },
    "price_credits": 25,
    "effective_price": 25,
    "is_free": false,
    "price_reason": "priced",
    "non_refundable": true,
    "copies": 0,
    "listed_at": "2026-10-26T14:14:50.411Z",
    "can_copy": false,
    "mine": false
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Example: Not approved yet (or withdrawn) (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "listing_not_found",
    "message": "No such listing: it was withdrawn, taken down, or never existed.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `listing_not_found` | No such gallery listing for the caller: withdrawn, taken down, not approved yet (and not theirs), or it never existed. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### DELETE /v2/gallery/{listing_id}

Access: app session only (API keys and partner tokens get 403)

Withdraw your listing

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes | Listing id. |

```bash
curl -X DELETE "https://api.cirrus.trade/v2/gallery/<listing_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing` | `object` | yes | The listing as others see it once approved. |
| `listing.listing_id` | `string` | yes |  |
| `listing.name` | `string` | yes |  |
| `listing.description` | `string` | yes |  |
| `listing.tags` | `string[]` | yes |  |
| `listing.underlying` | `string` | yes |  |
| `listing.summary` | `string` | yes | The strategy in plain English. |
| `listing.author` | `string` | yes | The author's chosen display name. |
| `listing.badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `listing.label` | `string` | yes | "Past results on paper (simulated); not advice." |
| `listing.performance` | `object` | yes | Published performance as shown: percent only. |
| `listing.performance.return_pct` | `number (double)` | yes | Total return, % of the capital it ran with. |
| `listing.performance.max_drawdown_pct` | `number (double)` | yes | Largest fall from a peak, %. |
| `listing.performance.win_rate_pct` | `number (double)` | yes | Trades with a profit, %. |
| `listing.performance.trades` | `integer (int32)` | yes | Finished trades counted. |
| `listing.performance.since` | `string` | yes | First and last day counted (`YYYY-MM-DD`). |
| `listing.performance.until` | `string` | yes |  |
| `listing.price_credits` | `integer (int64)` | yes | What it costs you now (0: free); the same as `effective_price`. |
| `listing.effective_price` | `integer (int64)` | yes | What it costs you now: 0 while paid templates are off, during a "make all free" promotion, or when staff made it free. |
| `listing.is_free` | `boolean` | yes |  |
| `listing.price_reason` | `string` | yes | Why: `paid_disabled`, `promotion`, `admin_free`, `unpriced` or `priced`. |
| `listing.non_refundable` | `boolean` | yes | True when it costs something: purchases are non-refundable. |
| `listing.copies` | `integer (int64)` | yes |  |
| `listing.listed_at` | `string` | yes |  |
| `listing.can_copy` | `boolean` | yes | You may copy it now (free, bought, or yours). |
| `listing.mine` | `boolean` | yes | It is yours. |
| `listing.document` | `object \| null` | no | The version's document (detail only, when you may copy it). |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes |  |
| `status` | `string` | yes | `pending`, `approved`, `rejected`, `taken_down` or `withdrawn`. |
| `review_notes` | `string` | yes | The reviewers' note, when they left one. |
| `capital` | `number (double)` | yes | The capital you declared (₹, seen by you and reviewers only). |
| `purchases` | `integer (int64)` | yes |  |
| `asking_price_credits` | `integer (int64)` | yes | The price you set (kept while paid templates are off or staff made it free; it applies again when they are back). |
| `admin_free` | `boolean` | yes | Staff made it free. |

**Example: Taken out of the gallery (200)**

```json
{
  "status": "success",
  "data": {
    "listing": {
      "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "description": "Weekly ATM straddle with a combined stop",
      "tags": [
        "weekly"
      ],
      "underlying": "NIFTY",
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "author": "Theta Lab",
      "badge": "paper",
      "label": "Past results on paper (simulated); not advice.",
      "performance": {
        "return_pct": 1.75,
        "max_drawdown_pct": 0.74,
        "win_rate_pct": 66.67,
        "trades": 21,
        "since": "2026-09-01",
        "until": "2026-09-21"
      },
      "price_credits": 25,
      "effective_price": 25,
      "is_free": false,
      "price_reason": "priced",
      "non_refundable": true,
      "copies": 2,
      "listed_at": "2026-10-26T14:14:50.411Z",
      "can_copy": true,
      "mine": true
    },
    "strategy_id": "01M5W90R03C4SMJZRNMFG0AM8Q",
    "version": 1,
    "status": "withdrawn",
    "review_notes": "Checked: stop-loss present, honest name",
    "capital": 200000,
    "purchases": 1,
    "asking_price_credits": 25,
    "admin_free": false
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Example: Someone else's listing (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "listing_not_found",
    "message": "No such listing: it was withdrawn, taken down, or never existed.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `listing_not_found` | No such gallery listing for the caller: withdrawn, taken down, not approved yet (and not theirs), or it never existed. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/gallery/{listing_id}/copy

Access: app session only (API keys and partner tokens get 403)

Copy a listing into your strategies

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes | Listing id. |

```bash
curl -X POST "https://api.cirrus.trade/v2/gallery/<listing_id>/copy" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy` | `object` | yes | A strategy with its draft. |
| `strategy.id` | `string` | yes |  |
| `strategy.name` | `string` | yes |  |
| `strategy.description` | `string` | yes |  |
| `strategy.tags` | `string[]` | yes |  |
| `strategy.display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `strategy.latest_version` | `integer (int32)` | yes |  |
| `strategy.revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `strategy.visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `strategy.forked_from` | `object \| null` | no |  |
| `strategy.forked_from.strategy_id` | `string` | yes |  |
| `strategy.forked_from.version` | `integer (int32)` | yes |  |
| `strategy.archived` | `boolean` | yes |  |
| `strategy.deployments` | `object` | yes | Deployments of a strategy by status. |
| `strategy.deployments.active` | `integer` | yes |  |
| `strategy.deployments.paused` | `integer` | yes |  |
| `strategy.deployments.error` | `integer` | yes |  |
| `strategy.deployments.stopped` | `integer` | yes |  |
| `strategy.summary` | `string` | yes | The draft in plain English. |
| `strategy.deployable` | `boolean` | yes | The draft has no blockers. |
| `strategy.blockers` | `object[]` | yes |  |
| `strategy.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `strategy.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `strategy.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `strategy.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `strategy.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `strategy.warnings` | `object[]` | yes |  |
| `strategy.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `strategy.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `strategy.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `strategy.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `strategy.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `strategy.schema_version` | `integer (int32)` | yes |  |
| `strategy.draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `strategy.created_at` | `string` | yes |  |
| `strategy.updated_at` | `string` | yes |  |
| `strategy.update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `strategy.update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `strategy.update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `strategy.update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |
| `validation` | `object` | yes | What checking a document found. |
| `validation.ok` | `boolean` | yes | No errors: the document can be saved. |
| `validation.deployable` | `boolean` | yes | No errors and no blockers: a version of it can be deployed. |
| `validation.schema_version` | `integer (int32)` | yes | Format of the checked document (after migration). |
| `validation.migrated_from` | `integer (int32) \| null` | no | The format it was written in, when older (it was migrated). |
| `validation.errors` | `object[]` | yes | Refused: fix these to save. |
| `validation.errors[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.errors[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.errors[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.errors[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.errors[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.blockers` | `object[]` | yes | Saved as a draft, not deployable until resolved. |
| `validation.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.warnings` | `object[]` | yes |  |
| `validation.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.needs_ack` | `string[]` | yes | Warning codes to acknowledge at deploy (`unlimited_loss`). |
| `validation.summary` | `string \| null` | no | The strategy in plain English. |
| `validation.compiled_hash` | `string \| null` | no | Hash of the compiled spec (same for any way of writing the same strategy). |
| `validation.components` | `string[]` | yes | `name@major` of every component used. |
| `validation.warm_up` | `object \| null` | no | Indicator warm-up (from the engine; null until it is connected). |

**Example: A bought template copied into your drafts (201)**

```json
{
  "status": "success",
  "data": {
    "strategy": {
      "id": "01M5W90RG5GJE0A04VDBQ3MKPN",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "description": "Weekly ATM straddle with a combined stop",
      "tags": [
        "weekly"
      ],
      "display_status": "draft",
      "latest_version": 0,
      "revision": 1,
      "visibility": "private",
      "forked_from": {
        "strategy_id": "01M5W90R03C4SMJZRNMFG0AM8Q",
        "version": 1
      },
      "archived": false,
      "deployments": {
        "active": 0,
        "paused": 0,
        "error": 0,
        "stopped": 0
      },
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "schema_version": 1,
      "draft": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
          "description": "Weekly ATM straddle with a combined stop",
          "tags": [
            "weekly"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      },
      "created_at": "2026-10-26T14:14:50.629Z",
      "updated_at": "2026-10-26T14:14:50.629Z"
    },
    "validation": {
      "ok": true,
      "deployable": true,
      "schema_version": 1,
      "migrated_from": null,
      "errors": [],
      "blockers": [],
      "warnings": [],
      "needs_ack": [],
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "compiled_hash": "sha256:44ffd35c2d50344cd553dba925e707521b55be5023eae9a4a165acdc1190a9cf",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "warm_up": null
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Example: Withdrawn listings can't be copied (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "listing_not_found",
    "message": "No such listing: it was withdrawn, taken down, or never existed.",
    "field": null
  }
}
```

**Example: Buy a paid template first (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "purchase_required",
    "message": "This template costs 25 credits; buying it makes your copy. Purchases are non-refundable.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `listing_not_found` | No such gallery listing for the caller: withdrawn, taken down, not approved yet (and not theirs), or it never existed. |
| 409 | `purchase_required` | A paid gallery template must be bought before it is copied. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/gallery/{listing_id}/purchase

Access: app session only (API keys and partner tokens get 403)

Buy a paid listing

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes | Listing id. |

```bash
curl -X POST "https://api.cirrus.trade/v2/gallery/<listing_id>/purchase" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `listing_id` | `string` | yes |  |
| `charged` | `integer (int64)` | yes | Credits it cost (0 when you owned it already). |
| `balance` | `integer (int64)` | yes | Your credits now. |
| `receipt` | `object \| null` | no | The receipt (purchases are non-refundable). |
| `receipt.receipt_id` | `string` | yes | The charge id, also on the wallet's transaction. |
| `receipt.listing_id` | `string` | yes |  |
| `receipt.name` | `string` | yes |  |
| `receipt.author` | `string` | yes | The author's display name. |
| `receipt.charged_credits` | `integer (int64)` | yes | Credits paid. |
| `receipt.strategy_id` | `string \| null` | no | Your copy made with the purchase. |
| `receipt.bought_at` | `string` | yes |  |
| `receipt.non_refundable` | `boolean` | yes | Always true: purchases are non-refundable. |
| `copy` | `object \| null` | no | Your copy, made with the purchase (absent when you owned it already). |
| `copy.strategy` | `object` | yes | A strategy with its draft. |
| `copy.strategy.id` | `string` | yes |  |
| `copy.strategy.name` | `string` | yes |  |
| `copy.strategy.description` | `string` | yes |  |
| `copy.strategy.tags` | `string[]` | yes |  |
| `copy.strategy.display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `copy.strategy.latest_version` | `integer (int32)` | yes |  |
| `copy.strategy.revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `copy.strategy.visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `copy.strategy.forked_from` | `object \| null` | no |  |
| `copy.strategy.forked_from.strategy_id` | `string` | yes |  |
| `copy.strategy.forked_from.version` | `integer (int32)` | yes |  |
| `copy.strategy.archived` | `boolean` | yes |  |
| `copy.strategy.deployments` | `object` | yes | Deployments of a strategy by status. |
| `copy.strategy.deployments.active` | `integer` | yes |  |
| `copy.strategy.deployments.paused` | `integer` | yes |  |
| `copy.strategy.deployments.error` | `integer` | yes |  |
| `copy.strategy.deployments.stopped` | `integer` | yes |  |
| `copy.strategy.summary` | `string` | yes | The draft in plain English. |
| `copy.strategy.deployable` | `boolean` | yes | The draft has no blockers. |
| `copy.strategy.blockers` | `object[]` | yes |  |
| `copy.strategy.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `copy.strategy.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `copy.strategy.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `copy.strategy.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `copy.strategy.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `copy.strategy.warnings` | `object[]` | yes |  |
| `copy.strategy.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `copy.strategy.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `copy.strategy.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `copy.strategy.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `copy.strategy.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `copy.strategy.schema_version` | `integer (int32)` | yes |  |
| `copy.strategy.draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `copy.strategy.created_at` | `string` | yes |  |
| `copy.strategy.updated_at` | `string` | yes |  |
| `copy.strategy.update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `copy.strategy.update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `copy.strategy.update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `copy.strategy.update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |
| `copy.validation` | `object` | yes | What checking a document found. |
| `copy.validation.ok` | `boolean` | yes | No errors: the document can be saved. |
| `copy.validation.deployable` | `boolean` | yes | No errors and no blockers: a version of it can be deployed. |
| `copy.validation.schema_version` | `integer (int32)` | yes | Format of the checked document (after migration). |
| `copy.validation.migrated_from` | `integer (int32) \| null` | no | The format it was written in, when older (it was migrated). |
| `copy.validation.errors` | `object[]` | yes | Refused: fix these to save. |
| `copy.validation.errors[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `copy.validation.errors[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `copy.validation.errors[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `copy.validation.errors[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `copy.validation.errors[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `copy.validation.blockers` | `object[]` | yes | Saved as a draft, not deployable until resolved. |
| `copy.validation.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `copy.validation.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `copy.validation.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `copy.validation.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `copy.validation.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `copy.validation.warnings` | `object[]` | yes |  |
| `copy.validation.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `copy.validation.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `copy.validation.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `copy.validation.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `copy.validation.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `copy.validation.needs_ack` | `string[]` | yes | Warning codes to acknowledge at deploy (`unlimited_loss`). |
| `copy.validation.summary` | `string \| null` | no | The strategy in plain English. |
| `copy.validation.compiled_hash` | `string \| null` | no | Hash of the compiled spec (same for any way of writing the same strategy). |
| `copy.validation.components` | `string[]` | yes | `name@major` of every component used. |
| `copy.validation.warm_up` | `object \| null` | no | Indicator warm-up (from the engine; null until it is connected). |

**Example: Bought for 25 credits and copied (non-refundable) (200)**

```json
{
  "status": "success",
  "data": {
    "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
    "charged": 25,
    "balance": 75,
    "receipt": {
      "receipt_id": "gal_f75aea3e834242cd8c671c041702e4a6",
      "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "author": "Theta Lab",
      "charged_credits": 25,
      "strategy_id": "62M3WVQ8PSDFR8MSDHPBFG6E4D",
      "bought_at": "2026-10-26T14:14:50.557Z",
      "non_refundable": true
    },
    "copy": {
      "strategy": {
        "id": "62M3WVQ8PSDFR8MSDHPBFG6E4D",
        "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
        "description": "Weekly ATM straddle with a combined stop",
        "tags": [
          "weekly"
        ],
        "display_status": "draft",
        "latest_version": 0,
        "revision": 1,
        "visibility": "private",
        "forked_from": {
          "strategy_id": "01M5W90R03C4SMJZRNMFG0AM8Q",
          "version": 1
        },
        "archived": false,
        "deployments": {
          "active": 0,
          "paused": 0,
          "error": 0,
          "stopped": 0
        },
        "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
        "deployable": true,
        "blockers": [],
        "warnings": [],
        "schema_version": 1,
        "draft": {
          "$schema": "/v2/strategies/schema/1.json",
          "schema_version": 1,
          "kind": "cirrus.strategy",
          "metadata": {
            "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
            "description": "Weekly ATM straddle with a combined stop",
            "tags": [
              "weekly"
            ]
          },
          "spec": {
            "underlying": {
              "name": "NIFTY",
              "exchange": "NFO",
              "reference": "future"
            },
            "watch": {},
            "positional": false,
            "legs": {
              "selector": "chain@1",
              "items": [
                {
                  "id": "sc",
                  "instrument": "option",
                  "side": "sell",
                  "option_type": "call",
                  "expiry": {
                    "kind": "weekly",
                    "n": 0
                  },
                  "strike": {
                    "by": "atm",
                    "offset": 0
                  },
                  "lots": 1,
                  "hedge_for": null,
                  "risk": null,
                  "liquidity": {
                    "max_spread_pct": 3,
                    "min_oi": 0,
                    "min_premium": 1
                  }
                },
                {
                  "id": "sp",
                  "instrument": "option",
                  "side": "sell",
                  "option_type": "put",
                  "expiry": {
                    "kind": "weekly",
                    "n": 0
                  },
                  "strike": {
                    "by": "atm",
                    "offset": 0
                  },
                  "lots": 1,
                  "hedge_for": null,
                  "risk": null,
                  "liquidity": {
                    "max_spread_pct": 3,
                    "min_oi": 0,
                    "min_premium": 1
                  }
                }
              ]
            },
            "entry": {
              "source": {
                "type": "conditions@1",
                "when": null,
                "on": "close",
                "hold_s": 0
              },
              "window": {
                "start": "09:30",
                "end": "13:30"
              },
              "skip_first_min": 0,
              "skip_last_min": 0,
              "days": [
                "mon",
                "tue",
                "wed",
                "thu",
                "fri"
              ],
              "expiry_day": "normal",
              "active_from": null,
              "active_until": null
            },
            "exit": {
              "sources": [],
              "square_off": "15:15",
              "expiry_day_exit": null,
              "max_hold_min": null
            },
            "sizing": {
              "type": "fixed_lots@1",
              "max_lots": 10
            },
            "execution": {
              "type": "protected_limit@1",
              "order": "auto",
              "hedge_wait_ms": 5000,
              "stagger_ms": 0,
              "buffer_pct": 0.5,
              "chase": {
                "every_ms": 1000,
                "max_slippage_pct": 1,
                "max_attempts": 10
              },
              "on_incomplete": "safest",
              "retry_next_signal": null
            },
            "risk": {
              "per_leg": {
                "type": "per_leg@1",
                "stop_loss": null,
                "target": null,
                "trail": null,
                "on_leg_stop": "nothing",
                "at_broker": false
              },
              "combined": {
                "type": "combined@1",
                "scope": "account",
                "basis": "rupees",
                "stop_loss": {
                  "rupees": 3000
                },
                "target": null,
                "breakeven": null,
                "trail": null,
                "lock_ladder": [],
                "deployment": null
              },
              "limits": {
                "type": "limits@1",
                "max_loss_per_day": null,
                "max_profit_per_day": null,
                "max_loss_per_week": null,
                "max_cycles_per_day": 3,
                "max_orders_per_day": 50,
                "exit_on_limit": true,
                "on_breaker": "nothing"
              }
            },
            "reentry": {
              "on": [],
              "max": 0,
              "delay_s": 300,
              "require_fresh_signal": true,
              "same_direction_only": true,
              "until": null
            },
            "adjustments": [],
            "notify": [],
            "costs": {
              "model": "auto",
              "slippage_pct": null
            }
          }
        },
        "created_at": "2026-10-26T14:14:50.566Z",
        "updated_at": "2026-10-26T14:14:50.566Z"
      },
      "validation": {
        "ok": true,
        "deployable": true,
        "schema_version": 1,
        "migrated_from": null,
        "errors": [],
        "blockers": [],
        "warnings": [],
        "needs_ack": [],
        "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
        "compiled_hash": "sha256:44ffd35c2d50344cd553dba925e707521b55be5023eae9a4a165acdc1190a9cf",
        "components": [
          "chain@1",
          "combined@1",
          "conditions@1",
          "fixed_lots@1",
          "limits@1",
          "per_leg@1",
          "protected_limit@1"
        ],
        "warm_up": null
      }
    }
  },
  "error": null
}
```

**Example: A purchase needs an Idempotency-Key (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_required",
    "message": "Idempotency-Key header is required (use a new UUID per request)",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Example: No such listing (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "listing_not_found",
    "message": "No such listing: it was withdrawn, taken down, or never existed.",
    "field": null
  }
}
```

**Example: Not enough credits; nothing charged (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "insufficient_credits",
    "message": "This template costs 25 credits and you have 0. Nothing was charged.",
    "field": null
  }
}
```

**Example: Your own listing is copied free, not bought (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "this listing is yours; copy it for free",
    "field": "listing_id"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `idempotency_key_required` | The `Idempotency-Key` header is missing (use a new UUID per order request). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `listing_not_found` | No such gallery listing for the caller: withdrawn, taken down, not approved yet (and not theirs), or it never existed. |
| 409 | `insufficient_credits` | Not enough platform credits for this purchase; nothing was charged. |
| 409 | `purchase_failed` | A gallery purchase did not complete and nothing was charged; retry with a new Idempotency-Key. |
| 409 | `purchase_in_progress` | A gallery purchase with this Idempotency-Key is still being made; retry with the same key. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `credits_unavailable` | Platform credits can't be reached right now; nothing was charged. Retry. |

### GET /v2/credits

Access: app session only (API keys and partner tokens get 403)

Your platform credits

```bash
curl "https://api.cirrus.trade/v2/credits" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `balance` | `integer (int64)` | yes |  |
| `entries` | `object[]` | yes | The latest changes, newest first (at most 50). |
| `entries[].at` | `string` | yes |  |
| `entries[].delta` | `integer (int64)` | yes | Credits added (positive) or spent (negative). |
| `entries[].balance_after` | `integer (int64)` | yes |  |
| `entries[].kind` | `string` | yes | One of: `grant`, `purchase`, `sale`, `unknown`. |
| `entries[].listing_id` | `string \| null` | no |  |
| `entries[].note` | `string` | yes |  |

**Example: Credits after buying a template (200)**

```json
{
  "status": "success",
  "data": {
    "balance": 75,
    "entries": [
      {
        "at": "2026-10-26T14:14:50.558Z",
        "delta": -25,
        "balance_after": 75,
        "kind": "purchase",
        "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
        "note": "Gallery template \"Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH\" by Theta Lab (non-refundable)"
      },
      {
        "at": "2026-10-26T14:14:50.530Z",
        "delta": 100,
        "balance_after": 100,
        "kind": "grant",
        "listing_id": null,
        "note": "Credits added by staff"
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `credits_unavailable` | Platform credits can't be reached right now; nothing was charged. Retry. |

### GET /v2/strategies/{strategy_id}/gallery/eligibility

Scope: `read`

Whether a version may be listed

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `version` | `integer (int32)` | no | The saved version (default: the newest). |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>/gallery/eligibility" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `version` | `integer (int32)` | yes | The saved version checked. |
| `required_cycles` | `integer (int32)` | yes | Finished trades a listing needs. |
| `badges` | `object[]` | yes | `paper` and `live`. |
| `badges[].badge` | `string` | yes | Whose results the performance is (decision 2), shown as a badge. One of: `paper`, `live`, `unknown`. |
| `badges[].finished_cycles` | `integer (int32)` | yes | Finished trades of this version in your own runs of that mode. |
| `badges[].eligible` | `boolean` | yes | It may be listed with this badge now (as far as trades go). |
| `badges[].reason` | `string \| null` | no | Why not, in plain English; null when eligible. |

**Example: Paper ready, live not yet (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M5W6711PRZGP3VMPA499ES8J",
    "version": 1,
    "required_cycles": 20,
    "badges": [
      {
        "badge": "paper",
        "finished_cycles": 20,
        "eligible": true,
        "reason": null
      },
      {
        "badge": "live",
        "finished_cycles": 0,
        "eligible": false,
        "reason": "A listing needs at least 20 finished trades of this version with the chosen badge; it has 0."
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such strategy of yours (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: Only a draft (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "no_saved_version",
    "message": "Save a version first: a listing is a saved version, never the draft.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 409 | `no_saved_version` | A strategy is shared as a saved version, never its draft: save a version first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v2/gallery/pricing

Access: app session only (API keys and partner tokens get 403)

Paid-template settings

```bash
curl "https://api.cirrus.trade/v2/gallery/pricing" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `paid_enabled` | `boolean` | yes | Paid templates are on (`OMS_AUTO_GALLERY_PAID`); off: every listing is free and authors can't set a price. |
| `all_free` | `boolean` | yes | Staff's "make all free" promotion is on. |
| `min_price_credits` | `integer (int64)` | yes | The prices an author may set (credits). |
| `max_price_credits` | `integer (int64)` | yes |  |
| `author_share_pct` | `integer (int32)` | yes | The author's share of each sale, percent (the rest is the platform's). |
| `non_refundable` | `boolean` | yes | Always true: purchases are non-refundable. |
| `purchases_available` | `boolean` | yes | Credits can be charged right now (paid on and configured). |

**Example: Paid templates on, 70% to the author (200)**

```json
{
  "status": "success",
  "data": {
    "paid_enabled": true,
    "all_free": false,
    "min_price_credits": 10,
    "max_price_credits": 10000,
    "author_share_pct": 70,
    "non_refundable": true,
    "purchases_available": true
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/gallery/purchases

Access: app session only (API keys and partner tokens get 403)

Your purchase receipts

```bash
curl "https://api.cirrus.trade/v2/gallery/purchases" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].receipt_id` | `string` | yes | The charge id, also on the wallet's transaction. |
| `[].listing_id` | `string` | yes |  |
| `[].name` | `string` | yes |  |
| `[].author` | `string` | yes | The author's display name. |
| `[].charged_credits` | `integer (int64)` | yes | Credits paid. |
| `[].strategy_id` | `string \| null` | no | Your copy made with the purchase. |
| `[].bought_at` | `string` | yes |  |
| `[].non_refundable` | `boolean` | yes | Always true: purchases are non-refundable. |

**Example: Your receipts (200)**

```json
{
  "status": "success",
  "data": [
    {
      "receipt_id": "gal_f75aea3e834242cd8c671c041702e4a6",
      "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
      "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
      "author": "Theta Lab",
      "charged_credits": 25,
      "strategy_id": "62M3WVQ8PSDFR8MSDHPBFG6E4D",
      "bought_at": "2026-10-26T14:14:50.557Z",
      "non_refundable": true
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/gallery/earnings

Access: app session only (API keys and partner tokens get 403)

Your earnings as an author

```bash
curl "https://api.cirrus.trade/v2/gallery/earnings" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `author_share_pct` | `integer (int32)` | yes | Your share of each sale now, percent. |
| `sales` | `integer (int64)` | yes |  |
| `credits_earned` | `integer (int64)` | yes |  |
| `templates` | `object[]` | yes | Per template, most earned first. |
| `templates[].listing_id` | `string` | yes |  |
| `templates[].name` | `string` | yes |  |
| `templates[].sales` | `integer (int64)` | yes |  |
| `templates[].credits_earned` | `integer (int64)` | yes | Your share, in credits. |

**Example: One sale: 17 of 25 credits (200)**

```json
{
  "status": "success",
  "data": {
    "author_share_pct": 70,
    "sales": 1,
    "credits_earned": 17,
    "templates": [
      {
        "listing_id": "01M5W90R6E1MM606W1ZM1WW4S1",
        "name": "Weekly straddle 01M5W90QZWNR5ZDD6W1Z44VGCH",
        "sales": 1,
        "credits_earned": 17
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "Only available when signed in to the app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

## Strategy builder

What a strategy can use, ready-made templates, and checks before you save or deploy.

### POST /v2/strategies/validate

Scope: `strategies`

Validate a strategy document

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | `any` | yes | The portable document: an object, or its JSON or YAML text. |
| `definition` | `object \| null` | no | Instead of `document`: the definition (`spec`) alone. |
| `name` | `string \| null` | no | With `definition`. |
| `description` | `string \| null` | no | With `definition`. |
| `tags` | `string[] \| null` | no | With `definition`. |
| `revision` | `integer (int64) \| null` | no | Ignored (accepted as for update). |
| `mode` | `string \| null` | no | Deploy mode to check against (default `paper`). One of: `paper`, `live`. |
| `accounts` | `object[] \| null` | no | Accounts to check against (as for deploy); none: account rules are left for the deploy. |
| `accounts[].account` | `string` | yes |  |
| `accounts[].multiplier` | `number (double) \| null` | no | 0.1 to 10 in steps of 0.1 (default 1). |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/validate" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "Naked straddle",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO"
        },
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            }
          ]
        },
        "entry": {
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "source": {
            "type": "conditions@1",
            "when": "rsi(14, '\''5m'\'') > 60"
          }
        },
        "risk": {},
        "adjustments": [
          {
            "type": "delta_hedge@1",
            "when": "abs(net.delta) > 0.5",
            "with": "future",
            "target": 0.1,
            "min_interval_s": 300,
            "max_per_cycle": 3
          }
        ]
      }
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ok` | `boolean` | yes | No errors: the document can be saved. |
| `deployable` | `boolean` | yes | No errors and no blockers: a version of it can be deployed. |
| `schema_version` | `integer (int32)` | yes | Format of the checked document (after migration). |
| `migrated_from` | `integer (int32) \| null` | no | The format it was written in, when older (it was migrated). |
| `errors` | `object[]` | yes | Refused: fix these to save. |
| `errors[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `errors[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `errors[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `errors[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `errors[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `blockers` | `object[]` | yes | Saved as a draft, not deployable until resolved. |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `needs_ack` | `string[]` | yes | Warning codes to acknowledge at deploy (`unlimited_loss`). |
| `summary` | `string \| null` | no | The strategy in plain English. |
| `compiled_hash` | `string \| null` | no | Hash of the compiled spec (same for any way of writing the same strategy). |
| `components` | `string[]` | yes | `name@major` of every component used. |
| `warm_up` | `object \| null` | no | Indicator warm-up (from the engine; null until it is connected). |

**Example: Saved but not deployable; a warning to acknowledge (200)**

```json
{
  "status": "success",
  "data": {
    "ok": true,
    "deployable": false,
    "schema_version": 1,
    "migrated_from": null,
    "errors": [],
    "blockers": [
      {
        "field": "spec.entry.source.when",
        "label": "When · Entry",
        "message": "`rsi(14, '5m') > 60` uses rsi, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
        "code": "not_available_yet",
        "needs_ack": false
      },
      {
        "field": "spec.adjustments[0].when",
        "label": "Protect · Adjustment",
        "message": "`abs(net.delta) > 0.5` uses net.delta, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
        "code": "not_available_yet",
        "needs_ack": false
      }
    ],
    "warnings": [
      {
        "field": "spec.risk.combined.stop_loss",
        "label": "Protect · Combined protection",
        "message": "Combined stop is missing for a short option: your loss would be unlimited.",
        "code": "unlimited_loss",
        "needs_ack": true
      }
    ],
    "needs_ack": [
      "unlimited_loss"
    ],
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter when rsi(14, '5m') > 60 (checked at each bar close), between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; no per-leg stops; at most 3 trades a day. No re-entries. Adjust: hedge the net delta when abs(net.delta) > 0.5.",
    "compiled_hash": "sha256:650ddfdbbe519dbc38b434980bce7d9e9629ef1c21c4dd30363112871eb0c0ef",
    "components": [
      "chain@1",
      "combined@1",
      "conditions@1",
      "delta_hedge@1",
      "fixed_lots@1",
      "limits@1",
      "per_leg@1",
      "protected_limit@1"
    ],
    "warm_up": null
  },
  "error": null
}
```

**Example: A file made for a newer format (200)**

```json
{
  "status": "success",
  "data": {
    "ok": false,
    "deployable": false,
    "schema_version": 1,
    "migrated_from": null,
    "errors": [
      {
        "field": "schema_version",
        "label": "",
        "message": "This file was made for a newer version (format 3); we support up to 1. Update the app or ask the author for an older export.",
        "code": "newer_format",
        "needs_ack": false
      }
    ],
    "blockers": [],
    "warnings": [],
    "needs_ack": [],
    "summary": null,
    "compiled_hash": null,
    "components": [],
    "warm_up": null
  },
  "error": null
}
```

**Example: A clean document: summary and hash (200)**

```json
{
  "status": "success",
  "data": {
    "ok": true,
    "deployable": true,
    "schema_version": 1,
    "migrated_from": null,
    "errors": [],
    "blockers": [],
    "warnings": [],
    "needs_ack": [],
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "compiled_hash": "sha256:c030cdecce518bdf50067237a17de0b5508d3e962e743a87dd41e7df3ef01ff9",
    "components": [
      "chain@1",
      "combined@1",
      "conditions@1",
      "fixed_lots@1",
      "limits@1",
      "per_leg@1",
      "protected_limit@1"
    ],
    "warm_up": null
  },
  "error": null
}
```

**Example: An unknown body field (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `doc`, expected one of `document`, `definition`, `name`, `description`, `tags`, `revision`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: YAML anchors are refused before parsing values (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_document",
    "message": "The document is not accepted: anchors (&name) are not allowed in strategy documents (line 2, column 7).",
    "field": "document"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_document` | The strategy document has problems; `message` lists each (where in the wizard, what is wrong) and `field` names the first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/dry-run

Scope: `strategies`

Dry-run a strategy

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | `any` | yes |  |
| `definition` | `object \| null` | no |  |
| `name` | `string \| null` | no |  |
| `description` | `string \| null` | no |  |
| `tags` | `string[] \| null` | no |  |
| `strategy_id` | `string \| null` | no | Instead of a document: one of the caller's strategies. |
| `version` | `integer (int32) \| null` | no | With `strategy_id` (default: the draft). |
| `account` | `string \| null` | no | Account to size and check against. |
| `at` | `string \| null` | no | Check the entry window and days as at this time (RFC 3339; default now). Contracts are always picked from live prices. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/dry-run" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "Broken",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO"
        },
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            }
          ]
        },
        "entry": {
          "window": {
            "start": "14:00",
            "end": "10:00"
          }
        },
        "risk": {
          "combined": {
            "type": "combined@1",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            }
          }
        }
      }
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `decision` | `string` | yes | What the strategy would do right now. One of: `would_wait`, `would_enter`, `would_skip`, `cannot_decide`, `invalid`. |
| `text` | `string` | yes | The decision in plain words. |
| `legs` | `object[]` | yes | Contracts it would trade now (when they could be picked). |
| `legs[].leg_id` | `string` | yes |  |
| `legs[].symbol` | `string` | yes | `EXCHANGE:SYMBOL`. |
| `legs[].side` | `string` | yes |  |
| `legs[].lots` | `integer (int32)` | yes |  |
| `legs[].qty` | `integer (int32)` | yes |  |
| `legs[].strike` | `number (double) \| null` | no |  |
| `legs[].price` | `number (double) \| null` | no | Premium (or price) seen. |
| `legs[].iv` | `number (double) \| null` | no |  |
| `legs[].delta` | `number (double) \| null` | no |  |
| `net_premium` | `number (double) \| null` | no | Net premium per cycle of those legs: positive pays, negative receives (₹). |
| `validation` | `object` | yes | What checking a document found. |
| `validation.ok` | `boolean` | yes | No errors: the document can be saved. |
| `validation.deployable` | `boolean` | yes | No errors and no blockers: a version of it can be deployed. |
| `validation.schema_version` | `integer (int32)` | yes | Format of the checked document (after migration). |
| `validation.migrated_from` | `integer (int32) \| null` | no | The format it was written in, when older (it was migrated). |
| `validation.errors` | `object[]` | yes | Refused: fix these to save. |
| `validation.errors[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.errors[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.errors[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.errors[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.errors[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.blockers` | `object[]` | yes | Saved as a draft, not deployable until resolved. |
| `validation.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.warnings` | `object[]` | yes |  |
| `validation.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.needs_ack` | `string[]` | yes | Warning codes to acknowledge at deploy (`unlimited_loss`). |
| `validation.summary` | `string \| null` | no | The strategy in plain English. |
| `validation.compiled_hash` | `string \| null` | no | Hash of the compiled spec (same for any way of writing the same strategy). |
| `validation.components` | `string[]` | yes | `name@major` of every component used. |
| `validation.warm_up` | `object \| null` | no | Indicator warm-up (from the engine; null until it is connected). |
| `analytics` | `object \| null` | no | Of `legs` as if entered now at their premiums: the payoff at expiry and today, breakevens, max profit / loss (with the unlimited flags), probability of profit, per-leg IV and greeks, and the spot × IV grid. Absent when no contracts were picked or they are not options / futures (`analytics_note` says why). |
| `analytics.underlying` | `string \| null` | yes | The underlying (e.g. `NIFTY`); null when the positions are not on one underlying. |
| `analytics.spot` | `number (double) \| null` | yes | The underlying's price used; null when not known. |
| `analytics.model` | `string` | yes | `black_scholes` (on the underlying's price, dividend yield 0). |
| `analytics.rate` | `number (double)` | yes | Annual risk-free rate used, e.g. `0.065`. |
| `analytics.greeks` | `object \| null` | yes | Of every open leg; null when an option leg has no IV. |
| `analytics.greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `analytics.greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `analytics.greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `analytics.greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `analytics.breakevens` | `(number (double))[]` | yes | Prices of the underlying where the P&L at expiry crosses zero (exact, also outside the payoff range), ascending. |
| `analytics.max_profit` | `number (double) \| null` | yes | Highest P&L at expiry of the open positions; null when unlimited (or not computed). |
| `analytics.max_profit_unlimited` | `boolean` | yes | The P&L at expiry keeps rising as the underlying rises. |
| `analytics.max_loss` | `number (double) \| null` | yes | Lowest P&L at expiry (negative: a loss); null when unlimited (or not computed). |
| `analytics.max_loss_unlimited` | `boolean` | yes | The P&L at expiry keeps falling as the underlying rises. |
| `analytics.probability_of_profit` | `number (double)` | no | Chance (0 to 1) that the P&L at expiry is above zero: risk-neutral, the underlying lognormal from `spot` at `rate`, one volatility (the option legs' IVs averaged, weighted by each leg's \|vega\|) to the options' common expiry. Absent (with a `note`) when an option leg has no IV, the options expire on different dates, or there is no option leg. |
| `analytics.note` | `string \| null` | yes | What was left out and why, in plain English; null when nothing. |
| `analytics.legs` | `object[] \| null` | no | Per leg (`GET /v1/strategies/{name}` only). |
| `analytics.legs[].instrument_token` | `string` | yes |  |
| `analytics.legs[].tradingsymbol` | `string` | yes |  |
| `analytics.legs[].instrument` | `string` | yes | `option` or `future`. |
| `analytics.legs[].option_type` | `string \| null` | yes | `CE` or `PE`; null for a future. |
| `analytics.legs[].strike` | `number (double) \| null` | yes |  |
| `analytics.legs[].expiry` | `string \| null` | yes | `YYYY-MM-DD`. |
| `analytics.legs[].days_to_expiry` | `number (double) \| null` | yes | Calendar days to 15:30 IST of the expiry day. |
| `analytics.legs[].net_qty` | `integer (int64)` | yes | Units, summed over accounts. |
| `analytics.legs[].avg_price` | `number (double)` | yes |  |
| `analytics.legs[].ltp` | `number (double) \| null` | yes |  |
| `analytics.legs[].iv_pct` | `number (double) \| null` | yes | Implied volatility from `ltp`, in percent; null when it cannot be solved (see `note`). |
| `analytics.legs[].greeks` | `object \| null` | yes | Of the whole leg (`net_qty` units); null without an IV. |
| `analytics.legs[].greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `analytics.legs[].greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `analytics.legs[].greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `analytics.legs[].greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `analytics.legs[].note` | `string \| null` | yes |  |
| `analytics.payoff` | `object \| null` | no | The payoff curves (`GET /v1/strategies/{name}` only). |
| `analytics.payoff.spots` | `(number (double))[]` | yes | Prices of the underlying, ascending (± 10% around `spot`). |
| `analytics.payoff.at_expiry` | `(number (double))[]` | yes | At expiry (every option at its intrinsic value), per spot. |
| `analytics.payoff.today` | `(number (double))[] \| null` | yes | Today, at the legs' implied volatilities, per spot; null when an option leg has no IV. |
| `analytics.scenario` | `object \| null` | no | Spot × IV scenario grid (`GET /v1/strategies/{name}?scenario=1` only, when every option leg has an IV). |
| `analytics.scenario.spot_shifts_pct` | `(integer (int32))[]` | yes | Spot moves, percent from `spot`: −5 to +5. |
| `analytics.scenario.iv_shifts` | `(integer (int32))[]` | yes | IV moves, volatility points added to every option leg's IV. |
| `analytics.scenario.today_pnl` | `((number (double))[])[]` | yes | `today_pnl[i][j]`: P&L with IV shift `iv_shifts[i]` and spot shift `spot_shifts_pct[j]`, today. |
| `margin_estimate` | `object \| null` | no | A rough margin figure for `legs`; absent without `analytics` or the underlying's price. |
| `margin_estimate.amount` | `number (double)` | yes | Rupees. |
| `margin_estimate.method` | `string` | yes | What a margin estimate is based on. One of: `defined_risk`, `contract_value`. |
| `margin_estimate.note` | `string` | yes | How it was worked out, in plain English. |
| `analytics_note` | `string \| null` | no | Why `analytics` is absent, in plain English. |

**Example: A document with problems would not run (200)**

```json
{
  "status": "success",
  "data": {
    "decision": "invalid",
    "text": "Would not run: fix the problems first.",
    "legs": [],
    "net_premium": null,
    "validation": {
      "ok": false,
      "deployable": false,
      "schema_version": 1,
      "migrated_from": null,
      "errors": [
        {
          "field": "spec.entry.window",
          "label": "When · Entry · Window",
          "message": "The entry window must start before it ends.",
          "code": "invalid_combination",
          "needs_ack": false
        }
      ],
      "blockers": [],
      "warnings": [],
      "needs_ack": [],
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 14:00 and 10:00, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "compiled_hash": null,
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "warm_up": null
    },
    "analytics_note": "No analytics: no contracts were picked."
  },
  "error": null
}
```

**Example: An unknown body field (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `x`, expected one of `document`, `definition`, `name`, `description`, `tags`, `strategy_id`, `version`, `account`, `at`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/condition-values

Scope: `strategies`

A condition's values now

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `when` | `string` | yes | The condition as the document writes it: `u.rsi(14, '15m') > 60`, `crosses_above(u.ema(9, '5m'), u.ema(21, '5m'))`. |
| `on` | `string \| null` | no | `close` or `tick`; absent: `close` when it reads an indicator, else `tick` (as the document's default). One of: `close`, `tick`. |
| `place` | `string` | no | Which part of the strategy the condition belongs to. One of: `entry`, `exit`. |
| `underlying` | `object` | yes | The underlying as the document's `spec.underlying` names it. |
| `underlying.name` | `string` | yes | `NIFTY`, `BANKNIFTY`, `RELIANCE`, ... |
| `underlying.exchange` | `string \| null` | no | `NFO` (default), `BFO`, `NSE`, `BSE`. |
| `underlying.reference` | `string \| null` | no | What `u` watches: `spot` (default: the index or stock) or `future` (the nearest future). |
| `watch` | `object` | no | Other watched names and their document symbols (`{"bank": "NSE_INDEX:NIFTY BANK"}`, `@future(NIFTY, 0)`); `u` may override the underlying's default. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/condition-values" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "when": "cycle.minutes > 30",
    "underlying": {
      "name": "NIFTY",
      "exchange": "NFO"
    },
    "place": "exit"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `true_now` | `boolean \| null` | yes | Whether it holds now; null when that is not known (`reason` says why). A crossing is true when it crossed at the last candle close. A hold (`hold_s`) is not waited for. |
| `status` | `string` | yes | `ok`, `warming` (a value needs more candles) or `no_history` (history could not be loaded now; values fill in from live candles only once a strategy runs). |
| `reason` | `string \| null` | yes | Why `true_now` is null, in plain English. |
| `on` | `string` | yes | `close` or `tick`: how it was checked. One of: `close`, `tick`. |
| `at` | `string` | yes | When the values were read (RFC 3339). |
| `values` | `object[]` | yes |  |
| `values[].expr` | `string` | yes | As written: `u.rsi(14, '15m')`. |
| `values[].label` | `string` | yes | `RSI(14) · NIFTY 50 · 15m`. |
| `values[].text` | `string \| null` | yes | `RSI(14,15m) = 62.3`; null while it is not known. |
| `values[].value` | `number (double) \| null` | yes | The value the condition reads now: the last closed candle's (`on: close`) or the forming candle's (`on: tick`). |
| `values[].closed` | `number (double) \| null` | yes | The last closed candle's value. |
| `values[].live` | `number (double) \| null` | yes | The forming candle's value (null outside market hours). |
| `values[].ready` | `boolean` | yes | Enough candles for the value to match a chart. |
| `values[].warm_bars_left` | `integer (int32)` | yes | Closed candles still needed. |
| `values[].compare` | `object \| null` | yes | How far it is from the condition's level, when it compares with a number. |
| `values[].compare.op` | `string` | yes |  |
| `values[].compare.to` | `number (double) \| null` | yes |  |
| `values[].compare.band` | `number (double) \| null` | yes |  |
| `values[].compare.distance` | `number (double) \| null` | yes |  |

**Example: An exit condition only a running trade can answer: not known, and why (200)**

```json
{
  "status": "success",
  "data": {
    "true_now": null,
    "status": "ok",
    "reason": "cycle.minutes is known only while a trade is open",
    "on": "tick",
    "at": "2026-10-20T10:33:10.157Z",
    "values": []
  },
  "error": null
}
```

**Example: A price condition that holds now (200)**

```json
{
  "status": "success",
  "data": {
    "true_now": true,
    "status": "ok",
    "reason": null,
    "on": "tick",
    "at": "2026-10-20T10:33:10.156Z",
    "values": []
  },
  "error": null
}
```

**Example: A condition that does not compile (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "condition ends too early (at byte 7)",
    "field": "when"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v2/strategies/components

Scope: `read`

List strategy components

```bash
curl "https://api.cirrus.trade/v2/strategies/components" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes | `name@major`, as written in documents. |
| `[].name` | `string` | yes |  |
| `[].major` | `integer (int32)` | yes |  |
| `[].slots` | `string[]` | yes | Slots it fills (`entry_source`, `legs`, `sizing`, ...). |
| `[].label` | `string` | yes |  |
| `[].explain` | `string` | yes |  |
| `[].group` | `string` | yes |  |
| `[].status` | `string` | yes | `ga`, `preview`, `planned` or `deprecated`. |
| `[].phase` | `string` | yes | Release phase (`a`, `b`, ...). |
| `[].deployable` | `boolean` | yes | Whether a version using it can be deployed now. |
| `[].deprecated` | `boolean` | yes |  |
| `[].sunset_after` | `string \| null` | no |  |
| `[].needs` | `string[]` | yes | Broker capabilities it needs. |
| `[].bare` | `boolean` | yes | May be written as the bare `name@major` (every setting has a default). |
| `[].params_schema` | `object` | yes | JSON Schema of its parameters. |

**Example: The component registry (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "conditions@1",
      "name": "conditions",
      "major": 1,
      "slots": [
        "entry_source",
        "exit_source"
      ],
      "label": "Conditions",
      "explain": "Indicators, prices and time, checked by the server.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "conditions@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "Leave empty to enter as soon as the window opens (time-only).",
            "default": null
          },
          "on": {
            "enum": [
              "close",
              "tick"
            ],
            "x-labels": {
              "close": "At each candle close",
              "tick": "On every price change"
            },
            "title": "Checked on",
            "description": "close: at each bar close (default); tick: on every price change.",
            "default": "close"
          },
          "hold_s": {
            "type": "integer",
            "minimum": 0,
            "maximum": 300,
            "title": "Hold for",
            "description": "Seconds the condition must stay true before it counts.",
            "default": 0
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Conditions",
        "description": "Indicators, prices and time, checked by the server.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "manual@1",
      "name": "manual",
      "major": 1,
      "slots": [
        "entry_source",
        "exit_source"
      ],
      "label": "Manual",
      "explain": "You decide when, with a button in the app.",
      "group": "You",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "manual@1"
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Manual",
        "description": "You decide when, with a button in the app.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "api@1",
      "name": "api",
      "major": 1,
      "slots": [
        "entry_source",
        "exit_source"
      ],
      "label": "Your code (API)",
      "explain": "Your program sends enter or exit signals to the API.",
      "group": "You",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "api@1"
          },
          "max_signal_age_s": {
            "type": "integer",
            "minimum": 30,
            "maximum": 120,
            "title": "Refuse signals older than (seconds)",
            "default": 30
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Your code (API)",
        "description": "Your program sends enter or exit signals to the API.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "webhook@1",
      "name": "webhook",
      "major": 1,
      "slots": [
        "entry_source",
        "exit_source"
      ],
      "label": "Alerts (signal URL)",
      "explain": "Alerts from a charting or screener tool, on a signal URL chosen at deploy.",
      "group": "Alerts",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "webhook@1"
          },
          "trigger_only": {
            "type": "boolean",
            "title": "The alert only starts the entry or exit",
            "description": "On: the strategy trades its own legs and the alert only says when. Off: use legs from the signal (from_signal@1) so the alert's instruments are traded.",
            "default": false
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Alerts (signal URL)",
        "description": "Alerts from a charting or screener tool, on a signal URL chosen at deploy.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "chain@1",
      "name": "chain",
      "major": 1,
      "slots": [
        "legs"
      ],
      "label": "From the option chain",
      "explain": "Contracts picked from the live chain when the entry fires.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "selector": {
            "const": "chain@1"
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[a-z][a-z0-9_]{0,15}$",
                  "title": "Leg id",
                  "description": "Short name other fields refer to (sc, lp, fut)."
                },
                "instrument": {
                  "enum": [
                    "option",
                    "future",
                    "stock"
                  ],
                  "x-labels": {
                    "option": "Option",
                    "future": "Future",
                    "stock": "Stock"
                  },
                  "title": "Instrument"
                },
                "side": {
                  "enum": [
                    "buy",
                    "sell"
                  ],
                  "x-labels": {
                    "buy": "Buy",
                    "sell": "Sell"
                  },
                  "title": "Side"
                },
                "option_type": {
                  "anyOf": [
                    {
                      "enum": [
                        "call",
                        "put"
                      ],
                      "x-labels": {
                        "call": "Call",
                        "put": "Put"
                      }
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Option type",
                  "default": null
                },
                "expiry": {
                  "anyOf": [
                    {
                      "oneOf": [
                        {
                          "type": "object",
                          "properties": {
                            "kind": {
                              "enum": [
                                "weekly",
                                "monthly"
                              ],
                              "x-labels": {
                                "weekly": "Weekly",
                                "monthly": "Monthly"
                              },
                              "title": "Expiry"
                            },
                            "n": {
                              "type": "integer",
                              "minimum": 0,
                              "maximum": 12,
                              "title": "Which one",
                              "description": "0 is the current expiry, 1 the next.",
                              "default": 0
                            }
                          },
                          "required": [
                            "kind"
                          ],
                          "additionalProperties": false
                        },
                        {
                          "type": "object",
                          "properties": {
                            "min_dte": {
                              "type": "integer",
                              "minimum": 0,
                              "maximum": 90,
                              "title": "At least days to expiry"
                            }
                          },
                          "required": [
                            "min_dte"
                          ],
                          "additionalProperties": false
                        },
                        {
                          "type": "object",
                          "properties": {
                            "date": {
                              "type": "string",
                              "format": "date",
                              "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
                              "title": "Expiry date"
                            }
                          },
                          "required": [
                            "date"
                          ],
                          "additionalProperties": false
                        }
                      ]
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Expiry",
                  "default": null
                },
                "strike": {
                  "anyOf": [
                    {
                      "oneOf": [
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "atm"
                            },
                            "offset": {
                              "type": "integer",
                              "minimum": -20,
                              "maximum": 20,
                              "title": "Strikes from ATM",
                              "description": "Positive is out of the money for calls and puts alike.",
                              "default": 0
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by"
                          ],
                          "title": "At the money"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "pct_otm"
                            },
                            "pct": {
                              "type": "number",
                              "minimum": 0,
                              "maximum": 20,
                              "title": "Percent OTM"
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "pct"
                          ],
                          "title": "Percent out of the money"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "delta"
                            },
                            "delta": {
                              "type": "number",
                              "minimum": 0.05,
                              "maximum": 0.5,
                              "title": "Delta",
                              "description": "Absolute delta; the sign follows the option type."
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "delta"
                          ],
                          "title": "Delta"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "premium"
                            },
                            "premium": {
                              "type": "number",
                              "exclusiveMinimum": 0,
                              "maximum": 100000,
                              "title": "Premium"
                            },
                            "mode": {
                              "enum": [
                                "nearest",
                                "at_least",
                                "at_most"
                              ],
                              "x-labels": {
                                "nearest": "Nearest to",
                                "at_least": "At least",
                                "at_most": "At most"
                              },
                              "title": "Match",
                              "default": "nearest"
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "premium"
                          ],
                          "title": "Premium"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "expected_move"
                            },
                            "k": {
                              "type": "number",
                              "minimum": 0.5,
                              "maximum": 3,
                              "title": "Times the expected move"
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "k"
                          ],
                          "title": "Expected move",
                          "x-phase": "b"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "relative"
                            },
                            "to": {
                              "type": "string",
                              "pattern": "^[a-z][a-z0-9_]{0,15}$",
                              "title": "Relative to leg"
                            },
                            "width_strikes": {
                              "anyOf": [
                                {
                                  "type": "integer",
                                  "minimum": 1,
                                  "maximum": 50
                                },
                                {
                                  "type": "null"
                                }
                              ],
                              "title": "Width in strikes",
                              "default": null
                            },
                            "width_points": {
                              "anyOf": [
                                {
                                  "type": "number",
                                  "exclusiveMinimum": 0,
                                  "maximum": 100000
                                },
                                {
                                  "type": "null"
                                }
                              ],
                              "title": "Width in points",
                              "default": null
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "to"
                          ],
                          "title": "Relative to another leg"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "by": {
                              "const": "strike"
                            },
                            "strike": {
                              "type": "number",
                              "exclusiveMinimum": 0,
                              "maximum": 10000000,
                              "title": "Strike"
                            }
                          },
                          "additionalProperties": false,
                          "required": [
                            "by",
                            "strike"
                          ],
                          "title": "Exact strike"
                        }
                      ]
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Strike",
                  "default": null
                },
                "lots": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 100,
                  "title": "Lots",
                  "description": "A ratio: sizing multiplies it.",
                  "default": 1
                },
                "hedge_for": {
                  "anyOf": [
                    {
                      "type": "string",
                      "pattern": "^[a-z][a-z0-9_]{0,15}$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Hedge for leg",
                  "description": "The short leg this bought option protects (paired automatically when empty).",
                  "default": null
                },
                "risk": {
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "stop_loss": {
                          "anyOf": [
                            {
                              "oneOf": [
                                {
                                  "type": "object",
                                  "properties": {
                                    "pct": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 1000,
                                      "title": "Percent"
                                    }
                                  },
                                  "required": [
                                    "pct"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "points": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 100000,
                                      "title": "Points"
                                    }
                                  },
                                  "required": [
                                    "points"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "price": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 10000000,
                                      "title": "Price"
                                    }
                                  },
                                  "required": [
                                    "price"
                                  ],
                                  "additionalProperties": false
                                }
                              ]
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Stop loss",
                          "default": null
                        },
                        "target": {
                          "anyOf": [
                            {
                              "oneOf": [
                                {
                                  "type": "object",
                                  "properties": {
                                    "pct": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 1000,
                                      "title": "Percent"
                                    }
                                  },
                                  "required": [
                                    "pct"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "points": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 100000,
                                      "title": "Points"
                                    }
                                  },
                                  "required": [
                                    "points"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "price": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 10000000,
                                      "title": "Price"
                                    }
                                  },
                                  "required": [
                                    "price"
                                  ],
                                  "additionalProperties": false
                                }
                              ]
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Target",
                          "default": null
                        },
                        "trail": {
                          "anyOf": [
                            {
                              "type": "object",
                              "properties": {
                                "step": {
                                  "type": "number",
                                  "exclusiveMinimum": 0,
                                  "maximum": 100000,
                                  "title": "Every"
                                },
                                "move_by": {
                                  "type": "number",
                                  "exclusiveMinimum": 0,
                                  "maximum": 100000,
                                  "title": "Move the stop by"
                                }
                              },
                              "required": [
                                "step",
                                "move_by"
                              ],
                              "additionalProperties": false
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Trailing stop",
                          "default": null
                        }
                      },
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Leg protection",
                  "description": "Overrides the per-leg protection for this leg.",
                  "default": null
                },
                "liquidity": {
                  "type": "object",
                  "properties": {
                    "max_spread_pct": {
                      "type": "number",
                      "minimum": 0.1,
                      "maximum": 10,
                      "title": "Widest price gap (percent of mid)",
                      "default": 3
                    },
                    "min_oi": {
                      "type": "integer",
                      "minimum": 0,
                      "maximum": 100000000,
                      "title": "Least open interest",
                      "default": 0
                    },
                    "min_premium": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 10000,
                      "title": "Least premium for a short",
                      "default": 1
                    }
                  },
                  "additionalProperties": false,
                  "title": "Liquidity guard",
                  "default": {}
                }
              },
              "required": [
                "id",
                "instrument",
                "side"
              ],
              "additionalProperties": false
            },
            "minItems": 1,
            "maxItems": 6,
            "title": "Legs"
          }
        },
        "additionalProperties": false,
        "required": [
          "selector",
          "items"
        ],
        "title": "From the option chain",
        "description": "Contracts picked from the live chain when the entry fires.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "fixed@1",
      "name": "fixed",
      "major": 1,
      "slots": [
        "legs"
      ],
      "label": "Exact contracts",
      "explain": "The contracts you name, every cycle.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "selector": {
            "const": "fixed@1"
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[a-z][a-z0-9_]{0,15}$",
                  "title": "Leg id"
                },
                "symbol": {
                  "type": "string",
                  "pattern": "^([A-Z0-9_]+:[A-Za-z0-9 &_.-]+|@future\\([A-Z0-9&_-]{1,32}, ?[0-3]\\))$",
                  "maxLength": 64,
                  "title": "Contract",
                  "description": "EXCHANGE:SYMBOL, e.g. NFO:NIFTY26OCT23400CE."
                },
                "side": {
                  "enum": [
                    "buy",
                    "sell"
                  ],
                  "x-labels": {
                    "buy": "Buy",
                    "sell": "Sell"
                  },
                  "title": "Side"
                },
                "lots": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 100,
                  "title": "Lots",
                  "default": 1
                },
                "hedge_for": {
                  "anyOf": [
                    {
                      "type": "string",
                      "pattern": "^[a-z][a-z0-9_]{0,15}$"
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Hedge for leg",
                  "default": null
                },
                "risk": {
                  "anyOf": [
                    {
                      "type": "object",
                      "properties": {
                        "stop_loss": {
                          "anyOf": [
                            {
                              "oneOf": [
                                {
                                  "type": "object",
                                  "properties": {
                                    "pct": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 1000,
                                      "title": "Percent"
                                    }
                                  },
                                  "required": [
                                    "pct"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "points": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 100000,
                                      "title": "Points"
                                    }
                                  },
                                  "required": [
                                    "points"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "price": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 10000000,
                                      "title": "Price"
                                    }
                                  },
                                  "required": [
                                    "price"
                                  ],
                                  "additionalProperties": false
                                }
                              ]
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Stop loss",
                          "default": null
                        },
                        "target": {
                          "anyOf": [
                            {
                              "oneOf": [
                                {
                                  "type": "object",
                                  "properties": {
                                    "pct": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 1000,
                                      "title": "Percent"
                                    }
                                  },
                                  "required": [
                                    "pct"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "points": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 100000,
                                      "title": "Points"
                                    }
                                  },
                                  "required": [
                                    "points"
                                  ],
                                  "additionalProperties": false
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "price": {
                                      "type": "number",
                                      "exclusiveMinimum": 0,
                                      "maximum": 10000000,
                                      "title": "Price"
                                    }
                                  },
                                  "required": [
                                    "price"
                                  ],
                                  "additionalProperties": false
                                }
                              ]
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Target",
                          "default": null
                        },
                        "trail": {
                          "anyOf": [
                            {
                              "type": "object",
                              "properties": {
                                "step": {
                                  "type": "number",
                                  "exclusiveMinimum": 0,
                                  "maximum": 100000,
                                  "title": "Every"
                                },
                                "move_by": {
                                  "type": "number",
                                  "exclusiveMinimum": 0,
                                  "maximum": 100000,
                                  "title": "Move the stop by"
                                }
                              },
                              "required": [
                                "step",
                                "move_by"
                              ],
                              "additionalProperties": false
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "title": "Trailing stop",
                          "default": null
                        }
                      },
                      "additionalProperties": false
                    },
                    {
                      "type": "null"
                    }
                  ],
                  "title": "Leg protection",
                  "default": null
                }
              },
              "required": [
                "id",
                "symbol",
                "side"
              ],
              "additionalProperties": false
            },
            "minItems": 1,
            "maxItems": 6,
            "title": "Legs"
          }
        },
        "additionalProperties": false,
        "required": [
          "selector",
          "items"
        ],
        "title": "Exact contracts",
        "description": "The contracts you name, every cycle.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "from_signal@1",
      "name": "from_signal",
      "major": 1,
      "slots": [
        "legs"
      ],
      "label": "From the signal",
      "explain": "The alert names what to trade (Chartink stocks, a TradingView alert's instrument); only allowed ones, within your limits.",
      "group": "Alerts",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "selector": {
            "const": "from_signal@1"
          },
          "underlyings": {
            "type": "array",
            "items": {
              "type": "string",
              "pattern": "^[A-Z0-9&_-]{1,32}$"
            },
            "maxItems": 20,
            "title": "Allowed underlyings",
            "description": "Only these are traded; empty: any the alert names.",
            "default": []
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 1
          },
          "instrument": {
            "enum": [
              "signal",
              "stock",
              "future",
              "option"
            ],
            "x-labels": {
              "signal": "As the alert says",
              "stock": "The stock",
              "future": "Its nearest future",
              "option": "Its nearest option"
            },
            "title": "Trade each one as",
            "description": "signal: as the alert says (a scanned stock is the stock); stock; future: its nearest monthly future; option: its nearest monthly option at ATM ± offset.",
            "default": "signal"
          },
          "side": {
            "enum": [
              "signal",
              "buy",
              "sell"
            ],
            "x-labels": {
              "signal": "As the alert says",
              "buy": "Buy",
              "sell": "Sell"
            },
            "title": "Side",
            "description": "signal: as the alert says (TradingView leg, Chartink buy / sell).",
            "default": "signal"
          },
          "option_type": {
            "enum": [
              "call",
              "put"
            ],
            "x-labels": {
              "call": "Call",
              "put": "Put"
            },
            "title": "Option type",
            "default": "call"
          },
          "strike_offset": {
            "type": "integer",
            "minimum": -20,
            "maximum": 20,
            "title": "Strikes from ATM",
            "default": 0
          },
          "lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000,
            "title": "Lots per instrument (shares for a stock)",
            "description": "What the strategy's fixed sizing multiplies; the signal's own size with from_signal sizing.",
            "default": 1
          },
          "max_shares": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000,
            "title": "At most shares per stock",
            "default": 1000
          },
          "max_per_alert": {
            "type": "integer",
            "minimum": 1,
            "maximum": 6,
            "title": "At most instruments per alert",
            "default": 5
          },
          "max_per_day": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "title": "At most instruments per day",
            "description": "Each instrument is traded at most once a day.",
            "default": 10
          },
          "skip_held": {
            "type": "boolean",
            "title": "Skip what the account already holds",
            "default": true
          }
        },
        "additionalProperties": false,
        "required": [
          "selector"
        ],
        "title": "From the signal",
        "description": "The alert names what to trade (Chartink stocks, a TradingView alert's instrument); only allowed ones, within your limits.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "fixed_lots@1",
      "name": "fixed_lots",
      "major": 1,
      "slots": [
        "sizing"
      ],
      "label": "Fixed lots",
      "explain": "The same lots every cycle.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "fixed_lots@1"
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 10
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Fixed lots",
        "description": "The same lots every cycle.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "max_premium@1",
      "name": "max_premium",
      "major": 1,
      "slots": [
        "sizing"
      ],
      "label": "Premium cap",
      "explain": "Fewer lots when premiums are high, so each cycle stays under a cap.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "max_premium@1"
          },
          "max_premium": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100000000,
            "title": "Most premium per cycle"
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 10
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "max_premium"
        ],
        "title": "Premium cap",
        "description": "Fewer lots when premiums are high, so each cycle stays under a cap.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "risk@1",
      "name": "risk",
      "major": 1,
      "slots": [
        "sizing"
      ],
      "label": "Risk per trade",
      "explain": "Lots from the most you are willing to lose per trade.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "risk@1"
          },
          "risk_rupees": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100000000,
            "title": "Risk per trade"
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 10
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "risk_rupees"
        ],
        "title": "Risk per trade",
        "description": "Lots from the most you are willing to lose per trade.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "capital_pct@1",
      "name": "capital_pct",
      "major": 1,
      "slots": [
        "sizing"
      ],
      "label": "Percent of capital",
      "explain": "Lots from a share of your available margin.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "capital_pct@1"
          },
          "capital_pct": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100,
            "title": "Percent of available margin"
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 10
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "capital_pct"
        ],
        "title": "Percent of capital",
        "description": "Lots from a share of your available margin.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "from_signal@1",
      "name": "from_signal",
      "major": 1,
      "slots": [
        "sizing"
      ],
      "label": "From the signal",
      "explain": "The signal says how much (a TradingView leg's lots or amount, a Chartink strategy's quantity or amount per stock), up to a cap. Only with legs from the signal.",
      "group": "Alerts",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "from_signal@1"
          },
          "max_lots": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "At most lots per leg",
            "default": 10
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "From the signal",
        "description": "The signal says how much (a TradingView leg's lots or amount, a Chartink strategy's quantity or amount per stock), up to a cap. Only with legs from the signal.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "protected_limit@1",
      "name": "protected_limit",
      "major": 1,
      "slots": [
        "execution"
      ],
      "label": "Protected limit",
      "explain": "Limit orders a little beyond the market, re-priced until filled or the slippage cap.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "protected_limit@1"
          },
          "order": {
            "enum": [
              "auto",
              "hedge_first",
              "simultaneous",
              "stagger"
            ],
            "x-labels": {
              "auto": "Automatic",
              "hedge_first": "Hedges first",
              "simultaneous": "All legs together",
              "stagger": "One after another"
            },
            "title": "Order of legs",
            "description": "auto: hedges first when a bought option protects a sold one, else together.",
            "default": "auto"
          },
          "hedge_wait_ms": {
            "type": "integer",
            "minimum": 1000,
            "maximum": 30000,
            "title": "Wait for hedges (ms)",
            "default": 5000
          },
          "stagger_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60000,
            "title": "Gap between legs (ms)",
            "default": 0
          },
          "buffer_pct": {
            "type": "number",
            "minimum": 0,
            "maximum": 3,
            "title": "Price buffer (percent)",
            "default": 0.5
          },
          "chase": {
            "type": "object",
            "properties": {
              "every_ms": {
                "type": "integer",
                "minimum": 200,
                "maximum": 10000,
                "title": "Re-price every (ms)",
                "default": 1000
              },
              "max_slippage_pct": {
                "type": "number",
                "minimum": 0,
                "maximum": 5,
                "title": "Most slippage (percent)",
                "default": 1
              },
              "max_attempts": {
                "type": "integer",
                "minimum": 1,
                "maximum": 50,
                "title": "Most re-prices",
                "default": 10
              }
            },
            "additionalProperties": false,
            "title": "Chase",
            "default": {}
          },
          "on_incomplete": {
            "enum": [
              "safest",
              "keep_and_manage",
              "close_opened"
            ],
            "x-labels": {
              "safest": "Do the safest thing",
              "keep_and_manage": "Keep and manage what filled",
              "close_opened": "Close what filled"
            },
            "title": "If only some legs fill",
            "description": "safest: close a sold option left without its hedge at once, keep and manage the rest.",
            "default": "safest"
          },
          "exit": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "buffer_pct": {
                    "anyOf": [
                      {
                        "type": "number",
                        "minimum": 0,
                        "maximum": 5
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Exit price buffer (percent)",
                    "description": "Not set: the entry buffer.",
                    "default": null
                  },
                  "chase": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "every_ms": {
                            "anyOf": [
                              {
                                "type": "integer",
                                "minimum": 200,
                                "maximum": 10000
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "title": "Re-price exits every (ms)",
                            "default": null
                          },
                          "max_slippage_pct": {
                            "anyOf": [
                              {
                                "type": "number",
                                "minimum": 0,
                                "maximum": 10
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "title": "Most exit slippage per placement (percent)",
                            "default": null
                          },
                          "max_attempts": {
                            "anyOf": [
                              {
                                "type": "integer",
                                "minimum": 1,
                                "maximum": 50
                              },
                              {
                                "type": "null"
                              }
                            ],
                            "title": "Most exit re-prices per placement",
                            "default": null
                          }
                        },
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Exit chase",
                    "description": "Each setting not given is the entry chase's.",
                    "default": null
                  },
                  "retry_every_ms": {
                    "type": "integer",
                    "minimum": 2000,
                    "maximum": 60000,
                    "title": "Place an unfilled exit again every (ms)",
                    "description": "A refused or unfilled remainder is placed again from a fresh position read.",
                    "default": 10000
                  },
                  "market_after_s": {
                    "anyOf": [
                      {
                        "type": "integer",
                        "minimum": 30,
                        "maximum": 900
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Send at market after (seconds)",
                    "description": "Off unless set: an exit still not filled this long after it started sends the remainder as a market order (with the broker's market protection), in market hours only, once per leg.",
                    "default": null
                  },
                  "escalate": {
                    "anyOf": [
                      {
                        "type": "object",
                        "properties": {
                          "telegram_s": {
                            "type": "integer",
                            "minimum": 30,
                            "maximum": 3600,
                            "title": "Telegram after (seconds)",
                            "default": 60
                          },
                          "all_channels_s": {
                            "type": "integer",
                            "minimum": 60,
                            "maximum": 7200,
                            "title": "Every channel after (seconds)",
                            "default": 180
                          },
                          "ops_s": {
                            "type": "integer",
                            "minimum": 120,
                            "maximum": 14400,
                            "title": "Page operators after (seconds)",
                            "default": 600
                          }
                        },
                        "additionalProperties": false
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Tell me when an exit is not filling",
                    "description": "Seconds since the exit started; each must be later than the one before.",
                    "default": null
                  }
                },
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "title": "Exit orders",
            "description": "Overrides for exit orders; absent, exits are priced and chased like entries.",
            "default": null
          },
          "retry_next_signal": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 10
              },
              {
                "type": "null"
              }
            ],
            "title": "Try again on the next signal (times)",
            "description": "Entries are never re-sent; a refused entry may be tried again on the next signal, at most this many times, then no more trades that day (0: none). Not set: a refused entry is tried again after a short wait.",
            "default": null
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Protected limit",
        "description": "Limit orders a little beyond the market, re-priced until filled or the slippage cap.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "per_leg@1",
      "name": "per_leg",
      "major": 1,
      "slots": [
        "per_leg"
      ],
      "label": "Per-leg protection",
      "explain": "A stop, target or trailing stop on every leg.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "per_leg@1"
          },
          "stop_loss": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "required": [
                      "pct"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "points": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000,
                        "title": "Points"
                      }
                    },
                    "required": [
                      "points"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "price": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 10000000,
                        "title": "Price"
                      }
                    },
                    "required": [
                      "price"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Stop loss",
            "default": null
          },
          "target": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "required": [
                      "pct"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "points": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000,
                        "title": "Points"
                      }
                    },
                    "required": [
                      "points"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "price": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 10000000,
                        "title": "Price"
                      }
                    },
                    "required": [
                      "price"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Target",
            "default": null
          },
          "trail": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "step": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000,
                    "title": "Every"
                  },
                  "move_by": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000,
                    "title": "Move the stop by"
                  }
                },
                "required": [
                  "step",
                  "move_by"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "title": "Trailing stop",
            "default": null
          },
          "on_leg_stop": {
            "enum": [
              "nothing",
              "move_others_to_cost",
              "exit_all"
            ],
            "x-labels": {
              "nothing": "Do nothing",
              "move_others_to_cost": "Move the other legs' stops to cost",
              "exit_all": "Exit every leg"
            },
            "title": "When a leg stops",
            "description": "move_others_to_cost: the other legs' stops move to their entry price.",
            "default": "nothing"
          },
          "at_broker": {
            "type": "boolean",
            "title": "Also place stops at the broker",
            "description": "Live accounts only: a broker-held stop protects the leg if the server is unreachable.",
            "default": false
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Per-leg protection",
        "description": "A stop, target or trailing stop on every leg.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "combined@1",
      "name": "combined",
      "major": 1,
      "slots": [
        "combined"
      ],
      "label": "Combined protection",
      "explain": "A stop and target on the whole trade; when hit, every leg closes, hedges last.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "combined@1"
          },
          "scope": {
            "enum": [
              "account",
              "deployment"
            ],
            "x-labels": {
              "account": "Each account",
              "deployment": "The whole deployment"
            },
            "title": "Applies to",
            "description": "account: each account's own P&L; deployment: the sum over every account.",
            "default": "account"
          },
          "basis": {
            "enum": [
              "rupees",
              "pct_premium",
              "pct_margin"
            ],
            "x-labels": {
              "rupees": "Rupees",
              "pct_premium": "% of premium",
              "pct_margin": "% of margin"
            },
            "title": "Measured in",
            "default": "rupees"
          },
          "stop_loss": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "required": [
                      "rupees"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "pct_premium": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent of entry premium"
                      }
                    },
                    "required": [
                      "pct_premium"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "pct_margin": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent of margin",
                        "x-phase": "b"
                      }
                    },
                    "required": [
                      "pct_margin"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Combined stop",
            "default": null
          },
          "target": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "required": [
                      "rupees"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "pct_premium": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent of entry premium"
                      }
                    },
                    "required": [
                      "pct_premium"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "pct_margin": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent of margin",
                        "x-phase": "b"
                      }
                    },
                    "required": [
                      "pct_margin"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Combined target",
            "default": null
          },
          "breakeven": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "at_rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "At a profit of (rupees)"
                      }
                    },
                    "required": [
                      "at_rupees"
                    ],
                    "additionalProperties": false
                  },
                  {
                    "type": "object",
                    "properties": {
                      "at_pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "At a profit of (percent)"
                      }
                    },
                    "required": [
                      "at_pct"
                    ],
                    "additionalProperties": false
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Move the stop to cost",
            "default": null
          },
          "trail": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "arm_at": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000000,
                    "title": "Start trailing at"
                  },
                  "giveback": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000000,
                    "title": "Give back at most"
                  },
                  "step": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000000,
                    "title": "Raise every"
                  }
                },
                "required": [
                  "arm_at",
                  "giveback",
                  "step"
                ],
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "title": "Trailing profit",
            "default": null
          },
          "lock_ladder": {
            "type": "array",
            "items": {
              "oneOf": [
                {
                  "type": "object",
                  "properties": {
                    "at": {
                      "type": "number",
                      "exclusiveMinimum": 0,
                      "maximum": 100000000,
                      "title": "At a profit of"
                    },
                    "lock": {
                      "type": "number",
                      "exclusiveMinimum": 0,
                      "maximum": 100000000,
                      "title": "Lock"
                    }
                  },
                  "required": [
                    "at",
                    "lock"
                  ],
                  "additionalProperties": false
                },
                {
                  "type": "object",
                  "properties": {
                    "at_pct": {
                      "type": "number",
                      "exclusiveMinimum": 0,
                      "maximum": 1000,
                      "title": "At a profit of (percent)"
                    },
                    "lock_pct": {
                      "type": "number",
                      "exclusiveMinimum": 0,
                      "maximum": 1000,
                      "title": "Lock (percent)"
                    }
                  },
                  "required": [
                    "at_pct",
                    "lock_pct"
                  ],
                  "additionalProperties": false
                }
              ]
            },
            "maxItems": 10,
            "title": "Profit lock ladder",
            "default": []
          },
          "deployment": {
            "anyOf": [
              {
                "type": "object",
                "properties": {
                  "stop_loss": {
                    "anyOf": [
                      {
                        "oneOf": [
                          {
                            "type": "object",
                            "properties": {
                              "rupees": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 100000000,
                                "title": "Rupees"
                              }
                            },
                            "required": [
                              "rupees"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "object",
                            "properties": {
                              "pct_premium": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 1000,
                                "title": "Percent of entry premium"
                              }
                            },
                            "required": [
                              "pct_premium"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "object",
                            "properties": {
                              "pct_margin": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 1000,
                                "title": "Percent of margin",
                                "x-phase": "b"
                              }
                            },
                            "required": [
                              "pct_margin"
                            ],
                            "additionalProperties": false
                          }
                        ]
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Combined stop",
                    "default": null
                  },
                  "target": {
                    "anyOf": [
                      {
                        "oneOf": [
                          {
                            "type": "object",
                            "properties": {
                              "rupees": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 100000000,
                                "title": "Rupees"
                              }
                            },
                            "required": [
                              "rupees"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "object",
                            "properties": {
                              "pct_premium": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 1000,
                                "title": "Percent of entry premium"
                              }
                            },
                            "required": [
                              "pct_premium"
                            ],
                            "additionalProperties": false
                          },
                          {
                            "type": "object",
                            "properties": {
                              "pct_margin": {
                                "type": "number",
                                "exclusiveMinimum": 0,
                                "maximum": 1000,
                                "title": "Percent of margin",
                                "x-phase": "b"
                              }
                            },
                            "required": [
                              "pct_margin"
                            ],
                            "additionalProperties": false
                          }
                        ]
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Combined target",
                    "default": null
                  }
                },
                "additionalProperties": false
              },
              {
                "type": "null"
              }
            ],
            "title": "Across all accounts",
            "description": "A second stop or target on the sum over every account of the deployment.",
            "default": null
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Combined protection",
        "description": "A stop and target on the whole trade; when hit, every leg closes, hedges last.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "limits@1",
      "name": "limits",
      "major": 1,
      "slots": [
        "limits"
      ],
      "label": "Daily limits",
      "explain": "Your own limits per day: losses, profits, trades and orders.",
      "group": "Built in",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "limits@1"
          },
          "max_loss_per_day": {
            "anyOf": [
              {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100000000
              },
              {
                "type": "null"
              }
            ],
            "title": "Most loss a day",
            "default": null
          },
          "max_profit_per_day": {
            "anyOf": [
              {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100000000
              },
              {
                "type": "null"
              }
            ],
            "title": "Stop for the day at a profit of",
            "default": null
          },
          "max_loss_per_week": {
            "anyOf": [
              {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100000000
              },
              {
                "type": "null"
              }
            ],
            "title": "Most loss a week",
            "default": null,
            "x-phase": "b"
          },
          "max_cycles_per_day": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "title": "Most trades a day",
            "default": 3
          },
          "max_open_trades": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "Most trades open at once",
            "description": "Each entry (a time cycle, the conditions, an alert) opens its own trade with its own stops; the day's limits count them all. 1: one trade at a time.",
            "default": 1
          },
          "max_orders_per_day": {
            "type": "integer",
            "minimum": 1,
            "maximum": 200,
            "title": "Most orders a day",
            "default": 50
          },
          "exit_on_limit": {
            "type": "boolean",
            "title": "Exit when a daily limit is hit",
            "default": true
          },
          "on_breaker": {
            "enum": [
              "nothing",
              "close_open_trade"
            ],
            "x-labels": {
              "nothing": "Do nothing",
              "close_open_trade": "Close the open trade"
            },
            "title": "If the circuit breaker trips",
            "description": "close_open_trade: also close the open trade, mains first, hedges last.",
            "default": "nothing"
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Daily limits",
        "description": "Your own limits per day: losses, profits, trades and orders.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "close_leg@1",
      "name": "close_leg",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Close a leg",
      "explain": "close one leg, for example the tested side",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "close_leg@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "leg": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^[a-z][a-z0-9_]{0,15}$"
              },
              {
                "type": "null"
              }
            ],
            "title": "Leg",
            "description": "The leg to act on; or pick `target` instead.",
            "default": null
          },
          "target": {
            "anyOf": [
              {
                "enum": [
                  "tested",
                  "sold"
                ],
                "x-labels": {
                  "tested": "The leg being tested",
                  "sold": "All sold legs"
                }
              },
              {
                "type": "null"
              }
            ],
            "title": "Which legs",
            "description": "tested: the sold leg whose price rose most against its entry; sold: every sold leg. Give this or `leg`.",
            "default": null
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 2
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Close a leg",
        "description": "close one leg, for example the tested side",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "reduce@1",
      "name": "reduce",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Reduce",
      "explain": "close a share of every leg, in whole lots",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "reduce@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "pct": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "title": "Percent to close"
          },
          "target": {
            "anyOf": [
              {
                "enum": [
                  "tested",
                  "sold"
                ],
                "x-labels": {
                  "tested": "The leg being tested",
                  "sold": "All sold legs"
                }
              },
              {
                "type": "null"
              }
            ],
            "title": "Which legs",
            "description": "Only these legs: tested (the sold leg whose price rose most) or sold (every sold leg). Absent: every leg.",
            "default": null
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 2
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "pct"
        ],
        "title": "Reduce",
        "description": "close a share of every leg, in whole lots",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "roll_strike@1",
      "name": "roll_strike",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Roll a strike",
      "explain": "close a leg and open the same option at a new strike",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "roll_strike@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "leg": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^[a-z][a-z0-9_]{0,15}$"
              },
              {
                "type": "null"
              }
            ],
            "title": "Leg",
            "description": "The leg to act on; or pick `target` instead.",
            "default": null
          },
          "target": {
            "anyOf": [
              {
                "enum": [
                  "tested",
                  "sold"
                ],
                "x-labels": {
                  "tested": "The leg being tested",
                  "sold": "All sold legs"
                }
              },
              {
                "type": "null"
              }
            ],
            "title": "Which legs",
            "description": "tested: the sold leg whose price rose most against its entry; sold: every sold leg (each to the new strike). Give this or `leg`.",
            "default": null
          },
          "strike": {
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "atm"
                  },
                  "offset": {
                    "type": "integer",
                    "minimum": -20,
                    "maximum": 20,
                    "title": "Strikes from ATM",
                    "description": "Positive is out of the money for calls and puts alike.",
                    "default": 0
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by"
                ],
                "title": "At the money"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "pct_otm"
                  },
                  "pct": {
                    "type": "number",
                    "minimum": 0,
                    "maximum": 20,
                    "title": "Percent OTM"
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "pct"
                ],
                "title": "Percent out of the money"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "delta"
                  },
                  "delta": {
                    "type": "number",
                    "minimum": 0.05,
                    "maximum": 0.5,
                    "title": "Delta",
                    "description": "Absolute delta; the sign follows the option type."
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "delta"
                ],
                "title": "Delta"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "premium"
                  },
                  "premium": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 100000,
                    "title": "Premium"
                  },
                  "mode": {
                    "enum": [
                      "nearest",
                      "at_least",
                      "at_most"
                    ],
                    "x-labels": {
                      "nearest": "Nearest to",
                      "at_least": "At least",
                      "at_most": "At most"
                    },
                    "title": "Match",
                    "default": "nearest"
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "premium"
                ],
                "title": "Premium"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "expected_move"
                  },
                  "k": {
                    "type": "number",
                    "minimum": 0.5,
                    "maximum": 3,
                    "title": "Times the expected move"
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "k"
                ],
                "title": "Expected move",
                "x-phase": "b"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "relative"
                  },
                  "to": {
                    "type": "string",
                    "pattern": "^[a-z][a-z0-9_]{0,15}$",
                    "title": "Relative to leg"
                  },
                  "width_strikes": {
                    "anyOf": [
                      {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 50
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Width in strikes",
                    "default": null
                  },
                  "width_points": {
                    "anyOf": [
                      {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "title": "Width in points",
                    "default": null
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "to"
                ],
                "title": "Relative to another leg"
              },
              {
                "type": "object",
                "properties": {
                  "by": {
                    "const": "strike"
                  },
                  "strike": {
                    "type": "number",
                    "exclusiveMinimum": 0,
                    "maximum": 10000000,
                    "title": "Strike"
                  }
                },
                "additionalProperties": false,
                "required": [
                  "by",
                  "strike"
                ],
                "title": "Exact strike"
              }
            ],
            "title": "New strike",
            "description": "A relative strike counts from the rolled leg itself (`to`: its id)."
          },
          "move_hedge": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^[a-z][a-z0-9_]{0,15}$"
              },
              {
                "type": "null"
              }
            ],
            "title": "Also move hedge",
            "default": null
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 2
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "strike"
        ],
        "title": "Roll a strike",
        "description": "close a leg and open the same option at a new strike",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "add_hedge@1",
      "name": "add_hedge",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Add a hedge",
      "explain": "buy a wing beyond a sold leg",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": false,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "add_hedge@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "leg": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]{0,15}$",
            "title": "Sold leg"
          },
          "width_strikes": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 50
              },
              {
                "type": "null"
              }
            ],
            "title": "Width in strikes",
            "default": null
          },
          "delta": {
            "anyOf": [
              {
                "type": "number",
                "minimum": 0.05,
                "maximum": 0.5
              },
              {
                "type": "null"
              }
            ],
            "title": "Delta",
            "default": null
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 1
          }
        },
        "additionalProperties": false,
        "required": [
          "type",
          "leg"
        ],
        "title": "Add a hedge",
        "description": "buy a wing beyond a sold leg",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "widen@1",
      "name": "widen",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Widen the strikes",
      "explain": "move every sold option and its hedge further out of the money",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "widen@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "strikes": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "title": "Strikes further out",
            "description": "Each sold leg and its hedge move this many strikes away from the money.",
            "default": 2
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 1
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Widen the strikes",
        "description": "move every sold option and its hedge further out of the money",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "delta_hedge@1",
      "name": "delta_hedge",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Delta hedge",
      "explain": "trade futures to keep the net delta inside a band",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "delta_hedge@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "description": "A condition in the trigger expression grammar, e.g. rsi(14, '5m') > 70. Give this or `trigger`.",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "with": {
            "enum": [
              "future",
              "roll_short"
            ],
            "x-labels": {
              "future": "With futures",
              "roll_short": "By rolling the sold leg"
            },
            "title": "Hedge with",
            "default": "future"
          },
          "target": {
            "type": "number",
            "minimum": 0,
            "maximum": 5,
            "title": "Bring net delta to",
            "default": 0.1
          },
          "min_interval_s": {
            "type": "integer",
            "minimum": 300,
            "maximum": 86400,
            "title": "At least seconds between hedges",
            "default": 300
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 3
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Delta hedge",
        "description": "trade futures to keep the net delta inside a band",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "roll_expiry@1",
      "name": "roll_expiry",
      "major": 1,
      "slots": [
        "adjustment"
      ],
      "label": "Roll to the next expiry",
      "explain": "close every leg and re-open the same structure in the next expiry",
      "group": "Adjustments",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "roll_expiry@1"
          },
          "when": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 2048,
                "x-expression": true
              },
              {
                "type": "null"
              }
            ],
            "title": "Condition",
            "default": null
          },
          "trigger": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Loss reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "loss_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Loss reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit"
                      },
                      "rupees": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 100000000,
                        "title": "Rupees"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "rupees"
                    ],
                    "title": "Profit reaches (rupees)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "profit_pct"
                      },
                      "pct": {
                        "type": "number",
                        "exclusiveMinimum": 0,
                        "maximum": 1000,
                        "title": "Percent"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "pct"
                    ],
                    "title": "Profit reaches (percent of premium)"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "elapsed"
                      },
                      "minutes": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 43200,
                        "title": "Minutes"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "minutes"
                    ],
                    "title": "Time since entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "premium_ratio"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "ratio": {
                        "type": "number",
                        "minimum": 0.1,
                        "maximum": 100,
                        "title": "Times the entry price",
                        "description": "2 is a sold leg whose premium doubled."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "ratio"
                    ],
                    "title": "A leg's price reaches a multiple of its entry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "condition"
                      },
                      "when": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 2048,
                        "x-expression": true,
                        "title": "Condition"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "when"
                    ],
                    "title": "Indicator or condition"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "expiry"
                      },
                      "dte_at_most": {
                        "type": "integer",
                        "minimum": 0,
                        "maximum": 30,
                        "title": "At most days to expiry",
                        "default": 0
                      },
                      "at": {
                        "anyOf": [
                          {
                            "type": "string",
                            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "title": "From (time)",
                        "default": null
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on"
                    ],
                    "title": "Days to expiry"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "delta_band"
                      },
                      "band": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 10,
                        "title": "Band (lots of delta)"
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "band"
                    ],
                    "title": "Net delta outside a band"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "on": {
                        "const": "leg_delta"
                      },
                      "leg": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,15}$",
                        "title": "Leg"
                      },
                      "delta": {
                        "type": "number",
                        "minimum": 0.01,
                        "maximum": 1,
                        "title": "Delta (absolute)",
                        "description": "0.30: the leg's delta reached ±0.30."
                      }
                    },
                    "additionalProperties": false,
                    "required": [
                      "on",
                      "leg",
                      "delta"
                    ],
                    "title": "A leg's delta reaches",
                    "x-phase": "c"
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Trigger",
            "description": "When the adjustment fires: the moment this turns true, not again while it stays true.",
            "default": null
          },
          "at": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$"
              },
              {
                "type": "null"
              }
            ],
            "title": "At",
            "default": null
          },
          "dte_at_most": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0,
                "maximum": 30
              },
              {
                "type": "null"
              }
            ],
            "title": "When days to expiry is at most",
            "default": null
          },
          "max_per_cycle": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10,
            "title": "At most per cycle",
            "default": 1
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Roll to the next expiry",
        "description": "close every leg and re-open the same structure in the next expiry",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "app@1",
      "name": "app",
      "major": 1,
      "slots": [
        "notifier"
      ],
      "label": "App",
      "explain": "Push notifications and the inbox in the app.",
      "group": "Channels",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "app@1"
          },
          "events": {
            "type": "array",
            "items": {
              "enum": [
                "entered",
                "exited",
                "stops",
                "adjusted",
                "skipped",
                "needs_attention",
                "daily_summary"
              ],
              "x-labels": {
                "entered": "Entered a trade",
                "exited": "Exited a trade",
                "stops": "Stops moved or hit",
                "adjusted": "Adjusted a trade",
                "skipped": "Skipped an entry",
                "needs_attention": "Needs your attention",
                "daily_summary": "Daily summary"
              }
            },
            "maxItems": 7,
            "uniqueItems": true,
            "title": "Events",
            "description": "Urgent events (failures, circuit breaker, kill switch) are always delivered.",
            "default": [
              "entered",
              "exited",
              "stops",
              "needs_attention",
              "daily_summary"
            ]
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "App",
        "description": "Push notifications and the inbox in the app.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "telegram@1",
      "name": "telegram",
      "major": 1,
      "slots": [
        "notifier"
      ],
      "label": "Telegram",
      "explain": "Messages to your own Telegram, once it is linked in Settings.",
      "group": "Channels",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "telegram@1"
          },
          "events": {
            "type": "array",
            "items": {
              "enum": [
                "entered",
                "exited",
                "stops",
                "adjusted",
                "skipped",
                "needs_attention",
                "daily_summary"
              ],
              "x-labels": {
                "entered": "Entered a trade",
                "exited": "Exited a trade",
                "stops": "Stops moved or hit",
                "adjusted": "Adjusted a trade",
                "skipped": "Skipped an entry",
                "needs_attention": "Needs your attention",
                "daily_summary": "Daily summary"
              }
            },
            "maxItems": 7,
            "uniqueItems": true,
            "title": "Events",
            "description": "Urgent events (failures, circuit breaker, kill switch) are always delivered.",
            "default": [
              "entered",
              "exited",
              "stops",
              "needs_attention",
              "daily_summary"
            ]
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Telegram",
        "description": "Messages to your own Telegram, once it is linked in Settings.",
        "x-status": "preview",
        "x-phase": "a"
      }
    },
    {
      "id": "webhook@1",
      "name": "webhook",
      "major": 1,
      "slots": [
        "notifier"
      ],
      "label": "Webhook",
      "explain": "Signed HTTPS deliveries to a webhook chosen at deploy (no URL in the document).",
      "group": "Channels",
      "status": "preview",
      "phase": "a",
      "deployable": true,
      "deprecated": false,
      "sunset_after": null,
      "needs": [],
      "bare": true,
      "params_schema": {
        "type": "object",
        "properties": {
          "type": {
            "const": "webhook@1"
          },
          "events": {
            "type": "array",
            "items": {
              "enum": [
                "entered",
                "exited",
                "stops",
                "adjusted",
                "skipped",
                "needs_attention",
                "daily_summary"
              ],
              "x-labels": {
                "entered": "Entered a trade",
                "exited": "Exited a trade",
                "stops": "Stops moved or hit",
                "adjusted": "Adjusted a trade",
                "skipped": "Skipped an entry",
                "needs_attention": "Needs your attention",
                "daily_summary": "Daily summary"
              }
            },
            "maxItems": 7,
            "uniqueItems": true,
            "title": "Events",
            "description": "Urgent events (failures, circuit breaker, kill switch) are always delivered.",
            "default": [
              "entered",
              "exited",
              "stops",
              "needs_attention",
              "daily_summary"
            ]
          }
        },
        "additionalProperties": false,
        "required": [
          "type"
        ],
        "title": "Webhook",
        "description": "Signed HTTPS deliveries to a webhook chosen at deploy (no URL in the document).",
        "x-status": "preview",
        "x-phase": "a"
      }
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/capabilities

Scope: `read`

Strategy capabilities

```bash
curl "https://api.cirrus.trade/v2/strategies/capabilities" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200):** no body.

**Example: Enums, limits, catalogue and usage (200)**

```json
{
  "status": "success",
  "data": {
    "schema_version": 1,
    "schema_versions": [
      1
    ],
    "phase": "a",
    "slots": [
      {
        "slot": "entry_source",
        "label": "Entry",
        "tag_key": "type",
        "components": [
          "conditions@1",
          "manual@1",
          "api@1",
          "webhook@1"
        ]
      },
      {
        "slot": "exit_source",
        "label": "Exit",
        "tag_key": "type",
        "components": [
          "conditions@1",
          "manual@1",
          "api@1",
          "webhook@1"
        ]
      },
      {
        "slot": "legs",
        "label": "Legs",
        "tag_key": "selector",
        "components": [
          "chain@1",
          "fixed@1",
          "from_signal@1"
        ]
      },
      {
        "slot": "sizing",
        "label": "Sizing",
        "tag_key": "type",
        "components": [
          "fixed_lots@1",
          "max_premium@1",
          "risk@1",
          "capital_pct@1",
          "from_signal@1"
        ]
      },
      {
        "slot": "execution",
        "label": "Execution",
        "tag_key": "type",
        "components": [
          "protected_limit@1"
        ]
      },
      {
        "slot": "per_leg",
        "label": "Per-leg protection",
        "tag_key": "type",
        "components": [
          "per_leg@1"
        ]
      },
      {
        "slot": "combined",
        "label": "Combined protection",
        "tag_key": "type",
        "components": [
          "combined@1"
        ]
      },
      {
        "slot": "limits",
        "label": "Daily limits",
        "tag_key": "type",
        "components": [
          "limits@1"
        ]
      },
      {
        "slot": "adjustment",
        "label": "Adjustment",
        "tag_key": "type",
        "components": [
          "close_leg@1",
          "reduce@1",
          "roll_strike@1",
          "add_hedge@1",
          "widen@1",
          "delta_hedge@1",
          "roll_expiry@1"
        ]
      },
      {
        "slot": "notifier",
        "label": "Notifications",
        "tag_key": "type",
        "components": [
          "app@1",
          "telegram@1",
          "webhook@1"
        ]
      }
    ],
    "enums": {
      "instruments": [
        "option",
        "future",
        "stock"
      ],
      "sides": [
        "buy",
        "sell"
      ],
      "option_types": [
        "call",
        "put"
      ],
      "expiry_kinds": [
        "weekly",
        "monthly"
      ],
      "premium_modes": [
        "nearest",
        "at_least",
        "at_most"
      ],
      "strike_methods": [
        {
          "by": "atm",
          "label": "At the money",
          "phase": "a",
          "deployable": true
        },
        {
          "by": "pct_otm",
          "label": "Percent out of the money",
          "phase": "a",
          "deployable": true
        },
        {
          "by": "delta",
          "label": "Delta",
          "phase": "a",
          "deployable": true
        },
        {
          "by": "premium",
          "label": "Premium",
          "phase": "a",
          "deployable": true
        },
        {
          "by": "expected_move",
          "label": "Expected move",
          "phase": "b",
          "deployable": false
        },
        {
          "by": "relative",
          "label": "Relative to another leg",
          "phase": "a",
          "deployable": true
        },
        {
          "by": "strike",
          "label": "Exact strike",
          "phase": "a",
          "deployable": true
        }
      ],
      "exchanges": [
        "NFO",
        "BFO",
        "NSE",
        "BSE",
        "MCX"
      ],
      "deployable_exchanges": [
        "NFO",
        "BFO",
        "NSE",
        "BSE"
      ],
      "hedge_with": [
        "future",
        "roll_short"
      ],
      "references": [
        "future",
        "spot",
        "synthetic"
      ],
      "days": [
        "mon",
        "tue",
        "wed",
        "thu",
        "fri"
      ],
      "expiry_day": [
        "normal",
        "skip",
        "only"
      ],
      "evaluate_on": [
        "close",
        "tick"
      ],
      "reentry_on": [
        "stop_loss",
        "target",
        "exit_signal",
        "square_off"
      ],
      "cost_models": [
        "auto",
        "options_india",
        "futures_india",
        "equity_intraday_india"
      ],
      "orders": [
        "auto",
        "hedge_first",
        "simultaneous",
        "stagger"
      ],
      "on_incomplete": [
        "safest",
        "keep_and_manage",
        "close_opened"
      ],
      "on_leg_stop": [
        "nothing",
        "move_others_to_cost",
        "exit_all"
      ],
      "combined_scopes": [
        "account",
        "deployment"
      ],
      "bases": [
        "rupees",
        "pct_premium",
        "pct_margin"
      ],
      "on_breaker": [
        "nothing",
        "close_open_trade"
      ],
      "notify_events": [
        "entered",
        "exited",
        "stops",
        "adjusted",
        "skipped",
        "needs_attention",
        "daily_summary"
      ]
    },
    "limits": {
      "max_document_bytes": 65536,
      "max_depth": 20,
      "max_entry_legs": 6,
      "max_legs": 8,
      "max_expr_bytes": 2048,
      "max_indicator_calls": 20,
      "max_timeframes": 3,
      "max_adjustments": 10,
      "max_watch_symbols": 4,
      "max_notifiers": 3,
      "max_name_chars": 60,
      "max_description_chars": 500,
      "max_tags": 10,
      "max_accounts_per_deployment": 4,
      "max_active_deployments": 3,
      "max_cycles_per_day": 20,
      "max_orders_per_day": 200,
      "min_reentry_delay_s": 60,
      "latest_mis_square_off": "15:20",
      "session_open": "09:15",
      "session_close": "15:30",
      "min_multiplier": 0.1,
      "max_multiplier": 10
    },
    "segments": [
      {
        "segment": "NSE",
        "exchanges": [
          "NSE",
          "NFO",
          "NSE_INDEX"
        ],
        "open": "09:15",
        "close": "15:30",
        "phase": "a",
        "available": true,
        "latest_square_off": "15:20",
        "default_square_off": "15:15"
      },
      {
        "segment": "BSE",
        "exchanges": [
          "BSE",
          "BFO",
          "BSE_INDEX"
        ],
        "open": "09:15",
        "close": "15:30",
        "phase": "a",
        "available": true,
        "latest_square_off": "15:20",
        "default_square_off": "15:15"
      },
      {
        "segment": "MCX",
        "exchanges": [
          "MCX",
          "MCX_INDEX"
        ],
        "open": "09:00",
        "close": "23:30",
        "phase": "d",
        "available": false,
        "latest_square_off": "23:00",
        "default_square_off": "22:45"
      },
      {
        "segment": "CDS",
        "exchanges": [
          "CDS",
          "BCD"
        ],
        "open": "09:00",
        "close": "17:00",
        "phase": "d",
        "available": false,
        "latest_square_off": null,
        "default_square_off": null
      }
    ],
    "timeframes": [
      "1m",
      "3m",
      "5m",
      "10m",
      "15m",
      "30m",
      "60m"
    ],
    "indicators": [
      {
        "name": "sma",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Simple moving average",
        "available": false
      },
      {
        "name": "ema",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Exponential moving average",
        "available": false
      },
      {
        "name": "rsi",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "RSI",
        "available": false
      },
      {
        "name": "atr",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Average true range",
        "available": false
      },
      {
        "name": "bb_upper",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger upper band",
        "available": false
      },
      {
        "name": "bb_middle",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger middle band",
        "available": false
      },
      {
        "name": "bb_mid",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger middle band",
        "available": false
      },
      {
        "name": "bb_lower",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger lower band",
        "available": false
      },
      {
        "name": "bb_percent",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger %B",
        "available": false
      },
      {
        "name": "bb_width",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bollinger width",
        "available": false
      },
      {
        "name": "macd",
        "args": "f, s, sig, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "MACD",
        "available": false
      },
      {
        "name": "macd_signal",
        "args": "f, s, sig, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "MACD signal",
        "available": false
      },
      {
        "name": "macd_hist",
        "args": "f, s, sig, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "MACD histogram",
        "available": false
      },
      {
        "name": "stoch_k",
        "args": "k, sk, sd, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Stochastic %K",
        "available": false
      },
      {
        "name": "stoch_d",
        "args": "k, sk, sd, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Stochastic %D",
        "available": false
      },
      {
        "name": "vwap",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "VWAP",
        "available": false
      },
      {
        "name": "supertrend",
        "args": "p, m, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Supertrend",
        "available": false
      },
      {
        "name": "supertrend_dir",
        "args": "p, m, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Supertrend direction",
        "available": false
      },
      {
        "name": "bar_open",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bar open",
        "available": false
      },
      {
        "name": "bar_high",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bar high",
        "available": false
      },
      {
        "name": "bar_low",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bar low",
        "available": false
      },
      {
        "name": "bar_close",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bar close",
        "available": false
      },
      {
        "name": "bar_volume",
        "args": "iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bar volume",
        "available": false
      },
      {
        "name": "aroon_up",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Aroon up",
        "available": false
      },
      {
        "name": "aroon_down",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Aroon down",
        "available": false
      },
      {
        "name": "aroon_osc",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Aroon oscillator",
        "available": false
      },
      {
        "name": "cci",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Commodity channel index",
        "available": false
      },
      {
        "name": "willr",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Williams %R",
        "available": false
      },
      {
        "name": "roc",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Rate of change",
        "available": false
      },
      {
        "name": "mom",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Momentum",
        "available": false
      },
      {
        "name": "mfi",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Money flow index",
        "available": false
      },
      {
        "name": "keltner_middle",
        "args": "p, k, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Keltner middle",
        "available": false
      },
      {
        "name": "donchian_middle",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Donchian middle",
        "available": false
      },
      {
        "name": "wma",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Weighted moving average",
        "available": false
      },
      {
        "name": "dema",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Double exponential moving average",
        "available": false
      },
      {
        "name": "tema",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Triple exponential moving average",
        "available": false
      },
      {
        "name": "kama",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Kaufman adaptive moving average",
        "available": false
      },
      {
        "name": "psar",
        "args": "step, max, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Parabolic SAR",
        "available": false
      },
      {
        "name": "stochrsi_k",
        "args": "r, k, d, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Stochastic RSI %K",
        "available": false
      },
      {
        "name": "stochrsi_d",
        "args": "r, k, d, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Stochastic RSI %D",
        "available": false
      },
      {
        "name": "stddev",
        "args": "p, iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Standard deviation",
        "available": false
      },
      {
        "name": "bullish_pattern",
        "args": "'name', iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bullish candle pattern",
        "available": false
      },
      {
        "name": "bearish_pattern",
        "args": "'name', iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Bearish candle pattern",
        "available": false
      },
      {
        "name": "pattern",
        "args": "'name', iv[, sym]",
        "kind": "streaming",
        "phase": "a",
        "label": "Candle pattern (either direction)",
        "available": false
      },
      {
        "name": "adx",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "ADX",
        "available": false
      },
      {
        "name": "plus_di",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "+DI",
        "available": false
      },
      {
        "name": "minus_di",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "-DI",
        "available": false
      },
      {
        "name": "hull",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Hull moving average",
        "available": false
      },
      {
        "name": "chop",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Choppiness index",
        "available": false
      },
      {
        "name": "donchian_upper",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Donchian upper",
        "available": false
      },
      {
        "name": "donchian_lower",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Donchian lower",
        "available": false
      },
      {
        "name": "keltner_upper",
        "args": "p, k, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Keltner upper",
        "available": false
      },
      {
        "name": "keltner_lower",
        "args": "p, k, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Keltner lower",
        "available": false
      },
      {
        "name": "ichimoku_tenkan",
        "args": "iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Ichimoku tenkan",
        "available": false
      },
      {
        "name": "ichimoku_kijun",
        "args": "iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Ichimoku kijun",
        "available": false
      },
      {
        "name": "ichimoku_span_a",
        "args": "iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Ichimoku span A",
        "available": false
      },
      {
        "name": "ichimoku_span_b",
        "args": "iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Ichimoku span B",
        "available": false
      },
      {
        "name": "cmf",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Chaikin money flow",
        "available": false
      },
      {
        "name": "obv",
        "args": "iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "On-balance volume",
        "available": false
      },
      {
        "name": "vortex_plus",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Vortex +",
        "available": false
      },
      {
        "name": "vortex_minus",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Vortex -",
        "available": false
      },
      {
        "name": "fisher",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Fisher transform",
        "available": false
      },
      {
        "name": "tsi",
        "args": "r, s, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "True strength index",
        "available": false
      },
      {
        "name": "rvol",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Relative volume",
        "available": false
      },
      {
        "name": "hv",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Historical volatility",
        "available": false
      },
      {
        "name": "yz_vol",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Yang-Zhang volatility",
        "available": false
      },
      {
        "name": "regime",
        "args": "p, iv[, sym]",
        "kind": "windowed",
        "phase": "b",
        "label": "Trend regime (1 trend, 0 range)",
        "available": false
      },
      {
        "name": "pivot",
        "args": "method[, sym]",
        "kind": "session",
        "phase": "b",
        "label": "Pivot",
        "available": false
      },
      {
        "name": "r1",
        "args": "method[, sym]",
        "kind": "session",
        "phase": "b",
        "label": "Resistance 1",
        "available": false
      },
      {
        "name": "r2",
        "args": "method[, sym]",
        "kind": "session",
        "phase": "b",
        "label": "Resistance 2",
        "available": false
      },
      {
        "name": "s1",
        "args": "method[, sym]",
        "kind": "session",
        "phase": "b",
        "label": "Support 1",
        "available": false
      },
      {
        "name": "s2",
        "args": "method[, sym]",
        "kind": "session",
        "phase": "b",
        "label": "Support 2",
        "available": false
      },
      {
        "name": "prev_high",
        "args": "[sym]",
        "kind": "session",
        "phase": "b",
        "label": "Previous day's high",
        "available": false
      },
      {
        "name": "prev_low",
        "args": "[sym]",
        "kind": "session",
        "phase": "b",
        "label": "Previous day's low",
        "available": false
      },
      {
        "name": "prev_close",
        "args": "[sym]",
        "kind": "session",
        "phase": "b",
        "label": "Previous day's close",
        "available": false
      },
      {
        "name": "day_open",
        "args": "[sym]",
        "kind": "session",
        "phase": "b",
        "label": "Today's open",
        "available": false
      },
      {
        "name": "day_high",
        "args": "[x]",
        "kind": "session",
        "phase": "b",
        "label": "Today's high",
        "available": false
      },
      {
        "name": "day_low",
        "args": "[x]",
        "kind": "session",
        "phase": "b",
        "label": "Today's low",
        "available": false
      },
      {
        "name": "orb_high",
        "args": "min[, sym]",
        "kind": "opening_range",
        "phase": "b",
        "label": "Opening range high",
        "available": false
      },
      {
        "name": "orb_low",
        "args": "min[, sym]",
        "kind": "opening_range",
        "phase": "b",
        "label": "Opening range low",
        "available": false
      },
      {
        "name": "orb_ready",
        "args": "min[, sym]",
        "kind": "opening_range",
        "phase": "b",
        "label": "Opening range complete",
        "available": false
      },
      {
        "name": "ltp",
        "args": "sym",
        "kind": "strategy",
        "phase": "a",
        "label": "Last price of a watched symbol",
        "available": false
      },
      {
        "name": "atm_iv",
        "args": "sym, expiry",
        "kind": "strategy",
        "phase": "b",
        "label": "ATM implied volatility",
        "available": false
      },
      {
        "name": "iv_rank",
        "args": "sym, days",
        "kind": "strategy",
        "phase": "b",
        "label": "IV rank",
        "available": false
      },
      {
        "name": "iv_pct",
        "args": "sym, days",
        "kind": "strategy",
        "phase": "b",
        "label": "IV percentile",
        "available": false
      },
      {
        "name": "expected_move",
        "args": "sym, expiry",
        "kind": "strategy",
        "phase": "b",
        "label": "Expected move",
        "available": false
      },
      {
        "name": "dte",
        "args": "leg",
        "kind": "strategy",
        "phase": "a",
        "label": "Days to expiry",
        "available": false
      },
      {
        "name": "leg",
        "args": "id",
        "kind": "strategy",
        "phase": "b",
        "label": "An open leg (ltp, pnl, delta, iv, premium_ratio)",
        "available": false
      },
      {
        "name": "minutes",
        "args": "'HH:MM'",
        "kind": "strategy",
        "phase": "a",
        "label": "Minutes since midnight of a time",
        "available": false
      }
    ],
    "variables": [
      {
        "name": "cycle.pnl",
        "phase": "a",
        "available": false
      },
      {
        "name": "cycle.minutes",
        "phase": "a",
        "available": true
      },
      {
        "name": "cycle.pnl_pct",
        "phase": "a",
        "available": false
      },
      {
        "name": "day.pnl",
        "phase": "a",
        "available": false
      },
      {
        "name": "day.cycles",
        "phase": "a",
        "available": false
      },
      {
        "name": "week.pnl",
        "phase": "b",
        "available": false
      },
      {
        "name": "net.delta",
        "phase": "b",
        "available": false
      },
      {
        "name": "net.theta",
        "phase": "b",
        "available": false
      },
      {
        "name": "net.vega",
        "phase": "b",
        "available": false
      },
      {
        "name": "is_expiry_day",
        "phase": "a",
        "available": false
      },
      {
        "name": "minutes_to_close",
        "phase": "a",
        "available": false
      }
    ],
    "adjustment_triggers": [
      {
        "on": "loss",
        "label": "Loss reaches (rupees)",
        "fields": [
          {
            "key": "rupees",
            "label": "Rupees",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "loss_pct",
        "label": "Loss reaches (percent of premium)",
        "fields": [
          {
            "key": "pct",
            "label": "Percent",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "profit",
        "label": "Profit reaches (rupees)",
        "fields": [
          {
            "key": "rupees",
            "label": "Rupees",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "profit_pct",
        "label": "Profit reaches (percent of premium)",
        "fields": [
          {
            "key": "pct",
            "label": "Percent",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "elapsed",
        "label": "Time since entry",
        "fields": [
          {
            "key": "minutes",
            "label": "Minutes",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "premium_ratio",
        "label": "A leg's price reaches a multiple of its entry",
        "fields": [
          {
            "key": "leg",
            "label": "Leg",
            "help": "",
            "required": true,
            "default": null
          },
          {
            "key": "ratio",
            "label": "Times the entry price",
            "help": "2 is a sold leg whose premium doubled.",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "condition",
        "label": "Indicator or condition",
        "fields": [
          {
            "key": "when",
            "label": "Condition",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "expiry",
        "label": "Days to expiry",
        "fields": [
          {
            "key": "dte_at_most",
            "label": "At most days to expiry",
            "help": "",
            "required": false,
            "default": 0
          },
          {
            "key": "at",
            "label": "From (time)",
            "help": "",
            "required": false,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "delta_band",
        "label": "Net delta outside a band",
        "fields": [
          {
            "key": "band",
            "label": "Band (lots of delta)",
            "help": "",
            "required": true,
            "default": null
          }
        ],
        "phase": "a",
        "deployable": true
      },
      {
        "on": "leg_delta",
        "label": "A leg's delta reaches",
        "fields": [
          {
            "key": "leg",
            "label": "Leg",
            "help": "",
            "required": true,
            "default": null
          },
          {
            "key": "delta",
            "label": "Delta (absolute)",
            "help": "0.30: the leg's delta reached ±0.30.",
            "required": true,
            "default": null
          }
        ],
        "phase": "c",
        "deployable": false
      }
    ],
    "adjustment_targets": [
      {
        "target": "tested",
        "label": "The leg being tested",
        "explain": "the sold option whose price rose most against its entry, from fresh prices",
        "adjusters": [
          "close_leg@1",
          "reduce@1",
          "roll_strike@1"
        ]
      },
      {
        "target": "sold",
        "label": "All sold legs",
        "explain": "every open sold option (hedges stay)",
        "adjusters": [
          "close_leg@1",
          "reduce@1",
          "roll_strike@1"
        ]
      }
    ],
    "labels": {
      "instruments": [
        {
          "value": "option",
          "label": "Option"
        },
        {
          "value": "future",
          "label": "Future"
        },
        {
          "value": "stock",
          "label": "Stock"
        }
      ],
      "sides": [
        {
          "value": "buy",
          "label": "Buy"
        },
        {
          "value": "sell",
          "label": "Sell"
        }
      ],
      "signal_instruments": [
        {
          "value": "signal",
          "label": "As the alert says"
        },
        {
          "value": "stock",
          "label": "The stock"
        },
        {
          "value": "future",
          "label": "Its nearest future"
        },
        {
          "value": "option",
          "label": "Its nearest option"
        }
      ],
      "signal_sides": [
        {
          "value": "signal",
          "label": "As the alert says"
        },
        {
          "value": "buy",
          "label": "Buy"
        },
        {
          "value": "sell",
          "label": "Sell"
        }
      ],
      "option_types": [
        {
          "value": "call",
          "label": "Call"
        },
        {
          "value": "put",
          "label": "Put"
        }
      ],
      "stock_cover": [
        {
          "value": "strategy",
          "label": "Shares this strategy bought"
        },
        {
          "value": "any",
          "label": "Any shares held"
        }
      ],
      "expiry_kinds": [
        {
          "value": "weekly",
          "label": "Weekly"
        },
        {
          "value": "monthly",
          "label": "Monthly"
        }
      ],
      "premium_modes": [
        {
          "value": "nearest",
          "label": "Nearest to"
        },
        {
          "value": "at_least",
          "label": "At least"
        },
        {
          "value": "at_most",
          "label": "At most"
        }
      ],
      "exchanges": [
        {
          "value": "NFO",
          "label": "NSE futures & options"
        },
        {
          "value": "BFO",
          "label": "BSE futures & options"
        },
        {
          "value": "NSE",
          "label": "NSE stocks"
        },
        {
          "value": "BSE",
          "label": "BSE stocks"
        },
        {
          "value": "MCX",
          "label": "MCX commodities"
        }
      ],
      "references": [
        {
          "value": "future",
          "label": "Futures price"
        },
        {
          "value": "spot",
          "label": "Spot (index or stock) price"
        },
        {
          "value": "synthetic",
          "label": "Synthetic price from options"
        }
      ],
      "days": [
        {
          "value": "mon",
          "label": "Monday"
        },
        {
          "value": "tue",
          "label": "Tuesday"
        },
        {
          "value": "wed",
          "label": "Wednesday"
        },
        {
          "value": "thu",
          "label": "Thursday"
        },
        {
          "value": "fri",
          "label": "Friday"
        }
      ],
      "expiry_day": [
        {
          "value": "normal",
          "label": "Trade as usual"
        },
        {
          "value": "skip",
          "label": "Skip expiry day"
        },
        {
          "value": "only",
          "label": "Only on expiry day"
        }
      ],
      "evaluate_on": [
        {
          "value": "close",
          "label": "At each candle close"
        },
        {
          "value": "tick",
          "label": "On every price change"
        }
      ],
      "reentry_on": [
        {
          "value": "stop_loss",
          "label": "After a stop-loss"
        },
        {
          "value": "target",
          "label": "After a target"
        },
        {
          "value": "exit_signal",
          "label": "After an exit signal"
        },
        {
          "value": "square_off",
          "label": "After the square-off"
        }
      ],
      "cost_models": [
        {
          "value": "auto",
          "label": "Automatic"
        },
        {
          "value": "options_india",
          "label": "Indian options charges"
        },
        {
          "value": "futures_india",
          "label": "Indian futures charges"
        },
        {
          "value": "equity_intraday_india",
          "label": "Indian intraday equity charges"
        }
      ],
      "orders": [
        {
          "value": "auto",
          "label": "Automatic"
        },
        {
          "value": "hedge_first",
          "label": "Hedges first"
        },
        {
          "value": "simultaneous",
          "label": "All legs together"
        },
        {
          "value": "stagger",
          "label": "One after another"
        }
      ],
      "on_incomplete": [
        {
          "value": "safest",
          "label": "Do the safest thing"
        },
        {
          "value": "keep_and_manage",
          "label": "Keep and manage what filled"
        },
        {
          "value": "close_opened",
          "label": "Close what filled"
        }
      ],
      "on_leg_stop": [
        {
          "value": "nothing",
          "label": "Do nothing"
        },
        {
          "value": "move_others_to_cost",
          "label": "Move the other legs' stops to cost"
        },
        {
          "value": "exit_all",
          "label": "Exit every leg"
        }
      ],
      "combined_scopes": [
        {
          "value": "account",
          "label": "Each account"
        },
        {
          "value": "deployment",
          "label": "The whole deployment"
        }
      ],
      "bases": [
        {
          "value": "rupees",
          "label": "Rupees"
        },
        {
          "value": "pct_premium",
          "label": "% of premium"
        },
        {
          "value": "pct_margin",
          "label": "% of margin"
        }
      ],
      "on_breaker": [
        {
          "value": "nothing",
          "label": "Do nothing"
        },
        {
          "value": "close_open_trade",
          "label": "Close the open trade"
        }
      ],
      "leg_targets": [
        {
          "value": "tested",
          "label": "The leg being tested"
        },
        {
          "value": "sold",
          "label": "All sold legs"
        }
      ],
      "notify_events": [
        {
          "value": "entered",
          "label": "Entered a trade"
        },
        {
          "value": "exited",
          "label": "Exited a trade"
        },
        {
          "value": "stops",
          "label": "Stops moved or hit"
        },
        {
          "value": "adjusted",
          "label": "Adjusted a trade"
        },
        {
          "value": "skipped",
          "label": "Skipped an entry"
        },
        {
          "value": "needs_attention",
          "label": "Needs your attention"
        },
        {
          "value": "daily_summary",
          "label": "Daily summary"
        }
      ],
      "hedge_with": [
        {
          "value": "future",
          "label": "With futures"
        },
        {
          "value": "roll_short",
          "label": "By rolling the sold leg"
        }
      ],
      "timeframes": [
        {
          "value": "1m",
          "label": "1 minute"
        },
        {
          "value": "3m",
          "label": "3 minutes"
        },
        {
          "value": "5m",
          "label": "5 minutes"
        },
        {
          "value": "10m",
          "label": "10 minutes"
        },
        {
          "value": "15m",
          "label": "15 minutes"
        },
        {
          "value": "30m",
          "label": "30 minutes"
        },
        {
          "value": "60m",
          "label": "1 hour"
        }
      ],
      "deployable_exchanges": [
        {
          "value": "NFO",
          "label": "NSE futures & options"
        },
        {
          "value": "BFO",
          "label": "BSE futures & options"
        },
        {
          "value": "NSE",
          "label": "NSE stocks"
        },
        {
          "value": "BSE",
          "label": "BSE stocks"
        }
      ],
      "strike_methods": [
        {
          "value": "atm",
          "label": "At the money"
        },
        {
          "value": "pct_otm",
          "label": "Percent out of the money"
        },
        {
          "value": "delta",
          "label": "Delta"
        },
        {
          "value": "premium",
          "label": "Premium"
        },
        {
          "value": "expected_move",
          "label": "Expected move"
        },
        {
          "value": "relative",
          "label": "Relative to another leg"
        },
        {
          "value": "strike",
          "label": "Exact strike"
        }
      ],
      "slots": [
        {
          "value": "entry_source",
          "label": "Entry"
        },
        {
          "value": "exit_source",
          "label": "Exit"
        },
        {
          "value": "legs",
          "label": "Legs"
        },
        {
          "value": "sizing",
          "label": "Sizing"
        },
        {
          "value": "execution",
          "label": "Execution"
        },
        {
          "value": "per_leg",
          "label": "Per-leg protection"
        },
        {
          "value": "combined",
          "label": "Combined protection"
        },
        {
          "value": "limits",
          "label": "Daily limits"
        },
        {
          "value": "adjustment",
          "label": "Adjustment"
        },
        {
          "value": "notifier",
          "label": "Notifications"
        }
      ],
      "components": [
        {
          "value": "conditions@1",
          "label": "Conditions"
        },
        {
          "value": "manual@1",
          "label": "Manual"
        },
        {
          "value": "api@1",
          "label": "Your code (API)"
        },
        {
          "value": "webhook@1",
          "label": "Alerts (signal URL)"
        },
        {
          "value": "chain@1",
          "label": "From the option chain"
        },
        {
          "value": "fixed@1",
          "label": "Exact contracts"
        },
        {
          "value": "from_signal@1",
          "label": "From the signal"
        },
        {
          "value": "fixed_lots@1",
          "label": "Fixed lots"
        },
        {
          "value": "max_premium@1",
          "label": "Premium cap"
        },
        {
          "value": "risk@1",
          "label": "Risk per trade"
        },
        {
          "value": "capital_pct@1",
          "label": "Percent of capital"
        },
        {
          "value": "from_signal@1",
          "label": "From the signal"
        },
        {
          "value": "protected_limit@1",
          "label": "Protected limit"
        },
        {
          "value": "per_leg@1",
          "label": "Per-leg protection"
        },
        {
          "value": "combined@1",
          "label": "Combined protection"
        },
        {
          "value": "limits@1",
          "label": "Daily limits"
        },
        {
          "value": "close_leg@1",
          "label": "Close a leg"
        },
        {
          "value": "reduce@1",
          "label": "Reduce"
        },
        {
          "value": "roll_strike@1",
          "label": "Roll a strike"
        },
        {
          "value": "add_hedge@1",
          "label": "Add a hedge"
        },
        {
          "value": "widen@1",
          "label": "Widen the strikes"
        },
        {
          "value": "delta_hedge@1",
          "label": "Delta hedge"
        },
        {
          "value": "roll_expiry@1",
          "label": "Roll to the next expiry"
        },
        {
          "value": "app@1",
          "label": "App"
        },
        {
          "value": "telegram@1",
          "label": "Telegram"
        },
        {
          "value": "webhook@1",
          "label": "Webhook"
        }
      ],
      "segments": [
        {
          "value": "NSE",
          "label": "NSE: stocks, index futures and options"
        },
        {
          "value": "BSE",
          "label": "BSE: stocks, index futures and options"
        },
        {
          "value": "MCX",
          "label": "MCX: commodities"
        },
        {
          "value": "CDS",
          "label": "Currency derivatives"
        }
      ],
      "indicators": [
        {
          "value": "sma",
          "label": "Simple moving average"
        },
        {
          "value": "ema",
          "label": "Exponential moving average"
        },
        {
          "value": "rsi",
          "label": "RSI"
        },
        {
          "value": "atr",
          "label": "Average true range"
        },
        {
          "value": "bb_upper",
          "label": "Bollinger upper band"
        },
        {
          "value": "bb_middle",
          "label": "Bollinger middle band"
        },
        {
          "value": "bb_mid",
          "label": "Bollinger middle band"
        },
        {
          "value": "bb_lower",
          "label": "Bollinger lower band"
        },
        {
          "value": "bb_percent",
          "label": "Bollinger %B"
        },
        {
          "value": "bb_width",
          "label": "Bollinger width"
        },
        {
          "value": "macd",
          "label": "MACD"
        },
        {
          "value": "macd_signal",
          "label": "MACD signal"
        },
        {
          "value": "macd_hist",
          "label": "MACD histogram"
        },
        {
          "value": "stoch_k",
          "label": "Stochastic %K"
        },
        {
          "value": "stoch_d",
          "label": "Stochastic %D"
        },
        {
          "value": "vwap",
          "label": "VWAP"
        },
        {
          "value": "supertrend",
          "label": "Supertrend"
        },
        {
          "value": "supertrend_dir",
          "label": "Supertrend direction"
        },
        {
          "value": "bar_open",
          "label": "Bar open"
        },
        {
          "value": "bar_high",
          "label": "Bar high"
        },
        {
          "value": "bar_low",
          "label": "Bar low"
        },
        {
          "value": "bar_close",
          "label": "Bar close"
        },
        {
          "value": "bar_volume",
          "label": "Bar volume"
        },
        {
          "value": "aroon_up",
          "label": "Aroon up"
        },
        {
          "value": "aroon_down",
          "label": "Aroon down"
        },
        {
          "value": "aroon_osc",
          "label": "Aroon oscillator"
        },
        {
          "value": "cci",
          "label": "Commodity channel index"
        },
        {
          "value": "willr",
          "label": "Williams %R"
        },
        {
          "value": "roc",
          "label": "Rate of change"
        },
        {
          "value": "mom",
          "label": "Momentum"
        },
        {
          "value": "mfi",
          "label": "Money flow index"
        },
        {
          "value": "keltner_middle",
          "label": "Keltner middle"
        },
        {
          "value": "donchian_middle",
          "label": "Donchian middle"
        },
        {
          "value": "wma",
          "label": "Weighted moving average"
        },
        {
          "value": "dema",
          "label": "Double exponential moving average"
        },
        {
          "value": "tema",
          "label": "Triple exponential moving average"
        },
        {
          "value": "kama",
          "label": "Kaufman adaptive moving average"
        },
        {
          "value": "psar",
          "label": "Parabolic SAR"
        },
        {
          "value": "stochrsi_k",
          "label": "Stochastic RSI %K"
        },
        {
          "value": "stochrsi_d",
          "label": "Stochastic RSI %D"
        },
        {
          "value": "stddev",
          "label": "Standard deviation"
        },
        {
          "value": "bullish_pattern",
          "label": "Bullish candle pattern"
        },
        {
          "value": "bearish_pattern",
          "label": "Bearish candle pattern"
        },
        {
          "value": "pattern",
          "label": "Candle pattern (either direction)"
        },
        {
          "value": "adx",
          "label": "ADX"
        },
        {
          "value": "plus_di",
          "label": "+DI"
        },
        {
          "value": "minus_di",
          "label": "-DI"
        },
        {
          "value": "hull",
          "label": "Hull moving average"
        },
        {
          "value": "chop",
          "label": "Choppiness index"
        },
        {
          "value": "donchian_upper",
          "label": "Donchian upper"
        },
        {
          "value": "donchian_lower",
          "label": "Donchian lower"
        },
        {
          "value": "keltner_upper",
          "label": "Keltner upper"
        },
        {
          "value": "keltner_lower",
          "label": "Keltner lower"
        },
        {
          "value": "ichimoku_tenkan",
          "label": "Ichimoku tenkan"
        },
        {
          "value": "ichimoku_kijun",
          "label": "Ichimoku kijun"
        },
        {
          "value": "ichimoku_span_a",
          "label": "Ichimoku span A"
        },
        {
          "value": "ichimoku_span_b",
          "label": "Ichimoku span B"
        },
        {
          "value": "cmf",
          "label": "Chaikin money flow"
        },
        {
          "value": "obv",
          "label": "On-balance volume"
        },
        {
          "value": "vortex_plus",
          "label": "Vortex +"
        },
        {
          "value": "vortex_minus",
          "label": "Vortex -"
        },
        {
          "value": "fisher",
          "label": "Fisher transform"
        },
        {
          "value": "tsi",
          "label": "True strength index"
        },
        {
          "value": "rvol",
          "label": "Relative volume"
        },
        {
          "value": "hv",
          "label": "Historical volatility"
        },
        {
          "value": "yz_vol",
          "label": "Yang-Zhang volatility"
        },
        {
          "value": "regime",
          "label": "Trend regime (1 trend, 0 range)"
        },
        {
          "value": "pivot",
          "label": "Pivot"
        },
        {
          "value": "r1",
          "label": "Resistance 1"
        },
        {
          "value": "r2",
          "label": "Resistance 2"
        },
        {
          "value": "s1",
          "label": "Support 1"
        },
        {
          "value": "s2",
          "label": "Support 2"
        },
        {
          "value": "prev_high",
          "label": "Previous day's high"
        },
        {
          "value": "prev_low",
          "label": "Previous day's low"
        },
        {
          "value": "prev_close",
          "label": "Previous day's close"
        },
        {
          "value": "day_open",
          "label": "Today's open"
        },
        {
          "value": "day_high",
          "label": "Today's high"
        },
        {
          "value": "day_low",
          "label": "Today's low"
        },
        {
          "value": "orb_high",
          "label": "Opening range high"
        },
        {
          "value": "orb_low",
          "label": "Opening range low"
        },
        {
          "value": "orb_ready",
          "label": "Opening range complete"
        },
        {
          "value": "ltp",
          "label": "Last price of a watched symbol"
        },
        {
          "value": "atm_iv",
          "label": "ATM implied volatility"
        },
        {
          "value": "iv_rank",
          "label": "IV rank"
        },
        {
          "value": "iv_pct",
          "label": "IV percentile"
        },
        {
          "value": "expected_move",
          "label": "Expected move"
        },
        {
          "value": "dte",
          "label": "Days to expiry"
        },
        {
          "value": "leg",
          "label": "An open leg (ltp, pnl, delta, iv, premium_ratio)"
        },
        {
          "value": "minutes",
          "label": "Minutes since midnight of a time"
        }
      ],
      "template_categories": [
        {
          "value": "income",
          "label": "Earn from a quiet market"
        },
        {
          "value": "hedged_income",
          "label": "Earn with protection"
        },
        {
          "value": "breakout",
          "label": "Catch a breakout"
        },
        {
          "value": "trend",
          "label": "Ride a trend"
        },
        {
          "value": "big_move",
          "label": "Bet on a big move"
        },
        {
          "value": "bullish",
          "label": "Bullish"
        },
        {
          "value": "bearish",
          "label": "Bearish"
        }
      ],
      "risk": [
        {
          "value": "limited",
          "label": "Limited loss"
        },
        {
          "value": "unlimited",
          "label": "Unlimited loss"
        }
      ]
    },
    "live_brokers": [
      "zerodha"
    ],
    "underlyings": [
      "NIFTY",
      "BANKNIFTY",
      "FINNIFTY",
      "SENSEX",
      "MIDCPNIFTY"
    ],
    "underlying_details": [
      {
        "name": "NIFTY",
        "exchange": "NFO",
        "lot_size": 75,
        "strike_step": 50,
        "expiry_kinds": [
          "weekly",
          "monthly"
        ],
        "next_expiry": "2030-12-19",
        "next_monthly_expiry": "2030-12-26",
        "spot": 25000,
        "spot_stale": false,
        "note": null
      },
      {
        "name": "BANKNIFTY",
        "exchange": null,
        "lot_size": null,
        "strike_step": null,
        "expiry_kinds": [],
        "next_expiry": null,
        "next_monthly_expiry": null,
        "spot": null,
        "spot_stale": false,
        "note": "No live options or futures in today's instrument list."
      },
      {
        "name": "FINNIFTY",
        "exchange": null,
        "lot_size": null,
        "strike_step": null,
        "expiry_kinds": [],
        "next_expiry": null,
        "next_monthly_expiry": null,
        "spot": null,
        "spot_stale": false,
        "note": "No live options or futures in today's instrument list."
      },
      {
        "name": "SENSEX",
        "exchange": null,
        "lot_size": null,
        "strike_step": null,
        "expiry_kinds": [],
        "next_expiry": null,
        "next_monthly_expiry": null,
        "spot": null,
        "spot_stale": false,
        "note": "No live options or futures in today's instrument list."
      },
      {
        "name": "MIDCPNIFTY",
        "exchange": null,
        "lot_size": null,
        "strike_step": null,
        "expiry_kinds": [],
        "next_expiry": null,
        "next_monthly_expiry": null,
        "spot": null,
        "spot_stale": false,
        "note": "No live options or futures in today's instrument list."
      }
    ],
    "mcx_sessions": {
      "late": {
        "open": "09:00",
        "window_close": "23:30",
        "latest_square_off": "23:00",
        "default_square_off": "22:45",
        "close_us_dst": "23:30",
        "close_us_standard": "23:55",
        "commodities": [
          "GOLD",
          "GOLDM",
          "GOLDGUINEA",
          "GOLDPETAL",
          "SILVER",
          "SILVERM",
          "SILVERMIC",
          "CRUDEOIL",
          "CRUDEOILM",
          "NATURALGAS",
          "NATGASMINI",
          "COPPER",
          "ZINC",
          "ZINCMINI",
          "ALUMINIUM",
          "ALUMINI",
          "LEAD",
          "LEADMINI",
          "NICKEL"
        ]
      },
      "early": {
        "open": "09:00",
        "window_close": "17:00",
        "latest_square_off": "16:30",
        "default_square_off": "16:15",
        "close": "17:00"
      },
      "commodities": []
    },
    "switches": {
      "gallery": true,
      "portfolio_risk": true,
      "mcx": false,
      "stock_fno": false,
      "equity": false,
      "stock_universe": false
    },
    "engine_connected": false,
    "usage": {
      "active_deployments": 0,
      "active_deployments_left": 3
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/schema/{schema_file}

Access: no authentication

Strategy document JSON Schema

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `schema_file` | `string` | yes | `{schema_version}.json`, e.g. `1.json`. |

```bash
curl "https://api.cirrus.trade/v2/strategies/schema/<schema_file>"
```

**Response `data` (200):** no body.

**Example: No such format (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "schema_not_found",
    "message": "No such document format; this server has format 1",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 404 | `schema_not_found` | No such strategy document format on this server. |

### GET /v2/strategies/templates

Scope: `read`

List strategy templates

```bash
curl "https://api.cirrus.trade/v2/strategies/templates" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes | Stable id, e.g. `short_strangle`. |
| `[].name` | `string` | yes | Plain-English name, e.g. `Short strangle`. |
| `[].goal` | `string` | yes | The outcome it is for, e.g. `Earn from a quiet market`. |
| `[].description` | `string` | yes | One line. |
| `[].category` | `string` | yes | `income`, `hedged_income`, `breakout`, `trend`, `big_move`, `bullish` or `bearish` (labels in `category_label`). |
| `[].category_label` | `string` | yes |  |
| `[].risk` | `string` | yes | `limited` or `unlimited`: whether the most it can lose is known before entering (a sold option without a bought one of the same type, or a future, is unlimited). |
| `[].risk_label` | `string` | yes | `Limited loss` or `Unlimited loss`. |
| `[].components` | `string[]` | yes | `name@major` of every component it uses (`GET /v2/strategies/components`). |
| `[].indicators` | `string[]` | yes | Indicator functions its conditions call (`rsi`, `supertrend_dir`, ...). |
| `[].needs` | `string[]` | yes | Broker capabilities its components need. |
| `[].summary` | `string` | yes | The strategy in plain English. |
| `[].deployable` | `boolean` | yes | A version of it can be deployed on this server now. |
| `[].blockers` | `object[]` | yes | Why not, when not (e.g. indicator conditions not available here). |
| `[].blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `[].blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `[].blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `[].blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `[].blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `[].warnings` | `object[]` | yes |  |
| `[].warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `[].warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `[].warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `[].warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `[].warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `[].document` | `object` | yes | The document, normalized (defaults written out): send it as `document` to `POST /v2/strategies` or `/import`, after any edits. |

**Example: Ready strategies to start from (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "short_strangle",
      "name": "Short strangle",
      "goal": "Earn from a quiet market",
      "description": "Sell an out-of-the-money call and put each morning; keep the premium while the market stays in a range.",
      "category": "income",
      "category_label": "Earn from a quiet market",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Sell 1 lot of a 0.2 delta call (weekly expiry) and sell 1 lot of a 0.2 delta put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY short strangle",
          "description": "Sell a 0.2 delta call and put at 09:30 with a combined stop.",
          "tags": [
            "nifty",
            "weekly",
            "income"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.2
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.2
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": {
                "rupees": 2000
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "rsi_strangle",
      "name": "RSI-gated strangle",
      "goal": "Earn from a quiet market",
      "description": "Sell a 0.2 delta strangle only when the 5-minute RSI says the market is calm (between 45 and 60).",
      "category": "income",
      "category_label": "Earn from a quiet market",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "app@1",
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "rsi"
      ],
      "needs": [],
      "summary": "Sell 1 lot of a 0.2 delta call (weekly expiry) and sell 1 lot of a 0.2 delta put (weekly expiry) on NIFTY. Enter when rsi(14, '5m') > 45 && rsi(14, '5m') < 60 (checked at each bar close), between 09:30 and 13:30, Monday to Friday, not on expiry day. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 4 lots per leg. Orders: all legs together, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: keep and manage whatever filled. Protection: stop at ₹3,000 loss, take ₹2,000 profit, move the stop to cost at ₹1,000 profit; each leg: stop at 50% against it; when a leg stops, move the others' stops to cost; stops are also placed at the broker; at most 3 trades and ₹6,000 loss a day. Try again up to 2 times after a stop, 10 minutes later, until 13:30, only on a fresh signal. Updates to app: entered, exited, stops, adjusted, needs attention and daily summary.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`rsi(14, '5m') > 45 && rsi(14, '5m') < 60` uses rsi, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY RSI strangle",
          "description": "Sell a 0.2 delta strangle when NIFTY's 5-minute RSI is between 45 and 60.",
          "tags": [
            "weekly",
            "nifty"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {
            "u": "NSE_INDEX:NIFTY 50"
          },
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.2
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.2
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "rsi(14, '5m') > 45 && rsi(14, '5m') < 60",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "skip",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 4
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "simultaneous",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "keep_and_manage",
            "retry_next_signal": 0
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 50
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "move_others_to_cost",
              "at_broker": true
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": {
                "rupees": 2000
              },
              "breakeven": {
                "at_rupees": 1000
              },
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 6000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [
              "stop_loss"
            ],
            "max": 2,
            "delay_s": 600,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": "13:30"
          },
          "adjustments": [],
          "notify": [
            {
              "type": "app@1",
              "events": [
                "entered",
                "exited",
                "stops",
                "adjusted",
                "needs_attention",
                "daily_summary"
              ]
            }
          ],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "iron_condor",
      "name": "Iron condor",
      "goal": "Earn from a quiet market, safer",
      "description": "Sell a strangle and buy wings further out, so the most you can lose is known before you enter.",
      "category": "hedged_income",
      "category_label": "Earn with protection",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1",
        "roll_strike@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Sell 1 lot of a 0.16 delta call (weekly expiry), buy 1 lot of a 4-strikes-beyond-sc call (weekly expiry), sell 1 lot of a 0.16 delta put (weekly expiry) and buy 1 lot of a 4-strikes-beyond-sp put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:45 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹4,000 loss, take ₹2,500 profit; no per-leg stops; at most 3 trades a day. No re-entries. Adjust: roll sc to a new strike when sc trades at 2 times its entry price; roll sp to a new strike when sp trades at 2 times its entry price.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY iron condor",
          "description": "Sell 0.16 delta strikes with wings 4 strikes out, wings bought first; roll the tested side back to 0.16 delta when its premium doubles.",
          "tags": [
            "nifty",
            "weekly",
            "hedged"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.16
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "lc",
                "instrument": "option",
                "side": "buy",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "relative",
                  "to": "sc",
                  "width_strikes": 4,
                  "width_points": null
                },
                "lots": 1,
                "hedge_for": "sc",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.16
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "lp",
                "instrument": "option",
                "side": "buy",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "relative",
                  "to": "sp",
                  "width_strikes": 4,
                  "width_points": null
                },
                "lots": 1,
                "hedge_for": "sp",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:45",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "hedge_first",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 4000
              },
              "target": {
                "rupees": 2500
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [
            {
              "type": "roll_strike@1",
              "when": null,
              "trigger": {
                "on": "premium_ratio",
                "leg": "sc",
                "ratio": 2
              },
              "leg": "sc",
              "strike": {
                "by": "delta",
                "delta": 0.16
              },
              "move_hedge": "lc",
              "max_per_cycle": 1
            },
            {
              "type": "roll_strike@1",
              "when": null,
              "trigger": {
                "on": "premium_ratio",
                "leg": "sp",
                "ratio": 2
              },
              "leg": "sp",
              "strike": {
                "by": "delta",
                "delta": 0.16
              },
              "move_hedge": "lp",
              "max_per_cycle": 1
            }
          ],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "high_iv_iron_condor",
      "name": "High-IV iron condor",
      "goal": "Earn from high volatility, hedged",
      "description": "When India VIX is above 16 and NIFTY is range-bound, sell 0.16 delta strikes with wings 4 out; roll a side whose premium doubles.",
      "category": "hedged_income",
      "category_label": "Earn with protection",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "app@1",
        "chain@1",
        "combined@1",
        "conditions@1",
        "delta_hedge@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1",
        "risk@1",
        "roll_strike@1",
        "webhook@1"
      ],
      "indicators": [
        "adx"
      ],
      "needs": [],
      "summary": "Sell 1 lot of a 0.16 delta call (nearest expiry at least 7 days away), buy 1 lot of a 4-strikes-beyond-sc call (nearest expiry at least 7 days away), sell 1 lot of a 0.16 delta put (nearest expiry at least 7 days away) and buy 1 lot of a 4-strikes-beyond-sp put (nearest expiry at least 7 days away) on NIFTY. Enter when vix.ltp > 16 && adx(14, '15m') < 20 (checked at each bar close), between 10:00 and 14:00, on Monday, Tuesday and Wednesday. Positional: trades may stay open overnight. On expiry day, exit at 14:45. Size: as many lots as keep the most one trade can lose under ₹10,000 (from the hedges, else the combined stop), at most 5 lots per leg. Orders: hedges first, limit prices 0.5% beyond the market, chasing up to 1.5% slippage; if only some legs fill: close whatever filled. Protection: stop at 150% of the entry premium loss, take 50% of the entry premium profit, lock profits in 1 steps; no per-leg stops; at most 2 trades and ₹8,000 loss a day. Try again once after a target, 1 hour later, until 14:00. Adjust: roll sc to a new strike when sc trades at 2 times its entry price; roll sp to a new strike when sp trades at 2 times its entry price; hedge the net delta when the net delta leaves ±0.5. Updates to app: entered, exited, stops, adjusted, needs attention and daily summary; webhook: entered, exited and adjusted.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`vix.ltp > 16 && adx(14, '15m') < 20` uses adx, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY high-IV iron condor",
          "description": "When India VIX is above 16 and NIFTY is range-bound, sell 0.16 delta strikes with wings 4 strikes out; roll a side whose premium doubles; when the net delta leaves ±0.5 lots, trade the future back to within 0.1.",
          "tags": [
            "nifty",
            "positional"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "synthetic"
          },
          "watch": {
            "u": "NSE_INDEX:NIFTY 50",
            "vix": "NSE_INDEX:INDIA VIX"
          },
          "positional": true,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "min_dte": 7
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.16
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "lc",
                "instrument": "option",
                "side": "buy",
                "option_type": "call",
                "expiry": {
                  "min_dte": 7
                },
                "strike": {
                  "by": "relative",
                  "to": "sc",
                  "width_strikes": 4,
                  "width_points": null
                },
                "lots": 1,
                "hedge_for": "sc",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "min_dte": 7
                },
                "strike": {
                  "by": "delta",
                  "delta": 0.16
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "lp",
                "instrument": "option",
                "side": "buy",
                "option_type": "put",
                "expiry": {
                  "min_dte": 7
                },
                "strike": {
                  "by": "relative",
                  "to": "sp",
                  "width_strikes": 4,
                  "width_points": null
                },
                "lots": 1,
                "hedge_for": "sp",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "vix.ltp > 16 && adx(14, '15m') < 20",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "10:00",
              "end": "14:00"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": null,
            "expiry_day_exit": "14:45",
            "max_hold_min": null
          },
          "sizing": {
            "type": "risk@1",
            "risk_rupees": 10000,
            "max_lots": 5
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "hedge_first",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1.5,
              "max_attempts": 15
            },
            "on_incomplete": "close_opened",
            "retry_next_signal": 0
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "pct_premium",
              "stop_loss": {
                "pct_premium": 150
              },
              "target": {
                "pct_premium": 50
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [
                {
                  "at_pct": 30,
                  "lock_pct": 10
                }
              ],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 8000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 2,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [
              "target"
            ],
            "max": 1,
            "delay_s": 3600,
            "require_fresh_signal": false,
            "same_direction_only": true,
            "until": "14:00"
          },
          "adjustments": [
            {
              "type": "roll_strike@1",
              "when": null,
              "trigger": {
                "on": "premium_ratio",
                "leg": "sc",
                "ratio": 2
              },
              "leg": "sc",
              "strike": {
                "by": "delta",
                "delta": 0.16
              },
              "move_hedge": "lc",
              "max_per_cycle": 2
            },
            {
              "type": "roll_strike@1",
              "when": null,
              "trigger": {
                "on": "premium_ratio",
                "leg": "sp",
                "ratio": 2
              },
              "leg": "sp",
              "strike": {
                "by": "delta",
                "delta": 0.16
              },
              "move_hedge": "lp",
              "max_per_cycle": 2
            },
            {
              "type": "delta_hedge@1",
              "when": null,
              "trigger": {
                "on": "delta_band",
                "band": 0.5
              },
              "with": "future",
              "target": 0.1,
              "min_interval_s": 300,
              "max_per_cycle": 3
            }
          ],
          "notify": [
            {
              "type": "app@1",
              "events": [
                "entered",
                "exited",
                "stops",
                "adjusted",
                "needs_attention",
                "daily_summary"
              ]
            },
            {
              "type": "webhook@1",
              "events": [
                "entered",
                "exited",
                "adjusted"
              ]
            }
          ],
          "costs": {
            "model": "options_india",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "short_straddle",
      "name": "Short straddle",
      "goal": "Earn from a very quiet market",
      "description": "Sell the at-the-money call and put; earns most when the market barely moves, loses fast when it does.",
      "category": "income",
      "category_label": "Earn from a quiet market",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY short straddle",
          "description": "Sell the ATM call and put at 09:30 with a combined stop.",
          "tags": [
            "nifty",
            "weekly",
            "income"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": {
                "rupees": 2000
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "long_straddle",
      "name": "Long straddle",
      "goal": "Bet on a big move",
      "description": "Buy the at-the-money call and put; profits from a big move either way, loses at most the premium paid.",
      "category": "big_move",
      "category_label": "Bet on a big move",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "chain@1",
        "combined@1",
        "fixed_lots@1",
        "limits@1",
        "manual@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Buy 1 lot of an ATM call (weekly expiry) and buy 1 lot of an ATM put (weekly expiry) on NIFTY. Enter when you tap the button in the app, between 09:30 and 14:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹2,500 loss, take ₹5,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY long straddle",
          "description": "Buy the ATM call and put on your signal, for a big move either way.",
          "tags": [
            "nifty",
            "weekly",
            "events"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "lc",
                "instrument": "option",
                "side": "buy",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "lp",
                "instrument": "option",
                "side": "buy",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "manual@1"
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 2500
              },
              "target": {
                "rupees": 5000
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "bull_call_spread",
      "name": "Bull call spread",
      "goal": "Bullish, limited risk",
      "description": "Buy the at-the-money call and sell one 4 strikes higher; gains if the market rises, loses at most the net premium.",
      "category": "bullish",
      "category_label": "Bullish",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "chain@1",
        "combined@1",
        "fixed_lots@1",
        "limits@1",
        "manual@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Buy 1 lot of an ATM call (weekly expiry) and sell 1 lot of a 4-strike OTM call (weekly expiry) on NIFTY. Enter when you tap the button in the app, between 09:30 and 14:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY bull call spread",
          "description": "Buy the ATM call and sell the call 4 strikes higher, on your signal.",
          "tags": [
            "nifty",
            "weekly",
            "bullish"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "lc",
                "instrument": "option",
                "side": "buy",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": "sc",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 4
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "manual@1"
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "hedge_first",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "bear_put_spread",
      "name": "Bear put spread",
      "goal": "Bearish, limited risk",
      "description": "Buy the at-the-money put and sell one 4 strikes lower; gains if the market falls, loses at most the net premium.",
      "category": "bearish",
      "category_label": "Bearish",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "chain@1",
        "combined@1",
        "fixed_lots@1",
        "limits@1",
        "manual@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Buy 1 lot of an ATM put (weekly expiry) and sell 1 lot of a 4-strike OTM put (weekly expiry) on NIFTY. Enter when you tap the button in the app, between 09:30 and 14:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY bear put spread",
          "description": "Buy the ATM put and sell the put 4 strikes lower, on your signal.",
          "tags": [
            "nifty",
            "weekly",
            "bearish"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "lp",
                "instrument": "option",
                "side": "buy",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": "sp",
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 4
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "manual@1"
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "hedge_first",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "first_15_min_breakout",
      "name": "First-15-minute breakout",
      "goal": "Catch a breakout",
      "description": "Buy BANKNIFTY futures when a 5-minute candle closes above the first 15 minutes' high on strong volume.",
      "category": "breakout",
      "category_label": "Catch a breakout",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "app@1",
        "chain@1",
        "combined@1",
        "conditions@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1",
        "risk@1"
      ],
      "indicators": [
        "supertrend_dir",
        "bar_close",
        "rvol",
        "orb_high",
        "orb_ready"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the future (monthly expiry) on BANKNIFTY. Enter when orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15), 10) && rvol(20, '5m') > 1.5 (checked at each bar close), between 09:30 and 11:30, Monday to Friday. Exit everything when crosses_below(supertrend_dir(10, 3, '5m'), 0) (checked at each bar close). Everything closes at 15:10. Size: as many lots as keep the most one trade can lose under ₹4,000 (from the hedges, else the combined stop), at most 3 lots per leg. Orders: all legs together, limit prices 0.1% beyond the market, chasing up to 0.2% slippage; if only some legs fill: keep and manage whatever filled. Protection: take ₹9,000 profit, once 3000 up, give back at most 1500; each leg: stop at 120 points against it; trail the stop by 40 every 40; stops are also placed at the broker; at most 2 trades and ₹8,000 loss a day. Try again once after a stop, 15 minutes later, until 11:30, only on a fresh signal. Updates to app: entered, exited, stops, needs attention and daily summary.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15), 10) && rvol(20, '5m') > 1.5` uses bar_close, orb_high, orb_ready, rvol, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`crosses_below(supertrend_dir(10, 3, '5m'), 0)` uses supertrend_dir, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "BANKNIFTY 15-minute breakout",
          "description": "Buy BANKNIFTY futures when a 5-minute candle closes above the first 15 minutes' high.",
          "tags": [
            "banknifty",
            "intraday"
          ]
        },
        "spec": {
          "underlying": {
            "name": "BANKNIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {
            "u": "@future(BANKNIFTY, 0)"
          },
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "fut",
                "instrument": "future",
                "side": "buy",
                "option_type": null,
                "expiry": {
                  "kind": "monthly",
                  "n": 0
                },
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15), 10) && rvol(20, '5m') > 1.5",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "11:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "crosses_below(supertrend_dir(10, 3, '5m'), 0)",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:10",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "risk@1",
            "risk_rupees": 4000,
            "max_lots": 3
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "simultaneous",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.1,
            "chase": {
              "every_ms": 500,
              "max_slippage_pct": 0.2,
              "max_attempts": 10
            },
            "on_incomplete": "keep_and_manage",
            "retry_next_signal": 0
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "points": 120
              },
              "target": null,
              "trail": {
                "step": 40,
                "move_by": 40
              },
              "on_leg_stop": "nothing",
              "at_broker": true
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": {
                "rupees": 9000
              },
              "breakeven": null,
              "trail": {
                "arm_at": 3000,
                "giveback": 1500,
                "step": 500
              },
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 8000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 2,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [
              "stop_loss"
            ],
            "max": 1,
            "delay_s": 900,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": "11:30"
          },
          "adjustments": [],
          "notify": [
            {
              "type": "app@1",
              "events": [
                "entered",
                "exited",
                "stops",
                "needs_attention",
                "daily_summary"
              ]
            }
          ],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "supertrend_futures",
      "name": "Supertrend futures",
      "goal": "Ride a trend",
      "description": "Buy NIFTY futures when the 15-minute Supertrend turns up and exit when it turns down.",
      "category": "trend",
      "category_label": "Ride a trend",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "supertrend_dir"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the future (monthly expiry) on NIFTY. Enter when crosses_above(supertrend_dir(10, 3, '15m'), 0) (checked at each bar close), between 09:30 and 14:30, Monday to Friday. Exit everything when crosses_below(supertrend_dir(10, 3, '15m'), 0) (checked at each bar close). Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 100 points against it; at most 3 trades a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`crosses_above(supertrend_dir(10, 3, '15m'), 0)` uses supertrend_dir, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`crosses_below(supertrend_dir(10, 3, '15m'), 0)` uses supertrend_dir, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY Supertrend futures",
          "description": "Buy the near-month future when the 15-minute Supertrend turns up; exit when it turns down.",
          "tags": [
            "nifty",
            "futures",
            "trend"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {
            "u": "@future(NIFTY, 0)"
          },
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "fut",
                "instrument": "future",
                "side": "buy",
                "option_type": null,
                "expiry": {
                  "kind": "monthly",
                  "n": 0
                },
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "crosses_above(supertrend_dir(10, 3, '15m'), 0)",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "crosses_below(supertrend_dir(10, 3, '15m'), 0)",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "points": 100
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "adx_trend_futures",
      "name": "ADX trend futures",
      "goal": "Ride a trend",
      "description": "Buy NIFTY futures when +DI crosses above -DI on the 15-minute chart while ADX says the trend is strong (above 25).",
      "category": "trend",
      "category_label": "Ride a trend",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "adx",
        "plus_di",
        "minus_di"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the future (monthly expiry) on NIFTY. Enter when crosses_above(plus_di(14, '15m'), minus_di(14, '15m')) && adx(14, '15m') > 25 (checked at each bar close), between 09:30 and 14:30, Monday to Friday. Exit everything when crosses_below(plus_di(14, '15m'), minus_di(14, '15m')) (checked at each bar close). Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 100 points against it; at most 3 trades a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`crosses_above(plus_di(14, '15m'), minus_di(14, '15m')) && adx(14, '15m') > 25` uses adx, minus_di, plus_di, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`crosses_below(plus_di(14, '15m'), minus_di(14, '15m'))` uses minus_di, plus_di, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY ADX trend futures",
          "description": "Buy the near-month future when 15-minute +DI crosses above -DI with ADX above 25; exit when +DI crosses back below -DI.",
          "tags": [
            "nifty",
            "futures",
            "trend"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {
            "u": "@future(NIFTY, 0)"
          },
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "fut",
                "instrument": "future",
                "side": "buy",
                "option_type": null,
                "expiry": {
                  "kind": "monthly",
                  "n": 0
                },
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "crosses_above(plus_di(14, '15m'), minus_di(14, '15m')) && adx(14, '15m') > 25",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "crosses_below(plus_di(14, '15m'), minus_di(14, '15m'))",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "points": 100
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "engulfing_call",
      "name": "Bullish engulfing call",
      "goal": "Bullish, limited risk",
      "description": "Buy the at-the-money call when a bullish engulfing candle closes on the 15-minute chart after a dip (RSI below 45).",
      "category": "bullish",
      "category_label": "Bullish",
      "risk": "limited",
      "risk_label": "Limited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "rsi",
        "bullish_pattern"
      ],
      "needs": [],
      "summary": "Buy 1 lot of an ATM call (weekly expiry) on NIFTY. Enter when bullish_pattern('engulfing', '15m') && rsi(14, '15m') < 45 (checked at each bar close), between 09:45 and 14:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 30% against it; at most 3 trades a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`bullish_pattern('engulfing', '15m') && rsi(14, '15m') < 45` uses bullish_pattern, rsi, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY bullish engulfing call",
          "description": "Buy the ATM weekly call when a bullish engulfing candle closes on NIFTY's 15-minute chart with RSI below 45.",
          "tags": [
            "nifty",
            "weekly",
            "bullish",
            "pattern"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {
            "u": "NSE_INDEX:NIFTY 50"
          },
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "lc",
                "instrument": "option",
                "side": "buy",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "bullish_pattern('engulfing', '15m') && rsi(14, '15m') < 45",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:45",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 30
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      }
    },
    {
      "id": "stock_ema_crossover",
      "name": "Stock EMA crossover",
      "goal": "Ride a trend",
      "description": "Buy RELIANCE shares intraday when the 9 EMA crosses above the 21 EMA on 5-minute candles; sell on the cross back.",
      "category": "trend",
      "category_label": "Ride a trend",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "ema"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the stock on RELIANCE. Enter when crosses_above(ema(9, '5m'), ema(21, '5m')) (checked at each bar close), between 09:30 and 14:30, Monday to Friday. Exit everything when crosses_below(ema(9, '5m'), ema(21, '5m')) (checked at each bar close). Everything closes at 15:10. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 1.5% against it; at most 3 trades and ₹2,000 loss a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`crosses_above(ema(9, '5m'), ema(21, '5m'))` uses ema, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`crosses_below(ema(9, '5m'), ema(21, '5m'))` uses ema, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.stock",
          "label": "",
          "message": "The cash equity settings (delivery, capital sizing, circuit gap) aren't available yet",
          "code": "equity_not_available",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "RELIANCE EMA crossover",
          "description": "Buy RELIANCE shares (intraday) when the 5-minute 9 EMA crosses above the 21 EMA; sell when it crosses back.",
          "tags": [
            "stock",
            "equity",
            "trend"
          ]
        },
        "spec": {
          "underlying": {
            "name": "RELIANCE",
            "exchange": "NSE",
            "reference": "spot"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "eq",
                "instrument": "stock",
                "side": "buy",
                "option_type": null,
                "expiry": null,
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "crosses_above(ema(9, '5m'), ema(21, '5m'))",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "crosses_below(ema(9, '5m'), ema(21, '5m'))",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:10",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 1.5
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 2000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          },
          "stock": {
            "exit_days_before_expiry": 2,
            "cover": "strategy",
            "circuit_gap_pct": 2,
            "capital_per_trade": 50000,
            "max_shares": 500
          }
        }
      }
    },
    {
      "id": "stock_opening_breakout",
      "name": "Stock opening-range breakout",
      "goal": "Catch a breakout",
      "description": "Buy RELIANCE shares intraday when a 5-minute candle closes above the first 15 minutes' high.",
      "category": "breakout",
      "category_label": "Catch a breakout",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "bar_close",
        "orb_high",
        "orb_low",
        "orb_ready"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the stock on RELIANCE. Enter when orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15)) (checked at each bar close), between 09:30 and 11:30, Monday to Friday. Exit everything when crosses_below(bar_close('5m'), orb_low(15)) (checked at each bar close). Everything closes at 15:10. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 1% against it; take profit at 2%; at most 1 trade and ₹2,000 loss a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15))` uses bar_close, orb_high, orb_ready, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`crosses_below(bar_close('5m'), orb_low(15))` uses bar_close, orb_low, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.stock",
          "label": "",
          "message": "The cash equity settings (delivery, capital sizing, circuit gap) aren't available yet",
          "code": "equity_not_available",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "RELIANCE opening breakout",
          "description": "Buy RELIANCE shares (intraday) when a 5-minute candle closes above the opening 15-minute range.",
          "tags": [
            "stock",
            "equity",
            "breakout"
          ]
        },
        "spec": {
          "underlying": {
            "name": "RELIANCE",
            "exchange": "NSE",
            "reference": "spot"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "eq",
                "instrument": "stock",
                "side": "buy",
                "option_type": null,
                "expiry": null,
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "orb_ready(15) && crosses_above(bar_close('5m'), orb_high(15))",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "11:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "crosses_below(bar_close('5m'), orb_low(15))",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:10",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 1
              },
              "target": {
                "pct": 2
              },
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 2000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 1,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          },
          "stock": {
            "exit_days_before_expiry": 2,
            "cover": "strategy",
            "circuit_gap_pct": 2,
            "capital_per_trade": 50000,
            "max_shares": 500
          }
        }
      }
    },
    {
      "id": "stock_mean_reversion",
      "name": "Stock mean reversion",
      "goal": "Bullish",
      "description": "Buy RELIANCE shares intraday after a 15-minute close below the lower Bollinger band with RSI under 30; sell at the middle band.",
      "category": "bullish",
      "category_label": "Bullish",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [
        "rsi",
        "bb_mid",
        "bb_lower",
        "bar_close"
      ],
      "needs": [],
      "summary": "Buy 1 lot of the stock on RELIANCE. Enter when bar_close('15m') < bb_lower(20, 2, '15m') && rsi(14, '15m') < 30 (checked at each bar close), between 09:45 and 14:00, Monday to Friday. Exit everything when bar_close('15m') >= bb_mid(20, 2, '15m') (checked at each bar close). Everything closes at 15:10. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: no combined stop or target; each leg: stop at 2% against it; at most 2 trades and ₹2,000 loss a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.entry.source.when",
          "label": "When · Entry",
          "message": "`bar_close('15m') < bb_lower(20, 2, '15m') && rsi(14, '15m') < 30` uses bar_close, bb_lower, rsi, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.exit.sources[0].when",
          "label": "When · Exit",
          "message": "`bar_close('15m') >= bb_mid(20, 2, '15m')` uses bar_close, bb_mid, which isn't available on this server yet (indicator conditions are coming). This strategy can be saved but not deployed until then.",
          "code": "not_available_yet",
          "needs_ack": false
        },
        {
          "field": "spec.stock",
          "label": "",
          "message": "The cash equity settings (delivery, capital sizing, circuit gap) aren't available yet",
          "code": "equity_not_available",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "RELIANCE mean reversion",
          "description": "Buy RELIANCE shares (intraday) when price closes below the 15-minute lower Bollinger band with RSI under 30; sell back at the middle band.",
          "tags": [
            "stock",
            "equity",
            "mean-reversion"
          ]
        },
        "spec": {
          "underlying": {
            "name": "RELIANCE",
            "exchange": "NSE",
            "reference": "spot"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "eq",
                "instrument": "stock",
                "side": "buy",
                "option_type": null,
                "expiry": null,
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": "bar_close('15m') < bb_lower(20, 2, '15m') && rsi(14, '15m') < 30",
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:45",
              "end": "14:00"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [
              {
                "type": "conditions@1",
                "when": "bar_close('15m') >= bb_mid(20, 2, '15m')",
                "on": "close",
                "hold_s": 0
              }
            ],
            "square_off": "15:10",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 2
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": null,
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": 2000,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 2,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          },
          "stock": {
            "exit_days_before_expiry": 2,
            "cover": "strategy",
            "circuit_gap_pct": 2,
            "capital_per_trade": 50000,
            "max_shares": 500
          }
        }
      }
    },
    {
      "id": "covered_call",
      "name": "Covered call",
      "goal": "Earn with protection",
      "description": "Buy RELIANCE shares and sell a 5% out-of-the-money monthly call against them; close two trading days before expiry.",
      "category": "hedged_income",
      "category_label": "Earn with protection",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Buy 1 lot of the stock and sell 1 lot of a 5% OTM call (monthly expiry) on RELIANCE. Enter as soon as the window opens, between 09:30 and 14:30, Monday to Friday. Positional: trades may stay open overnight. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹15,000 loss; no per-leg stops; at most 1 trade a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.underlying.name",
          "label": "Trade · Underlying",
          "message": "Stock options and futures aren't available for automated strategies yet",
          "code": "stock_fno_not_available",
          "needs_ack": false
        },
        {
          "field": "spec.stock",
          "label": "",
          "message": "The cash equity settings (delivery, capital sizing, circuit gap) aren't available yet",
          "code": "equity_not_available",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "RELIANCE covered call",
          "description": "A buy-write on RELIANCE - buy exactly the shares one monthly call covers and sell that call 5% out of the money; everything closes two trading days before expiry (stock options settle by delivery).",
          "tags": [
            "stock",
            "options",
            "income",
            "positional"
          ]
        },
        "spec": {
          "underlying": {
            "name": "RELIANCE",
            "exchange": "NFO",
            "reference": "spot"
          },
          "watch": {},
          "positional": true,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "eq",
                "instrument": "stock",
                "side": "buy",
                "option_type": null,
                "expiry": null,
                "strike": null,
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "cc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "monthly",
                  "n": 0
                },
                "strike": {
                  "by": "pct_otm",
                  "pct": 5
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": null,
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 15000
              },
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 1,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          },
          "stock": {
            "exit_days_before_expiry": 2,
            "cover": "strategy",
            "circuit_gap_pct": 2,
            "capital_per_trade": null,
            "max_shares": null
          }
        }
      }
    },
    {
      "id": "cash_secured_put",
      "name": "Cash-secured put",
      "goal": "Earn from a quiet market",
      "description": "Sell a 5% out-of-the-money monthly RELIANCE put only with the cash to buy the shares; close two trading days before expiry.",
      "category": "income",
      "category_label": "Earn from a quiet market",
      "risk": "unlimited",
      "risk_label": "Unlimited loss",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "indicators": [],
      "needs": [],
      "summary": "Sell 1 lot of a 5% OTM put (monthly expiry) on RELIANCE. Enter as soon as the window opens, between 09:30 and 14:30, Monday to Friday. Positional: trades may stay open overnight. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹10,000 loss; each leg: stop at 100% against it; at most 1 trade a day. No re-entries.",
      "deployable": false,
      "blockers": [
        {
          "field": "spec.underlying.name",
          "label": "Trade · Underlying",
          "message": "Stock options and futures aren't available for automated strategies yet",
          "code": "stock_fno_not_available",
          "needs_ack": false
        }
      ],
      "warnings": [],
      "document": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "RELIANCE cash-secured put",
          "description": "Sell a monthly RELIANCE put 5% out of the money when the account holds the cash to buy the shares at the strike; everything closes two trading days before expiry (stock options settle by delivery).",
          "tags": [
            "stock",
            "options",
            "income",
            "positional"
          ]
        },
        "spec": {
          "underlying": {
            "name": "RELIANCE",
            "exchange": "NFO",
            "reference": "spot"
          },
          "watch": {},
          "positional": true,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "monthly",
                  "n": 0
                },
                "strike": {
                  "by": "pct_otm",
                  "pct": 5
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "14:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": null,
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": {
                "pct": 100
              },
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 10000
              },
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 1,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          },
          "stock": {
            "exit_days_before_expiry": 2,
            "cover": "strategy",
            "circuit_gap_pct": 2,
            "capital_per_trade": null,
            "max_shares": null
          }
        }
      }
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/stock-status

Scope: `read`

Today's stock safety checks

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `symbols` | `string` | yes | NSE symbols, comma separated (1 to 50), e.g. `RELIANCE,TCS`. |
| `circuit_gap_pct` | `number (double)` | no | The circuit gap to check (0.5 to 10 percent of the limit, default 2): a strategy's `stock.circuit_gap_pct`. |

```bash
curl "https://api.cirrus.trade/v2/strategies/stock-status?symbols=<symbols>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trading_day` | `string` | yes | Today (`YYYY-MM-DD`, IST): the day the lists must be for. |
| `circuit_gap_pct` | `number (double)` | yes | The circuit gap used (percent of the limit). |
| `stocks` | `object[]` | yes |  |
| `stocks[].name` | `string` | yes | As asked, upper case. |
| `stocks[].new_entries` | `string` | yes | Worst of the checks: `blocked` when any blocks, else `unknown` when any is not known, else `clear`. The ban applies to options and futures, the circuit band to shares, the ex-date to both. One of: `clear`, `blocked`, `unknown`. |
| `stocks[].fno_ban` | `object` | yes | One check, in words. |
| `stocks[].fno_ban.state` | `string` | yes | The outcome of one check. One of: `clear`, `blocked`, `unknown`. |
| `stocks[].fno_ban.code` | `string \| null` | no | The engine's waiting code when not `clear` (`fno_ban`, `fno_ban_unknown`, `ex_date`, `corporate_actions_unknown`, `circuit`, `circuit_unknown`); null when clear. |
| `stocks[].fno_ban.reason` | `string \| null` | no | Why, in plain English; null when clear. |
| `stocks[].ex_date` | `object` | yes | One check, in words. |
| `stocks[].ex_date.state` | `string` | yes | The outcome of one check. One of: `clear`, `blocked`, `unknown`. |
| `stocks[].ex_date.code` | `string \| null` | no | The engine's waiting code when not `clear` (`fno_ban`, `fno_ban_unknown`, `ex_date`, `corporate_actions_unknown`, `circuit`, `circuit_unknown`); null when clear. |
| `stocks[].ex_date.reason` | `string \| null` | no | Why, in plain English; null when clear. |
| `stocks[].corporate_actions` | `string[]` | yes | Today's corporate actions of the stock (empty when none or not known). |
| `stocks[].circuit` | `object` | yes | The circuit check with the band it read. |
| `stocks[].circuit.state` | `string` | yes | The outcome of one check. One of: `clear`, `blocked`, `unknown`. |
| `stocks[].circuit.code` | `string \| null` | no | `circuit` or `circuit_unknown` when not `clear`; null when clear. |
| `stocks[].circuit.reason` | `string \| null` | no | Why, in plain English; null when clear. |
| `stocks[].circuit.ltp` | `number (double) \| null` | no | Latest price; null when no fresh quote. |
| `stocks[].circuit.lower_circuit` | `number (double) \| null` | no |  |
| `stocks[].circuit.upper_circuit` | `number (double) \| null` | no |  |

**Example: An ex-date, a stock near its circuit and a banned one (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-26",
    "circuit_gap_pct": 2,
    "stocks": [
      {
        "name": "RELIANCE",
        "new_entries": "blocked",
        "fno_ban": {
          "state": "clear",
          "code": null,
          "reason": null
        },
        "ex_date": {
          "state": "blocked",
          "code": "ex_date",
          "reason": "RELIANCE goes ex-date today"
        },
        "corporate_actions": [
          "Dividend - Rs 10 Per Share"
        ],
        "circuit": {
          "state": "clear",
          "code": null,
          "reason": null,
          "ltp": 1400,
          "lower_circuit": 1260,
          "upper_circuit": 1540
        }
      },
      {
        "name": "TCS",
        "new_entries": "blocked",
        "fno_ban": {
          "state": "clear",
          "code": null,
          "reason": null
        },
        "ex_date": {
          "state": "clear",
          "code": null,
          "reason": null
        },
        "corporate_actions": [],
        "circuit": {
          "state": "blocked",
          "code": "circuit",
          "reason": "TCS: it trades at 3500.00, within 2% of its upper circuit 3520.00",
          "ltp": 3500,
          "lower_circuit": 3150,
          "upper_circuit": 3520
        }
      },
      {
        "name": "IDEA",
        "new_entries": "blocked",
        "fno_ban": {
          "state": "blocked",
          "code": "fno_ban",
          "reason": "IDEA is in NSE's F&O ban period today"
        },
        "ex_date": {
          "state": "clear",
          "code": null,
          "reason": null
        },
        "corporate_actions": [],
        "circuit": {
          "state": "unknown",
          "code": "circuit_unknown",
          "reason": "IDEA: its circuit limits or price are not known",
          "ltp": null,
          "lower_circuit": null,
          "upper_circuit": null
        }
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: Not an NSE symbol (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "A B isn't an NSE symbol",
    "field": "symbols"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

## Automated strategies

Write, check, version and share strategy documents.

### GET /v2/strategies

Scope: `read`

List strategies

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `archived` | `boolean` | no | Include archived strategies (default false). |

```bash
curl "https://api.cirrus.trade/v2/strategies" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes |  |
| `[].name` | `string` | yes |  |
| `[].description` | `string` | yes |  |
| `[].tags` | `string[]` | yes |  |
| `[].display_status` | `string` | yes | Derived from its deployments: `draft`, `active`, `paused`, `stopped` or `error`. One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `[].latest_version` | `integer (int32)` | yes | Newest saved version (0: none yet). |
| `[].revision` | `integer (int64)` | yes |  |
| `[].deployments` | `object` | yes | Deployments of a strategy by status. |
| `[].deployments.active` | `integer` | yes |  |
| `[].deployments.paused` | `integer` | yes |  |
| `[].deployments.error` | `integer` | yes |  |
| `[].deployments.stopped` | `integer` | yes |  |
| `[].updated_at` | `string` | yes |  |
| `[].pnl` | `object \| null` | no | Today's P&L over all its deployments (from the strategy ledgers); absent when the ledgers cannot be read just now. |
| `[].pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `[].pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `[].pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `[].pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `[].pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |

**Example: The caller's strategies (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "01M59F60KB7GW11NNBWTQPQ8YX",
      "name": "NIFTY straddle",
      "description": "",
      "tags": [
        "weekly"
      ],
      "display_status": "draft",
      "latest_version": 0,
      "revision": 1,
      "deployments": {
        "active": 0,
        "paused": 0,
        "error": 0,
        "stopped": 0
      },
      "updated_at": "2026-10-19T06:57:00.011Z",
      "pnl": {
        "trading_day": "2026-10-19",
        "realized": 0,
        "unrealized": 0,
        "total": 0,
        "ltp_stale": false
      }
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies

Scope: `strategies`

Create a strategy

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | `any` | yes | The portable document: an object, or its JSON or YAML text (`schema_version`, `kind`, `metadata`, `spec`). |
| `definition` | `object \| null` | no | Instead of `document`: the definition (`spec`) alone. |
| `name` | `string \| null` | no | With `definition`: the strategy's name (at most 60 characters). |
| `description` | `string \| null` | no | With `definition`. |
| `tags` | `string[] \| null` | no | With `definition`. |
| `revision` | `integer (int64) \| null` | no | Update only: the `revision` last read; a draft changed since is refused (409 `strategy_busy`) rather than overwritten. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO"
        },
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              }
            }
          ]
        },
        "entry": {
          "window": {
            "start": "09:30",
            "end": "13:30"
          }
        },
        "risk": {
          "combined": {
            "type": "combined@1",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            }
          }
        }
      }
    }
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `latest_version` | `integer (int32)` | yes |  |
| `revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `forked_from` | `object \| null` | no |  |
| `forked_from.strategy_id` | `string` | yes |  |
| `forked_from.version` | `integer (int32)` | yes |  |
| `archived` | `boolean` | yes |  |
| `deployments` | `object` | yes | Deployments of a strategy by status. |
| `deployments.active` | `integer` | yes |  |
| `deployments.paused` | `integer` | yes |  |
| `deployments.error` | `integer` | yes |  |
| `deployments.stopped` | `integer` | yes |  |
| `summary` | `string` | yes | The draft in plain English. |
| `deployable` | `boolean` | yes | The draft has no blockers. |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `schema_version` | `integer (int32)` | yes |  |
| `draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |

**Example: A short straddle saved as a draft (201)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKA19TCCDQSGVJ9EN831",
    "name": "NIFTY straddle",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "draft",
    "latest_version": 0,
    "revision": 1,
    "visibility": "private",
    "forked_from": null,
    "archived": false,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 0
    },
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-18T09:18:04.097Z",
    "updated_at": "2026-10-18T09:18:04.097Z"
  },
  "error": null
}
```

**Example: An unknown body field (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `documnet`, expected one of `document`, `definition`, `name`, `description`, `tags`, `revision`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: Every problem, per field, in plain words (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_document",
    "message": "2 problems:\nLeg 2 · Strike: delta must be between 0.05 and 0.5 (found 0.8).\nWhen · Exit: This file has `spec.exit.trailing_magic`, which isn't recognised. It may come from a newer version or a typo.",
    "field": "spec.legs.items[1].strike.delta"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_document` | The strategy document has problems; `message` lists each (where in the wizard, what is wrong) and `field` names the first. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/{strategy_id}

Scope: `read`

Get a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `latest_version` | `integer (int32)` | yes |  |
| `revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `forked_from` | `object \| null` | no |  |
| `forked_from.strategy_id` | `string` | yes |  |
| `forked_from.version` | `integer (int32)` | yes |  |
| `archived` | `boolean` | yes |  |
| `deployments` | `object` | yes | Deployments of a strategy by status. |
| `deployments.active` | `integer` | yes |  |
| `deployments.paused` | `integer` | yes |  |
| `deployments.error` | `integer` | yes |  |
| `deployments.stopped` | `integer` | yes |  |
| `summary` | `string` | yes | The draft in plain English. |
| `deployable` | `boolean` | yes | The draft has no blockers. |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `schema_version` | `integer (int32)` | yes |  |
| `draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |

**Example: One strategy with its normalized draft (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKA19TCCDQSGVJ9EN831",
    "name": "NIFTY straddle",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "draft",
    "latest_version": 0,
    "revision": 1,
    "visibility": "private",
    "forked_from": null,
    "archived": false,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 0
    },
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-18T09:18:04.097Z",
    "updated_at": "2026-10-18T09:18:04.097Z"
  },
  "error": null
}
```

**Example: A fork whose source has a newer version (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M59PAXDGXDRKKW2BBQ8T7AZ7",
    "name": "NIFTY straddle",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "draft",
    "latest_version": 0,
    "revision": 1,
    "visibility": "private",
    "forked_from": {
      "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
      "version": 1
    },
    "archived": false,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 0
    },
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": null,
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-19T09:02:00.624Z",
    "updated_at": "2026-10-19T09:02:00.624Z",
    "update_available": {
      "version": 2,
      "forked_version": 1,
      "message": "A newer version (v2) from @user… is available. Your draft and running deployments don't change unless you update them."
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### PATCH /v2/strategies/{strategy_id}

Scope: `strategies`

Edit a strategy's draft

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | `any` | yes | The portable document: an object, or its JSON or YAML text (`schema_version`, `kind`, `metadata`, `spec`). |
| `definition` | `object \| null` | no | Instead of `document`: the definition (`spec`) alone. |
| `name` | `string \| null` | no | With `definition`: the strategy's name (at most 60 characters). |
| `description` | `string \| null` | no | With `definition`. |
| `tags` | `string[] \| null` | no | With `definition`. |
| `revision` | `integer (int64) \| null` | no | Update only: the `revision` last read; a draft changed since is refused (409 `strategy_busy`) rather than overwritten. |

```bash
curl -X PATCH "https://api.cirrus.trade/v2/strategies/<strategy_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle, 2 lots",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO"
        },
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              },
              "lots": 2
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly"
              },
              "strike": {
                "by": "atm"
              },
              "lots": 2
            }
          ]
        },
        "entry": {
          "window": {
            "start": "09:30",
            "end": "13:30"
          }
        },
        "risk": {
          "combined": {
            "type": "combined@1",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            }
          }
        }
      }
    },
    "revision": 1
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `latest_version` | `integer (int32)` | yes |  |
| `revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `forked_from` | `object \| null` | no |  |
| `forked_from.strategy_id` | `string` | yes |  |
| `forked_from.version` | `integer (int32)` | yes |  |
| `archived` | `boolean` | yes |  |
| `deployments` | `object` | yes | Deployments of a strategy by status. |
| `deployments.active` | `integer` | yes |  |
| `deployments.paused` | `integer` | yes |  |
| `deployments.error` | `integer` | yes |  |
| `deployments.stopped` | `integer` | yes |  |
| `summary` | `string` | yes | The draft in plain English. |
| `deployable` | `boolean` | yes | The draft has no blockers. |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `schema_version` | `integer (int32)` | yes |  |
| `draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |

**Example: A new draft (revision checked) (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKA19TCCDQSGVJ9EN831",
    "name": "NIFTY straddle, 2 lots",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "draft",
    "latest_version": 0,
    "revision": 2,
    "visibility": "private",
    "forked_from": null,
    "archived": false,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 0
    },
    "summary": "Sell 2 lots of an ATM call (weekly expiry) and sell 2 lots of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle, 2 lots",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-18T09:18:04.097Z",
    "updated_at": "2026-10-18T09:18:05.038Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: The draft changed since revision 1 (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_busy",
    "message": "The strategy changed since it was read (or is archived); read it again and retry",
    "field": null
  }
}
```

**Example: A draft with problems is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_document",
    "message": "2 problems:\nLeg 2 · Strike: delta must be between 0.05 and 0.5 (found 0.8).\nWhen · Exit: This file has `spec.exit.trailing_magic`, which isn't recognised. It may come from a newer version or a typo.",
    "field": "spec.legs.items[1].strike.delta"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 409 | `strategy_busy` | The strategy changed since it was read (or is archived); read it again and retry. |
| 422 | `invalid_document` | The strategy document has problems; `message` lists each (where in the wizard, what is wrong) and `field` names the first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### DELETE /v2/strategies/{strategy_id}

Scope: `strategies`

Archive a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v2/strategies/<strategy_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `latest_version` | `integer (int32)` | yes |  |
| `revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `forked_from` | `object \| null` | no |  |
| `forked_from.strategy_id` | `string` | yes |  |
| `forked_from.version` | `integer (int32)` | yes |  |
| `archived` | `boolean` | yes |  |
| `deployments` | `object` | yes | Deployments of a strategy by status. |
| `deployments.active` | `integer` | yes |  |
| `deployments.paused` | `integer` | yes |  |
| `deployments.error` | `integer` | yes |  |
| `deployments.stopped` | `integer` | yes |  |
| `summary` | `string` | yes | The draft in plain English. |
| `deployable` | `boolean` | yes | The draft has no blockers. |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `schema_version` | `integer (int32)` | yes |  |
| `draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |

**Example: Archived after its deployment stopped (200)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VKA19TCCDQSGVJ9EN831",
    "name": "NIFTY straddle, edited again",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "stopped",
    "latest_version": 2,
    "revision": 4,
    "visibility": "private",
    "forked_from": null,
    "archived": true,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 1
    },
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle, edited again",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-18T09:18:04.097Z",
    "updated_at": "2026-10-18T09:18:07.208Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: A deployment is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_in_use",
    "message": "Stop this strategy's deployments before archiving it",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 409 | `strategy_in_use` | The strategy has a deployment that is not stopped; stop it first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/{strategy_id}/versions

Scope: `read`

List a strategy's versions

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>/versions" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].strategy_id` | `string` | yes |  |
| `[].n` | `integer (int32)` | yes |  |
| `[].schema_version` | `integer (int32)` | yes |  |
| `[].compiled_hash` | `string` | yes |  |
| `[].components` | `string[]` | yes |  |
| `[].summary` | `string` | yes |  |
| `[].deployable` | `boolean` | yes |  |
| `[].blockers` | `object[]` | yes |  |
| `[].blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `[].blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `[].blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `[].blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `[].blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `[].warnings` | `object[]` | yes |  |
| `[].warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `[].warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `[].warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `[].warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `[].warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `[].created_at` | `string` | yes |  |
| `[].document` | `object \| null` | no | The version's document, normalized. Left out of lists. |

**Example: Every version, newest first (200)**

```json
{
  "status": "success",
  "data": [
    {
      "strategy_id": "01M574VKA19TCCDQSGVJ9EN831",
      "n": 1,
      "schema_version": 1,
      "compiled_hash": "sha256:c17de463c06b305846097f5abda141dc76f1ea91ebdd5ced4adce96223a8a7a4",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "summary": "Sell 2 lots of an ATM call (weekly expiry) and sell 2 lots of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "created_at": "2026-10-18T09:18:05.615Z"
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/{strategy_id}/versions

Scope: `strategies`

Save the draft as a version

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `revision` | `integer (int64) \| null` | no | The draft `revision` the user saw; refused (409) when the draft changed since. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/<strategy_id>/versions" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "revision": 2
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `n` | `integer (int32)` | yes |  |
| `schema_version` | `integer (int32)` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `components` | `string[]` | yes |  |
| `summary` | `string` | yes |  |
| `deployable` | `boolean` | yes |  |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `created_at` | `string` | yes |  |
| `document` | `object \| null` | no | The version's document, normalized. Left out of lists. |

**Example: Version 1 saved from the draft (201)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M574VKA19TCCDQSGVJ9EN831",
    "n": 1,
    "schema_version": 1,
    "compiled_hash": "sha256:c17de463c06b305846097f5abda141dc76f1ea91ebdd5ced4adce96223a8a7a4",
    "components": [
      "chain@1",
      "combined@1",
      "conditions@1",
      "fixed_lots@1",
      "limits@1",
      "per_leg@1",
      "protected_limit@1"
    ],
    "summary": "Sell 2 lots of an ATM call (weekly expiry) and sell 2 lots of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "created_at": "2026-10-18T09:18:05.615Z",
    "document": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle, 2 lots",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: The draft changed since (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_busy",
    "message": "The strategy changed since it was read (or is archived); read it again and retry",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 409 | `strategy_busy` | The strategy changed since it was read (or is archived); read it again and retry. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/{strategy_id}/versions/{n}

Scope: `read`

Get a version

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |
| `n` | `integer (int32)` | yes | Version number. |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>/versions/<n>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `n` | `integer (int32)` | yes |  |
| `schema_version` | `integer (int32)` | yes |  |
| `compiled_hash` | `string` | yes |  |
| `components` | `string[]` | yes |  |
| `summary` | `string` | yes |  |
| `deployable` | `boolean` | yes |  |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `created_at` | `string` | yes |  |
| `document` | `object \| null` | no | The version's document, normalized. Left out of lists. |

**Example: Version 1, unchanged by later edits (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M574VKA19TCCDQSGVJ9EN831",
    "n": 1,
    "schema_version": 1,
    "compiled_hash": "sha256:c17de463c06b305846097f5abda141dc76f1ea91ebdd5ced4adce96223a8a7a4",
    "components": [
      "chain@1",
      "combined@1",
      "conditions@1",
      "fixed_lots@1",
      "limits@1",
      "per_leg@1",
      "protected_limit@1"
    ],
    "summary": "Sell 2 lots of an ATM call (weekly expiry) and sell 2 lots of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "created_at": "2026-10-18T09:18:05.615Z",
    "document": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle, 2 lots",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    }
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such version (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "version_not_found",
    "message": "Version not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `version_not_found` | No such version of the strategy. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/{strategy_id}/clone

Scope: `strategies`

Clone a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `version` | `integer (int32) \| null` | no | The version to copy (default: the draft). |
| `name` | `string \| null` | no | Name of the copy (default: `Copy of <name>`). |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/<strategy_id>/clone" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "version": 1,
    "name": "My copy"
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `latest_version` | `integer (int32)` | yes |  |
| `revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `forked_from` | `object \| null` | no |  |
| `forked_from.strategy_id` | `string` | yes |  |
| `forked_from.version` | `integer (int32)` | yes |  |
| `archived` | `boolean` | yes |  |
| `deployments` | `object` | yes | Deployments of a strategy by status. |
| `deployments.active` | `integer` | yes |  |
| `deployments.paused` | `integer` | yes |  |
| `deployments.error` | `integer` | yes |  |
| `deployments.stopped` | `integer` | yes |  |
| `summary` | `string` | yes | The draft in plain English. |
| `deployable` | `boolean` | yes | The draft has no blockers. |
| `blockers` | `object[]` | yes |  |
| `blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `warnings` | `object[]` | yes |  |
| `warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `schema_version` | `integer (int32)` | yes |  |
| `draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |

**Example: A new draft from version 1 (201)**

```json
{
  "status": "success",
  "data": {
    "id": "01M574VNT35HA09X24PQ7N0TD5",
    "name": "My copy",
    "description": "",
    "tags": [
      "weekly"
    ],
    "display_status": "draft",
    "latest_version": 0,
    "revision": 1,
    "visibility": "private",
    "forked_from": {
      "strategy_id": "01M574VKA19TCCDQSGVJ9EN831",
      "version": 1
    },
    "archived": false,
    "deployments": {
      "active": 0,
      "paused": 0,
      "error": 0,
      "stopped": 0
    },
    "summary": "Sell 2 lots of an ATM call (weekly expiry) and sell 2 lots of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
    "deployable": true,
    "blockers": [],
    "warnings": [],
    "schema_version": 1,
    "draft": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "My copy",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 2,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "created_at": "2026-10-18T09:18:06.659Z",
    "updated_at": "2026-10-18T09:18:06.659Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such version (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "version_not_found",
    "message": "Version not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/strategies/{strategy_id}/export

Scope: `read`

Export a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | `string` | no | `json` (default) or `yaml`. |
| `version` | `integer (int32)` | no | A version (default: the draft). |

```bash
curl "https://api.cirrus.trade/v2/strategies/<strategy_id>/export" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | `string` | yes | `json` or `yaml`. |
| `filename` | `string` | yes | Suggested file name (`<name>.strategy.yaml` or `.json`). |
| `version` | `integer (int32) \| null` | no | The exported version (null: the draft). |
| `schema_version` | `integer (int32)` | yes |  |
| `document` | `object \| null` | no |  |
| `document_yaml` | `string \| null` | no | The YAML text (for `format=yaml`). |

**Example: The draft as JSON (200)**

```json
{
  "status": "success",
  "data": {
    "format": "json",
    "filename": "nifty-straddle.strategy.json",
    "version": null,
    "schema_version": 1,
    "document": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": {
              "rupees": 2000
            },
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    },
    "document_yaml": null
  },
  "error": null
}
```

**Example: The draft as YAML (200)**

```json
{
  "status": "success",
  "data": {
    "format": "yaml",
    "filename": "nifty-straddle.strategy.yaml",
    "version": null,
    "schema_version": 1,
    "document": null,
    "document_yaml": "$schema: /v2/strategies/schema/1.json\nschema_version: 1\nkind: cirrus.strategy\nmetadata:\n  name: NIFTY straddle\n  description: \"\"\n  tags: [weekly]\nspec:\n  underlying:\n    name: NIFTY\n    exchange: NFO\n    reference: future\n  watch: {}\n  positional: false\n  legs:\n    selector: \"chain@1\"\n    items:\n      - id: sc\n        instrument: option\n        side: sell\n        option_type: call\n        expiry:\n          kind: weekly\n          \"n\": 0\n        strike:\n          by: atm\n          offset: 0\n        lots: 1\n        hedge_for: null\n        risk: null\n        liquidity:\n          max_spread_pct: 3\n          min_oi: 0\n          min_premium: 1\n      - id: sp\n        instrument: option\n        side: sell\n        option_type: put\n        expiry:\n          kind: weekly\n          \"n\": 0\n        strike:\n          by: atm\n          offset: 0\n        lots: 1\n        hedge_for: null\n        risk: null\n        liquidity:\n          max_spread_pct: 3\n          min_oi: 0\n          min_premium: 1\n  entry:\n    source:\n      type: \"conditions@1\"\n      when: null\n      \"on\": close\n      hold_s: 0\n    window:\n      start: \"09:30\"\n      end: \"13:30\"\n    skip_first_min: 0\n    skip_last_min: 0\n    days: [mon, tue, wed, thu, fri]\n    expiry_day: normal\n    active_from: null\n    active_until: null\n  exit:\n    sources: []\n    square_off: \"15:15\"\n    expiry_day_exit: null\n    max_hold_min: null\n  sizing:\n    type: \"fixed_lots@1\"\n    max_lots: 10\n  execution:\n    type: \"protected_limit@1\"\n    order: auto\n    hedge_wait_ms: 5000\n    stagger_ms: 0\n    buffer_pct: 0.5\n    chase:\n      every_ms: 1000\n      max_slippage_pct: 1\n      max_attempts: 10\n    on_incomplete: safest\n    retry_next_signal: null\n  risk:\n    per_leg:\n      type: \"per_leg@1\"\n      stop_loss: null\n      target: null\n      trail: null\n      on_leg_stop: nothing\n      at_broker: false\n    combined:\n      type: \"combined@1\"\n      scope: account\n      basis: rupees\n      stop_loss:\n        rupees: 3000\n      target:\n        rupees: 2000\n      breakeven: null\n      trail: null\n      lock_ladder: []\n      deployment: null\n    limits:\n      type: \"limits@1\"\n      max_loss_per_day: null\n      max_profit_per_day: null\n      max_loss_per_week: null\n      max_cycles_per_day: 3\n      max_orders_per_day: 50\n      exit_on_limit: true\n      on_breaker: nothing\n  reentry:\n    \"on\": []\n    max: 0\n    delay_s: 300\n    require_fresh_signal: true\n    same_direction_only: true\n    until: null\n  adjustments: []\n  notify: []\n  costs:\n    model: auto\n    slippage_pct: null\n"
  },
  "error": null
}
```

**Example: Only json or yaml (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_query",
    "message": "format must be json or yaml",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No such strategy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### POST /v2/strategies/import

Scope: `strategies`

Import a strategy

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | `any` | no | The document: an object, or its JSON or YAML text (or `from`). |
| `from` | `object \| null` | no | Where an import comes from. |
| `from.strategy_id` | `string` | no | One of the caller's own strategies, or (sharing on) one shared with the caller by name. |
| `from.version` | `integer (int32) \| null` | no | Default: its latest version (or, for your own, its draft when none is saved). A shared strategy forks saved versions only. |
| `from.link_token` | `string \| null` | no | A share link's token instead of `strategy_id` (sharing on): forks the version the link shows. |

```bash
curl -X POST "https://api.cirrus.trade/v2/strategies/import" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": {
      "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E"
    }
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy` | `object` | yes | A strategy with its draft. |
| `strategy.id` | `string` | yes |  |
| `strategy.name` | `string` | yes |  |
| `strategy.description` | `string` | yes |  |
| `strategy.tags` | `string[]` | yes |  |
| `strategy.display_status` | `string` | yes | The strategy's display status, derived from its deployments (§3.2). One of: `draft`, `active`, `paused`, `stopped`, `error`. |
| `strategy.latest_version` | `integer (int32)` | yes |  |
| `strategy.revision` | `integer (int64)` | yes | Send it back on update to refuse overwriting a newer draft. |
| `strategy.visibility` | `string` | yes | `private`, or (sharing on) `users` / `link`. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `strategy.forked_from` | `object \| null` | no |  |
| `strategy.forked_from.strategy_id` | `string` | yes |  |
| `strategy.forked_from.version` | `integer (int32)` | yes |  |
| `strategy.archived` | `boolean` | yes |  |
| `strategy.deployments` | `object` | yes | Deployments of a strategy by status. |
| `strategy.deployments.active` | `integer` | yes |  |
| `strategy.deployments.paused` | `integer` | yes |  |
| `strategy.deployments.error` | `integer` | yes |  |
| `strategy.deployments.stopped` | `integer` | yes |  |
| `strategy.summary` | `string` | yes | The draft in plain English. |
| `strategy.deployable` | `boolean` | yes | The draft has no blockers. |
| `strategy.blockers` | `object[]` | yes |  |
| `strategy.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `strategy.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `strategy.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `strategy.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `strategy.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `strategy.warnings` | `object[]` | yes |  |
| `strategy.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `strategy.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `strategy.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `strategy.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `strategy.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `strategy.schema_version` | `integer (int32)` | yes |  |
| `strategy.draft` | `object` | yes | The draft document, normalized (defaults written out). |
| `strategy.created_at` | `string` | yes |  |
| `strategy.updated_at` | `string` | yes |  |
| `strategy.update_available` | `object \| null` | no | On a fork whose source (still shared with you) has a newer saved version. Your draft and deployments never change on their own. |
| `strategy.update_available.version` | `integer (int32)` | yes | The source's newest saved version. |
| `strategy.update_available.forked_version` | `integer (int32)` | yes | The fork's pinned version. |
| `strategy.update_available.message` | `string` | yes | `A newer version (v4) from @prat… is available.` |
| `validation` | `object` | yes | What checking a document found. |
| `validation.ok` | `boolean` | yes | No errors: the document can be saved. |
| `validation.deployable` | `boolean` | yes | No errors and no blockers: a version of it can be deployed. |
| `validation.schema_version` | `integer (int32)` | yes | Format of the checked document (after migration). |
| `validation.migrated_from` | `integer (int32) \| null` | no | The format it was written in, when older (it was migrated). |
| `validation.errors` | `object[]` | yes | Refused: fix these to save. |
| `validation.errors[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.errors[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.errors[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.errors[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.errors[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.blockers` | `object[]` | yes | Saved as a draft, not deployable until resolved. |
| `validation.blockers[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.blockers[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.blockers[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.blockers[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.blockers[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.warnings` | `object[]` | yes |  |
| `validation.warnings[].field` | `string` | yes | Document path, e.g. `spec.legs.items[1].strike.delta`. |
| `validation.warnings[].label` | `string` | yes | Where in the wizard, e.g. `Leg 2 · Strike`. |
| `validation.warnings[].message` | `string` | yes | What is wrong, e.g. `delta must be between 0.05 and 0.5 (found 0.8).`. |
| `validation.warnings[].code` | `string` | yes | Stable code (`unknown_field`, `out_of_range`, `not_available_yet`, ...). |
| `validation.warnings[].needs_ack` | `boolean` | yes | A warning that must be acknowledged (by `code`) to deploy. |
| `validation.needs_ack` | `string[]` | yes | Warning codes to acknowledge at deploy (`unlimited_loss`). |
| `validation.summary` | `string \| null` | no | The strategy in plain English. |
| `validation.compiled_hash` | `string \| null` | no | Hash of the compiled spec (same for any way of writing the same strategy). |
| `validation.components` | `string[]` | yes | `name@major` of every component used. |
| `validation.warm_up` | `object \| null` | no | Indicator warm-up (from the engine; null until it is connected). |

**Example: A version shared with you, forked into your own draft (201)**

```json
{
  "status": "success",
  "data": {
    "strategy": {
      "id": "01M59PAXDGXDRKKW2BBQ8T7AZ7",
      "name": "NIFTY straddle",
      "description": "",
      "tags": [
        "weekly"
      ],
      "display_status": "draft",
      "latest_version": 0,
      "revision": 1,
      "visibility": "private",
      "forked_from": {
        "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
        "version": 1
      },
      "archived": false,
      "deployments": {
        "active": 0,
        "paused": 0,
        "error": 0,
        "stopped": 0
      },
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "schema_version": 1,
      "draft": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY straddle",
          "description": "",
          "tags": [
            "weekly"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": null,
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      },
      "created_at": "2026-10-19T09:02:00.624Z",
      "updated_at": "2026-10-19T09:02:00.624Z"
    },
    "validation": {
      "ok": true,
      "deployable": true,
      "schema_version": 1,
      "migrated_from": null,
      "errors": [],
      "blockers": [],
      "warnings": [],
      "needs_ack": [],
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
      "compiled_hash": "sha256:44ffd35c2d50344cd553dba925e707521b55be5023eae9a4a165acdc1190a9cf",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "warm_up": null
    }
  },
  "error": null
}
```

**Example: A pasted YAML document becomes a draft (201)**

```json
{
  "status": "success",
  "data": {
    "strategy": {
      "id": "01M574VMP7NY5WR1XNRJ9WF4P5",
      "name": "NIFTY straddle",
      "description": "",
      "tags": [
        "weekly"
      ],
      "display_status": "draft",
      "latest_version": 0,
      "revision": 1,
      "visibility": "private",
      "forked_from": null,
      "archived": false,
      "deployments": {
        "active": 0,
        "paused": 0,
        "error": 0,
        "stopped": 0
      },
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "deployable": true,
      "blockers": [],
      "warnings": [],
      "schema_version": 1,
      "draft": {
        "$schema": "/v2/strategies/schema/1.json",
        "schema_version": 1,
        "kind": "cirrus.strategy",
        "metadata": {
          "name": "NIFTY straddle",
          "description": "",
          "tags": [
            "weekly"
          ]
        },
        "spec": {
          "underlying": {
            "name": "NIFTY",
            "exchange": "NFO",
            "reference": "future"
          },
          "watch": {},
          "positional": false,
          "legs": {
            "selector": "chain@1",
            "items": [
              {
                "id": "sc",
                "instrument": "option",
                "side": "sell",
                "option_type": "call",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              },
              {
                "id": "sp",
                "instrument": "option",
                "side": "sell",
                "option_type": "put",
                "expiry": {
                  "kind": "weekly",
                  "n": 0
                },
                "strike": {
                  "by": "atm",
                  "offset": 0
                },
                "lots": 1,
                "hedge_for": null,
                "risk": null,
                "liquidity": {
                  "max_spread_pct": 3,
                  "min_oi": 0,
                  "min_premium": 1
                }
              }
            ]
          },
          "entry": {
            "source": {
              "type": "conditions@1",
              "when": null,
              "on": "close",
              "hold_s": 0
            },
            "window": {
              "start": "09:30",
              "end": "13:30"
            },
            "skip_first_min": 0,
            "skip_last_min": 0,
            "days": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "expiry_day": "normal",
            "active_from": null,
            "active_until": null
          },
          "exit": {
            "sources": [],
            "square_off": "15:15",
            "expiry_day_exit": null,
            "max_hold_min": null
          },
          "sizing": {
            "type": "fixed_lots@1",
            "max_lots": 10
          },
          "execution": {
            "type": "protected_limit@1",
            "order": "auto",
            "hedge_wait_ms": 5000,
            "stagger_ms": 0,
            "buffer_pct": 0.5,
            "chase": {
              "every_ms": 1000,
              "max_slippage_pct": 1,
              "max_attempts": 10
            },
            "on_incomplete": "safest",
            "retry_next_signal": null
          },
          "risk": {
            "per_leg": {
              "type": "per_leg@1",
              "stop_loss": null,
              "target": null,
              "trail": null,
              "on_leg_stop": "nothing",
              "at_broker": false
            },
            "combined": {
              "type": "combined@1",
              "scope": "account",
              "basis": "rupees",
              "stop_loss": {
                "rupees": 3000
              },
              "target": {
                "rupees": 2000
              },
              "breakeven": null,
              "trail": null,
              "lock_ladder": [],
              "deployment": null
            },
            "limits": {
              "type": "limits@1",
              "max_loss_per_day": null,
              "max_profit_per_day": null,
              "max_loss_per_week": null,
              "max_cycles_per_day": 3,
              "max_orders_per_day": 50,
              "exit_on_limit": true,
              "on_breaker": "nothing"
            }
          },
          "reentry": {
            "on": [],
            "max": 0,
            "delay_s": 300,
            "require_fresh_signal": true,
            "same_direction_only": true,
            "until": null
          },
          "adjustments": [],
          "notify": [],
          "costs": {
            "model": "auto",
            "slippage_pct": null
          }
        }
      },
      "created_at": "2026-10-18T09:18:05.511Z",
      "updated_at": "2026-10-18T09:18:05.511Z"
    },
    "validation": {
      "ok": true,
      "deployable": true,
      "schema_version": 1,
      "migrated_from": null,
      "errors": [],
      "blockers": [],
      "warnings": [],
      "needs_ack": [],
      "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss, take ₹2,000 profit; no per-leg stops; at most 3 trades a day. No re-entries.",
      "compiled_hash": "sha256:c030cdecce518bdf50067237a17de0b5508d3e962e743a87dd41e7df3ef01ff9",
      "components": [
        "chain@1",
        "combined@1",
        "conditions@1",
        "fixed_lots@1",
        "limits@1",
        "per_leg@1",
        "protected_limit@1"
      ],
      "warm_up": null
    }
  },
  "error": null
}
```

**Example: An unknown body field (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `doc`, expected `document` or `from`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: No such strategy to copy (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: Unknown fields are refused, never dropped (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_document",
    "message": "When · Exit: This file has `spec.exit.trailing_magic`, which isn't recognised. It may come from a newer version or a typo.",
    "field": "spec.exit.trailing_magic"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 404 | `version_not_found` | No such version of the strategy. |
| 422 | `invalid_document` | The strategy document has problems; `message` lists each (where in the wizard, what is wrong) and `field` names the first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### PUT /v2/strategies/{strategy_id}/sharing

Scope: `strategies`

Share a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `visibility` | `string` | yes | `private`, `users` or `link` (`public`, the gallery, comes later). One of: `private`, `users`, `link`, `public`, `unknown`. |
| `shared_with` | `string[] \| null` | no | Usernames who may view and fork (for `users`; at most 50). |
| `link` | `string \| null` | no | `create` a new link (the old one stops working) or `revoke` it. One of: `create`, `revoke`. |

```bash
curl -X PUT "https://api.cirrus.trade/v2/strategies/<strategy_id>/sharing" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "visibility": "link"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `visibility` | `string` | yes | Who may see a strategy (§24.1). Only `private` is writable in phase A. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `shared_with` | `string[]` | yes |  |
| `link` | `object \| null` | no | The share link, when one is live. |
| `link.token` | `string \| null` | no | The token, only in the answer that created it: keep it, it is not shown again. Open it with `GET /v2/shared/{token}`. |
| `link.created_at` | `string` | yes |  |
| `shared_version` | `integer (int32)` | yes | The version viewers see (the newest saved one). |

**Example: Shared by link (the token is shown once) (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M59PAX5AVPZXACR63HAP9ZMM",
    "visibility": "link",
    "shared_with": [],
    "link": {
      "token": "sl_ZYTSyMlELpCrijnpbQq9qAeoPwGUt2ZiIDb8uynW",
      "created_at": "2026-10-19T09:02:00.375Z"
    },
    "shared_version": 1
  },
  "error": null
}
```

**Example: Version 1 shared with one user (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
    "visibility": "users",
    "shared_with": [
      "owner-01M59PAX3ZRBAGC80PT5N24KQR"
    ],
    "link": null,
    "shared_version": 1
  },
  "error": null
}
```

**Example: Someone else's strategy: as if it did not exist (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Example: Nothing saved yet (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "no_saved_version",
    "message": "Save a version first: a share is a saved version, never the draft.",
    "field": null
  }
}
```

**Example: The public gallery comes later (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "the public gallery comes later; share with users or by link",
    "field": "visibility"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 409 | `no_saved_version` | A strategy is shared as a saved version, never its draft: save a version first. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### DELETE /v2/strategies/{strategy_id}/sharing

Scope: `strategies`

Stop sharing a strategy

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

```bash
curl -X DELETE "https://api.cirrus.trade/v2/strategies/<strategy_id>/sharing" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `visibility` | `string` | yes | Who may see a strategy (§24.1). Only `private` is writable in phase A. One of: `private`, `users`, `link`, `public`, `unknown`. |
| `shared_with` | `string[]` | yes |  |
| `link` | `object \| null` | no | The share link, when one is live. |
| `link.token` | `string \| null` | no | The token, only in the answer that created it: keep it, it is not shown again. Open it with `GET /v2/shared/{token}`. |
| `link.created_at` | `string` | yes |  |
| `shared_version` | `integer (int32)` | yes | The version viewers see (the newest saved one). |

**Example: Private again (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
    "visibility": "private",
    "shared_with": [],
    "link": null,
    "shared_version": 2
  },
  "error": null
}
```

**Example: Someone else's strategy: as if it did not exist (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "strategy_not_found",
    "message": "Strategy not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `strategy_not_found` | No automated strategy with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

## Strategy P&L

Today's positions and P&L grouped by the strategy tag on your orders.

### GET /v1/strategies

Scope: `read`

Your strategies today

```bash
curl "https://api.cirrus.trade/v1/strategies" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trading_day` | `string (date)` | yes | IST trading day the ledgers are for. |
| `available` | `boolean` | yes | `false` while no account has a ledger yet today (`strategies` is then empty and `missing` says why for each account): not an error, the ledger appears once the account's trades are read. |
| `strategies` | `object[]` | yes | Named strategies by name, then `Unassigned`. |
| `strategies[].name` | `string \| null` | yes | The strategy's name; null for the unassigned rest. |
| `strategies[].label` | `string` | yes | The name, or `Unassigned`. |
| `strategies[].accounts` | `object[]` | yes | Per account, by account id. |
| `strategies[].accounts[].account` | `string` | yes |  |
| `strategies[].accounts[].at` | `string (date-time)` | yes | As of: the older of the trades and positions the ledger was built from. |
| `strategies[].accounts[].stale` | `boolean` | yes | The ledger was not rebuilt recently during market hours: numbers may be behind. |
| `strategies[].accounts[].rows` | `object[]` | yes |  |
| `strategies[].accounts[].rows[].instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `strategies[].accounts[].rows[].symbol` | `string \| null` | yes | Underlying (e.g. `NIFTY`) or the stock's name; null when the instrument is not in the instrument list. |
| `strategies[].accounts[].rows[].tradingsymbol` | `string \| null` | yes | The instrument list's trading symbol; null as `symbol`. |
| `strategies[].accounts[].rows[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `strategies[].accounts[].rows[].net_qty` | `integer (int64)` | yes | `+` long, `-` short, in units. |
| `strategies[].accounts[].rows[].avg_price` | `number (double)` | yes | Average price of the open quantity (`0` when flat). |
| `strategies[].accounts[].rows[].realized` | `number (double)` | yes | Booked today (no charges). |
| `strategies[].accounts[].rows[].ltp` | `number (double) \| null` | yes | Latest price; null when none is known. |
| `strategies[].accounts[].rows[].ltp_stale` | `boolean` | yes | `ltp` is not current (older than a minute, of unknown time, or missing): `unrealized` is computed from it anyway, or null when there is no price at all. Always `false` for a flat row. |
| `strategies[].accounts[].rows[].unrealized` | `number (double) \| null` | yes | `net_qty × (ltp − avg_price)`; `0` when flat, null when open without a price. |
| `strategies[].accounts[].totals` | `object` | yes | Realized and unrealized P&L (no charges). |
| `strategies[].accounts[].totals.realized` | `number (double)` | yes |  |
| `strategies[].accounts[].totals.unrealized` | `number (double)` | yes | Of the rows with a price. |
| `strategies[].accounts[].totals.total` | `number (double)` | yes | `realized + unrealized`. |
| `strategies[].accounts[].totals.ltp_stale` | `boolean` | yes | Some open row's price is not current or missing (see `StrategyRow.ltp_stale`). |
| `strategies[].totals` | `object` | yes | Across `accounts`. |
| `strategies[].totals.realized` | `number (double)` | yes |  |
| `strategies[].totals.unrealized` | `number (double)` | yes | Of the rows with a price. |
| `strategies[].totals.total` | `number (double)` | yes | `realized + unrealized`. |
| `strategies[].totals.ltp_stale` | `boolean` | yes | Some open row's price is not current or missing (see `StrategyRow.ltp_stale`). |
| `strategies[].recon` | `object` | yes |  |
| `strategies[].recon.status` | `string` | yes | The worst over the strategy's open positions. One of: `ok`, `pending`, `mismatch`. |
| `strategies[].recon.note` | `string` | yes | In plain English. |
| `strategies[].at` | `string (date-time) \| null` | yes | As of: the oldest ledger it is built from; null without one. |
| `strategies[].analytics` | `object \| null` | no | Options analytics of the open positions (IV, greeks, payoff), for a strategy whose open positions are all options / futures on one underlying; absent otherwise. `GET /v1/strategies` has the summary, `GET /v1/strategies/{name}` adds `legs` and `payoff`. |
| `strategies[].analytics.underlying` | `string \| null` | yes | The underlying (e.g. `NIFTY`); null when the positions are not on one underlying. |
| `strategies[].analytics.spot` | `number (double) \| null` | yes | The underlying's price used; null when not known. |
| `strategies[].analytics.model` | `string` | yes | `black_scholes` (on the underlying's price, dividend yield 0). |
| `strategies[].analytics.rate` | `number (double)` | yes | Annual risk-free rate used, e.g. `0.065`. |
| `strategies[].analytics.greeks` | `object \| null` | yes | Of every open leg; null when an option leg has no IV. |
| `strategies[].analytics.greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `strategies[].analytics.greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `strategies[].analytics.greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `strategies[].analytics.greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `strategies[].analytics.breakevens` | `(number (double))[]` | yes | Prices of the underlying where the P&L at expiry crosses zero (exact, also outside the payoff range), ascending. |
| `strategies[].analytics.max_profit` | `number (double) \| null` | yes | Highest P&L at expiry of the open positions; null when unlimited (or not computed). |
| `strategies[].analytics.max_profit_unlimited` | `boolean` | yes | The P&L at expiry keeps rising as the underlying rises. |
| `strategies[].analytics.max_loss` | `number (double) \| null` | yes | Lowest P&L at expiry (negative: a loss); null when unlimited (or not computed). |
| `strategies[].analytics.max_loss_unlimited` | `boolean` | yes | The P&L at expiry keeps falling as the underlying rises. |
| `strategies[].analytics.probability_of_profit` | `number (double)` | no | Chance (0 to 1) that the P&L at expiry is above zero: risk-neutral, the underlying lognormal from `spot` at `rate`, one volatility (the option legs' IVs averaged, weighted by each leg's \|vega\|) to the options' common expiry. Absent (with a `note`) when an option leg has no IV, the options expire on different dates, or there is no option leg. |
| `strategies[].analytics.note` | `string \| null` | yes | What was left out and why, in plain English; null when nothing. |
| `strategies[].analytics.legs` | `object[] \| null` | no | Per leg (`GET /v1/strategies/{name}` only). |
| `strategies[].analytics.legs[].instrument_token` | `string` | yes |  |
| `strategies[].analytics.legs[].tradingsymbol` | `string` | yes |  |
| `strategies[].analytics.legs[].instrument` | `string` | yes | `option` or `future`. |
| `strategies[].analytics.legs[].option_type` | `string \| null` | yes | `CE` or `PE`; null for a future. |
| `strategies[].analytics.legs[].strike` | `number (double) \| null` | yes |  |
| `strategies[].analytics.legs[].expiry` | `string \| null` | yes | `YYYY-MM-DD`. |
| `strategies[].analytics.legs[].days_to_expiry` | `number (double) \| null` | yes | Calendar days to 15:30 IST of the expiry day. |
| `strategies[].analytics.legs[].net_qty` | `integer (int64)` | yes | Units, summed over accounts. |
| `strategies[].analytics.legs[].avg_price` | `number (double)` | yes |  |
| `strategies[].analytics.legs[].ltp` | `number (double) \| null` | yes |  |
| `strategies[].analytics.legs[].iv_pct` | `number (double) \| null` | yes | Implied volatility from `ltp`, in percent; null when it cannot be solved (see `note`). |
| `strategies[].analytics.legs[].greeks` | `object \| null` | yes | Of the whole leg (`net_qty` units); null without an IV. |
| `strategies[].analytics.legs[].greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `strategies[].analytics.legs[].greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `strategies[].analytics.legs[].greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `strategies[].analytics.legs[].greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `strategies[].analytics.legs[].note` | `string \| null` | yes |  |
| `strategies[].analytics.payoff` | `object \| null` | no | The payoff curves (`GET /v1/strategies/{name}` only). |
| `strategies[].analytics.payoff.spots` | `(number (double))[]` | yes | Prices of the underlying, ascending (± 10% around `spot`). |
| `strategies[].analytics.payoff.at_expiry` | `(number (double))[]` | yes | At expiry (every option at its intrinsic value), per spot. |
| `strategies[].analytics.payoff.today` | `(number (double))[] \| null` | yes | Today, at the legs' implied volatilities, per spot; null when an option leg has no IV. |
| `strategies[].analytics.scenario` | `object \| null` | no | Spot × IV scenario grid (`GET /v1/strategies/{name}?scenario=1` only, when every option leg has an IV). |
| `strategies[].analytics.scenario.spot_shifts_pct` | `(integer (int32))[]` | yes | Spot moves, percent from `spot`: −5 to +5. |
| `strategies[].analytics.scenario.iv_shifts` | `(integer (int32))[]` | yes | IV moves, volatility points added to every option leg's IV. |
| `strategies[].analytics.scenario.today_pnl` | `((number (double))[])[]` | yes | `today_pnl[i][j]`: P&L with IV shift `iv_shifts[i]` and spot shift `spot_shifts_pct[j]`, today. |
| `missing` | `object[]` | yes | Accounts with no ledger to show, and why. |
| `missing[].account` | `string` | yes |  |
| `missing[].code` | `string` | yes | Why an account has no ledger to show. One of: `not_available_yet`, `unreadable`. |
| `missing[].reason` | `string` | yes | Why, in plain English. |

**Example: No ledger yet today: not an error (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": false,
    "strategies": [],
    "missing": [
      {
        "account": "P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "P2",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ]
  },
  "error": null
}
```

**Example: An options strategy with its analytics summary (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategies": [
      {
        "name": "strangle.1",
        "label": "strangle.1",
        "accounts": [
          {
            "account": "P1",
            "at": "2026-10-16T12:09:02.889720Z",
            "stale": false,
            "rows": [
              {
                "symbol": "NIFTY",
                "tradingsymbol": "NIFTY30DEC25500CE",
                "product": "CARRYFORWARD",
                "net_qty": -75,
                "avg_price": 7000,
                "realized": 0,
                "ltp": 6800,
                "ltp_stale": false,
                "unrealized": 15000,
                "instrument_token": "CT:TEST:NIFTY30DEC25500CE"
              },
              {
                "symbol": "NIFTY",
                "tradingsymbol": "NIFTY30DEC24500PE",
                "product": "CARRYFORWARD",
                "net_qty": -75,
                "avg_price": 850,
                "realized": 0,
                "ltp": 800,
                "ltp_stale": false,
                "unrealized": 3750,
                "instrument_token": "CT:TEST:NIFTY30DEC24500PE"
              }
            ],
            "totals": {
              "realized": 0,
              "unrealized": 18750,
              "total": 18750,
              "ltp_stale": false
            }
          }
        ],
        "totals": {
          "realized": 0,
          "unrealized": 18750,
          "total": 18750,
          "ltp_stale": false
        },
        "recon": {
          "status": "ok",
          "note": "Matches the broker's positions."
        },
        "at": "2026-10-16T12:09:02.889720Z",
        "analytics": {
          "underlying": "NIFTY",
          "spot": 25000,
          "model": "black_scholes",
          "rate": 0.065,
          "greeks": {
            "delta": -49.03,
            "gamma": -0.004267,
            "theta_per_day": 229.06,
            "vega_per_vol_point": -19605.02
          },
          "breakevens": [
            16650,
            33350
          ],
          "max_profit": 588750,
          "max_profit_unlimited": false,
          "max_loss": null,
          "max_loss_unlimited": true,
          "probability_of_profit": 0.5446,
          "note": null
        }
      }
    ],
    "missing": [
      {
        "account": "P2",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ]
  },
  "error": null
}
```

**Example: A strategy in two accounts and the unassigned rest (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategies": [
      {
        "name": "momo.1",
        "label": "momo.1",
        "accounts": [
          {
            "account": "P1",
            "at": "2026-10-16T11:26:45.364286Z",
            "stale": false,
            "rows": [
              {
                "symbol": "RELIANCE",
                "tradingsymbol": "RELIANCE-EQ",
                "product": "MIS",
                "net_qty": 10,
                "avg_price": 2490,
                "realized": 150.5,
                "ltp": 2500,
                "ltp_stale": false,
                "unrealized": 100,
                "instrument_token": "CT:TEST:RELIANCE"
              }
            ],
            "totals": {
              "realized": 150.5,
              "unrealized": 100,
              "total": 250.5,
              "ltp_stale": false
            }
          },
          {
            "account": "P2",
            "at": "2026-10-16T11:26:45.364736Z",
            "stale": false,
            "rows": [
              {
                "symbol": "RELIANCE",
                "tradingsymbol": "RELIANCE-EQ",
                "product": "MIS",
                "net_qty": -4,
                "avg_price": 2510,
                "realized": 0,
                "ltp": 2500,
                "ltp_stale": false,
                "unrealized": 40,
                "instrument_token": "CT:TEST:RELIANCE"
              }
            ],
            "totals": {
              "realized": 0,
              "unrealized": 40,
              "total": 40,
              "ltp_stale": false
            }
          }
        ],
        "totals": {
          "realized": 150.5,
          "unrealized": 140,
          "total": 290.5,
          "ltp_stale": false
        },
        "recon": {
          "status": "ok",
          "note": "Matches the broker's positions."
        },
        "at": "2026-10-16T11:26:45.364286Z"
      },
      {
        "name": null,
        "label": "Unassigned",
        "accounts": [
          {
            "account": "P1",
            "at": "2026-10-16T11:26:45.364286Z",
            "stale": false,
            "rows": [
              {
                "symbol": "RELIANCE",
                "tradingsymbol": "RELIANCE-EQ",
                "product": "DELIVERY",
                "net_qty": 5,
                "avg_price": 2400,
                "realized": 0,
                "ltp": 2500,
                "ltp_stale": false,
                "unrealized": 500,
                "instrument_token": "CT:TEST:RELIANCE"
              }
            ],
            "totals": {
              "realized": 0,
              "unrealized": 500,
              "total": 500,
              "ltp_stale": false
            }
          }
        ],
        "totals": {
          "realized": 0,
          "unrealized": 500,
          "total": 500,
          "ltp_stale": false
        },
        "recon": {
          "status": "ok",
          "note": "Matches the broker's positions."
        },
        "at": "2026-10-16T11:26:45.364286Z"
      }
    ],
    "missing": [
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ]
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the read scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/strategies/{name}

Scope: `read`

One strategy today

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | The strategy's name, or `~unassigned`. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scenario` | `integer (int32)` | no | `1`: add the analytics scenario grid (P&L today for spot −5% to +5% × IV −5, 0, +5 points). |

```bash
curl "https://api.cirrus.trade/v1/strategies/<name>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trading_day` | `string (date)` | yes |  |
| `available` | `boolean` | yes | As in `GET /v1/strategies`. |
| `strategy` | `object` | yes | The strategy (no accounts and zero totals when it holds nothing and booked nothing today). |
| `strategy.name` | `string \| null` | yes | The strategy's name; null for the unassigned rest. |
| `strategy.label` | `string` | yes | The name, or `Unassigned`. |
| `strategy.accounts` | `object[]` | yes | Per account, by account id. |
| `strategy.accounts[].account` | `string` | yes |  |
| `strategy.accounts[].at` | `string (date-time)` | yes | As of: the older of the trades and positions the ledger was built from. |
| `strategy.accounts[].stale` | `boolean` | yes | The ledger was not rebuilt recently during market hours: numbers may be behind. |
| `strategy.accounts[].rows` | `object[]` | yes |  |
| `strategy.accounts[].rows[].instrument_token` | `string` | yes | Instrument id (see `/v1/instruments`). |
| `strategy.accounts[].rows[].symbol` | `string \| null` | yes | Underlying (e.g. `NIFTY`) or the stock's name; null when the instrument is not in the instrument list. |
| `strategy.accounts[].rows[].tradingsymbol` | `string \| null` | yes | The instrument list's trading symbol; null as `symbol`. |
| `strategy.accounts[].rows[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `strategy.accounts[].rows[].net_qty` | `integer (int64)` | yes | `+` long, `-` short, in units. |
| `strategy.accounts[].rows[].avg_price` | `number (double)` | yes | Average price of the open quantity (`0` when flat). |
| `strategy.accounts[].rows[].realized` | `number (double)` | yes | Booked today (no charges). |
| `strategy.accounts[].rows[].ltp` | `number (double) \| null` | yes | Latest price; null when none is known. |
| `strategy.accounts[].rows[].ltp_stale` | `boolean` | yes | `ltp` is not current (older than a minute, of unknown time, or missing): `unrealized` is computed from it anyway, or null when there is no price at all. Always `false` for a flat row. |
| `strategy.accounts[].rows[].unrealized` | `number (double) \| null` | yes | `net_qty × (ltp − avg_price)`; `0` when flat, null when open without a price. |
| `strategy.accounts[].totals` | `object` | yes | Realized and unrealized P&L (no charges). |
| `strategy.accounts[].totals.realized` | `number (double)` | yes |  |
| `strategy.accounts[].totals.unrealized` | `number (double)` | yes | Of the rows with a price. |
| `strategy.accounts[].totals.total` | `number (double)` | yes | `realized + unrealized`. |
| `strategy.accounts[].totals.ltp_stale` | `boolean` | yes | Some open row's price is not current or missing (see `StrategyRow.ltp_stale`). |
| `strategy.totals` | `object` | yes | Across `accounts`. |
| `strategy.totals.realized` | `number (double)` | yes |  |
| `strategy.totals.unrealized` | `number (double)` | yes | Of the rows with a price. |
| `strategy.totals.total` | `number (double)` | yes | `realized + unrealized`. |
| `strategy.totals.ltp_stale` | `boolean` | yes | Some open row's price is not current or missing (see `StrategyRow.ltp_stale`). |
| `strategy.recon` | `object` | yes |  |
| `strategy.recon.status` | `string` | yes | The worst over the strategy's open positions. One of: `ok`, `pending`, `mismatch`. |
| `strategy.recon.note` | `string` | yes | In plain English. |
| `strategy.at` | `string (date-time) \| null` | yes | As of: the oldest ledger it is built from; null without one. |
| `strategy.analytics` | `object \| null` | no | Options analytics of the open positions (IV, greeks, payoff), for a strategy whose open positions are all options / futures on one underlying; absent otherwise. `GET /v1/strategies` has the summary, `GET /v1/strategies/{name}` adds `legs` and `payoff`. |
| `strategy.analytics.underlying` | `string \| null` | yes | The underlying (e.g. `NIFTY`); null when the positions are not on one underlying. |
| `strategy.analytics.spot` | `number (double) \| null` | yes | The underlying's price used; null when not known. |
| `strategy.analytics.model` | `string` | yes | `black_scholes` (on the underlying's price, dividend yield 0). |
| `strategy.analytics.rate` | `number (double)` | yes | Annual risk-free rate used, e.g. `0.065`. |
| `strategy.analytics.greeks` | `object \| null` | yes | Of every open leg; null when an option leg has no IV. |
| `strategy.analytics.greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `strategy.analytics.greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `strategy.analytics.greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `strategy.analytics.greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `strategy.analytics.breakevens` | `(number (double))[]` | yes | Prices of the underlying where the P&L at expiry crosses zero (exact, also outside the payoff range), ascending. |
| `strategy.analytics.max_profit` | `number (double) \| null` | yes | Highest P&L at expiry of the open positions; null when unlimited (or not computed). |
| `strategy.analytics.max_profit_unlimited` | `boolean` | yes | The P&L at expiry keeps rising as the underlying rises. |
| `strategy.analytics.max_loss` | `number (double) \| null` | yes | Lowest P&L at expiry (negative: a loss); null when unlimited (or not computed). |
| `strategy.analytics.max_loss_unlimited` | `boolean` | yes | The P&L at expiry keeps falling as the underlying rises. |
| `strategy.analytics.probability_of_profit` | `number (double)` | no | Chance (0 to 1) that the P&L at expiry is above zero: risk-neutral, the underlying lognormal from `spot` at `rate`, one volatility (the option legs' IVs averaged, weighted by each leg's \|vega\|) to the options' common expiry. Absent (with a `note`) when an option leg has no IV, the options expire on different dates, or there is no option leg. |
| `strategy.analytics.note` | `string \| null` | yes | What was left out and why, in plain English; null when nothing. |
| `strategy.analytics.legs` | `object[] \| null` | no | Per leg (`GET /v1/strategies/{name}` only). |
| `strategy.analytics.legs[].instrument_token` | `string` | yes |  |
| `strategy.analytics.legs[].tradingsymbol` | `string` | yes |  |
| `strategy.analytics.legs[].instrument` | `string` | yes | `option` or `future`. |
| `strategy.analytics.legs[].option_type` | `string \| null` | yes | `CE` or `PE`; null for a future. |
| `strategy.analytics.legs[].strike` | `number (double) \| null` | yes |  |
| `strategy.analytics.legs[].expiry` | `string \| null` | yes | `YYYY-MM-DD`. |
| `strategy.analytics.legs[].days_to_expiry` | `number (double) \| null` | yes | Calendar days to 15:30 IST of the expiry day. |
| `strategy.analytics.legs[].net_qty` | `integer (int64)` | yes | Units, summed over accounts. |
| `strategy.analytics.legs[].avg_price` | `number (double)` | yes |  |
| `strategy.analytics.legs[].ltp` | `number (double) \| null` | yes |  |
| `strategy.analytics.legs[].iv_pct` | `number (double) \| null` | yes | Implied volatility from `ltp`, in percent; null when it cannot be solved (see `note`). |
| `strategy.analytics.legs[].greeks` | `object \| null` | yes | Of the whole leg (`net_qty` units); null without an IV. |
| `strategy.analytics.legs[].greeks.delta` | `number (double)` | yes | P&L change for a 1-point rise of the underlying. |
| `strategy.analytics.legs[].greeks.gamma` | `number (double)` | yes | Change of `delta` for a 1-point rise of the underlying. |
| `strategy.analytics.legs[].greeks.theta_per_day` | `number (double)` | yes | P&L change over one calendar day, everything else unchanged. |
| `strategy.analytics.legs[].greeks.vega_per_vol_point` | `number (double)` | yes | P&L change for a 1-point (1%) rise of implied volatility. |
| `strategy.analytics.legs[].note` | `string \| null` | yes |  |
| `strategy.analytics.payoff` | `object \| null` | no | The payoff curves (`GET /v1/strategies/{name}` only). |
| `strategy.analytics.payoff.spots` | `(number (double))[]` | yes | Prices of the underlying, ascending (± 10% around `spot`). |
| `strategy.analytics.payoff.at_expiry` | `(number (double))[]` | yes | At expiry (every option at its intrinsic value), per spot. |
| `strategy.analytics.payoff.today` | `(number (double))[] \| null` | yes | Today, at the legs' implied volatilities, per spot; null when an option leg has no IV. |
| `strategy.analytics.scenario` | `object \| null` | no | Spot × IV scenario grid (`GET /v1/strategies/{name}?scenario=1` only, when every option leg has an IV). |
| `strategy.analytics.scenario.spot_shifts_pct` | `(integer (int32))[]` | yes | Spot moves, percent from `spot`: −5 to +5. |
| `strategy.analytics.scenario.iv_shifts` | `(integer (int32))[]` | yes | IV moves, volatility points added to every option leg's IV. |
| `strategy.analytics.scenario.today_pnl` | `((number (double))[])[]` | yes | `today_pnl[i][j]`: P&L with IV shift `iv_shifts[i]` and spot shift `spot_shifts_pct[j]`, today. |
| `missing` | `object[]` | yes |  |
| `missing[].account` | `string` | yes |  |
| `missing[].code` | `string` | yes | Why an account has no ledger to show. One of: `not_available_yet`, `unreadable`. |
| `missing[].reason` | `string` | yes | Why, in plain English. |
| `triggers` | `object[] \| null` | yes | Its v2 triggers (`GET /v2/triggers/{trigger_id}` has the rest); null when v2 triggers are not enabled for the user or could not be read just now. |
| `triggers[].trigger_id` | `string` | yes |  |
| `triggers[].account` | `string` | yes |  |
| `triggers[].kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `triggers[].state` | `string` | yes | As `TriggerV2View.state`. |
| `triggers[].ended` | `boolean` | yes | The trigger can no longer change. |

**Example: A short strangle: IV, greeks and payoff (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategy": {
      "name": "strangle.1",
      "label": "strangle.1",
      "accounts": [
        {
          "account": "P1",
          "at": "2026-10-16T12:09:02.889720Z",
          "stale": false,
          "rows": [
            {
              "symbol": "NIFTY",
              "tradingsymbol": "NIFTY30DEC25500CE",
              "product": "CARRYFORWARD",
              "net_qty": -75,
              "avg_price": 7000,
              "realized": 0,
              "ltp": 6800,
              "ltp_stale": false,
              "unrealized": 15000,
              "instrument_token": "CT:TEST:NIFTY30DEC25500CE"
            },
            {
              "symbol": "NIFTY",
              "tradingsymbol": "NIFTY30DEC24500PE",
              "product": "CARRYFORWARD",
              "net_qty": -75,
              "avg_price": 850,
              "realized": 0,
              "ltp": 800,
              "ltp_stale": false,
              "unrealized": 3750,
              "instrument_token": "CT:TEST:NIFTY30DEC24500PE"
            }
          ],
          "totals": {
            "realized": 0,
            "unrealized": 18750,
            "total": 18750,
            "ltp_stale": false
          }
        }
      ],
      "totals": {
        "realized": 0,
        "unrealized": 18750,
        "total": 18750,
        "ltp_stale": false
      },
      "recon": {
        "status": "ok",
        "note": "Matches the broker's positions."
      },
      "at": "2026-10-16T12:09:02.889720Z",
      "analytics": {
        "underlying": "NIFTY",
        "spot": 25000,
        "model": "black_scholes",
        "rate": 0.065,
        "greeks": {
          "delta": -49.03,
          "gamma": -0.004267,
          "theta_per_day": 229.06,
          "vega_per_vol_point": -19605.02
        },
        "breakevens": [
          16650,
          33350
        ],
        "max_profit": 588750,
        "max_profit_unlimited": false,
        "max_loss": null,
        "max_loss_unlimited": true,
        "probability_of_profit": 0.5446,
        "note": null,
        "legs": [
          {
            "tradingsymbol": "NIFTY30DEC24500PE",
            "instrument": "option",
            "option_type": "PE",
            "strike": 24500,
            "expiry": "2030-12-26",
            "days_to_expiry": 1531.91,
            "net_qty": -75,
            "avg_price": 850,
            "ltp": 800,
            "iv_pct": 16.73,
            "greeks": {
              "delta": 11.43,
              "gamma": -0.002062,
              "theta_per_day": -12.14,
              "vega_per_vol_point": -9051.73
            },
            "note": null,
            "instrument_token": "CT:TEST:NIFTY30DEC24500PE"
          },
          {
            "tradingsymbol": "NIFTY30DEC25500CE",
            "instrument": "option",
            "option_type": "CE",
            "strike": 25500,
            "expiry": "2030-12-26",
            "days_to_expiry": 1531.91,
            "net_qty": -75,
            "avg_price": 7000,
            "ltp": 6800,
            "iv_pct": 18.25,
            "greeks": {
              "delta": -60.46,
              "gamma": -0.002205,
              "theta_per_day": 241.2,
              "vega_per_vol_point": -10553.29
            },
            "note": null,
            "instrument_token": "CT:TEST:NIFTY30DEC25500CE"
          }
        ],
        "payoff": {
          "spots": [
            22500,
            22583.33,
            22666.67,
            22750,
            22833.33,
            22916.67,
            23000,
            23083.33,
            23166.67,
            23250,
            23333.33,
            23416.67,
            23500,
            23583.33,
            23666.67,
            23750,
            23833.33,
            23916.67,
            24000,
            24083.33,
            24166.67,
            24250,
            24333.33,
            24416.67,
            24500,
            24583.33,
            24666.67,
            24750,
            24833.33,
            24916.67,
            25000,
            25083.33,
            25166.67,
            25250,
            25333.33,
            25416.67,
            25500,
            25583.33,
            25666.67,
            25750,
            25833.33,
            25916.67,
            26000,
            26083.33,
            26166.67,
            26250,
            26333.33,
            26416.67,
            26500,
            26583.33,
            26666.67,
            26750,
            26833.33,
            26916.67,
            27000,
            27083.33,
            27166.67,
            27250,
            27333.33,
            27416.67,
            27500
          ],
          "at_expiry": [
            438750,
            445000,
            451250,
            457500,
            463750,
            470000,
            476250,
            482500,
            488750,
            495000,
            501250,
            507500,
            513750,
            520000,
            526250,
            532500,
            538750,
            545000,
            551250,
            557500,
            563750,
            570000,
            576250,
            582500,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            582500,
            576250,
            570000,
            563750,
            557500,
            551250,
            545000,
            538750,
            532500,
            526250,
            520000,
            513750,
            507500,
            501250,
            495000,
            488750,
            482500,
            476250,
            470000,
            463750,
            457500,
            451250,
            445000,
            438750
          ],
          "today": [
            126271,
            123228.12,
            120144.01,
            117019.1,
            113853.84,
            110648.65,
            107403.95,
            104120.17,
            100797.74,
            97437.08,
            94038.6,
            90602.72,
            87129.85,
            83620.41,
            80074.81,
            76493.45,
            72876.73,
            69225.05,
            65538.83,
            61818.44,
            58064.28,
            54276.75,
            50456.23,
            46603.11,
            42717.76,
            38800.57,
            34851.91,
            30872.15,
            26861.67,
            22820.83,
            18750,
            14649.53,
            10519.79,
            6361.13,
            2173.9,
            -2041.54,
            -6284.87,
            -10555.72,
            -14853.76,
            -19178.66,
            -23530.08,
            -27907.69,
            -32311.16,
            -36740.17,
            -41194.4,
            -45673.54,
            -50177.25,
            -54705.24,
            -59257.19,
            -63832.81,
            -68431.78,
            -73053.8,
            -77698.59,
            -82365.85,
            -87055.28,
            -91766.6,
            -96499.52,
            -101253.77,
            -106029.07,
            -110825.14,
            -115641.7
          ]
        }
      }
    },
    "missing": [
      {
        "account": "P2",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ],
    "triggers": []
  },
  "error": null
}
```

**Example: With `scenario=1`: P&L today for spot × IV shifts (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategy": {
      "name": "strangle.1",
      "label": "strangle.1",
      "accounts": [
        {
          "account": "P1",
          "at": "2026-10-16T12:09:02.889720Z",
          "stale": false,
          "rows": [
            {
              "symbol": "NIFTY",
              "tradingsymbol": "NIFTY30DEC25500CE",
              "product": "CARRYFORWARD",
              "net_qty": -75,
              "avg_price": 7000,
              "realized": 0,
              "ltp": 6800,
              "ltp_stale": false,
              "unrealized": 15000,
              "instrument_token": "CT:TEST:NIFTY30DEC25500CE"
            },
            {
              "symbol": "NIFTY",
              "tradingsymbol": "NIFTY30DEC24500PE",
              "product": "CARRYFORWARD",
              "net_qty": -75,
              "avg_price": 850,
              "realized": 0,
              "ltp": 800,
              "ltp_stale": false,
              "unrealized": 3750,
              "instrument_token": "CT:TEST:NIFTY30DEC24500PE"
            }
          ],
          "totals": {
            "realized": 0,
            "unrealized": 18750,
            "total": 18750,
            "ltp_stale": false
          }
        }
      ],
      "totals": {
        "realized": 0,
        "unrealized": 18750,
        "total": 18750,
        "ltp_stale": false
      },
      "recon": {
        "status": "ok",
        "note": "Matches the broker's positions."
      },
      "at": "2026-10-16T12:09:02.889720Z",
      "analytics": {
        "underlying": "NIFTY",
        "spot": 25000,
        "model": "black_scholes",
        "rate": 0.065,
        "greeks": {
          "delta": -49.03,
          "gamma": -0.004267,
          "theta_per_day": 229.06,
          "vega_per_vol_point": -19605.02
        },
        "breakevens": [
          16650,
          33350
        ],
        "max_profit": 588750,
        "max_profit_unlimited": false,
        "max_loss": null,
        "max_loss_unlimited": true,
        "probability_of_profit": 0.5446,
        "note": null,
        "legs": [
          {
            "tradingsymbol": "NIFTY30DEC24500PE",
            "instrument": "option",
            "option_type": "PE",
            "strike": 24500,
            "expiry": "2030-12-26",
            "days_to_expiry": 1531.91,
            "net_qty": -75,
            "avg_price": 850,
            "ltp": 800,
            "iv_pct": 16.73,
            "greeks": {
              "delta": 11.43,
              "gamma": -0.002062,
              "theta_per_day": -12.14,
              "vega_per_vol_point": -9051.73
            },
            "note": null,
            "instrument_token": "CT:TEST:NIFTY30DEC24500PE"
          },
          {
            "tradingsymbol": "NIFTY30DEC25500CE",
            "instrument": "option",
            "option_type": "CE",
            "strike": 25500,
            "expiry": "2030-12-26",
            "days_to_expiry": 1531.91,
            "net_qty": -75,
            "avg_price": 7000,
            "ltp": 6800,
            "iv_pct": 18.25,
            "greeks": {
              "delta": -60.46,
              "gamma": -0.002205,
              "theta_per_day": 241.2,
              "vega_per_vol_point": -10553.29
            },
            "note": null,
            "instrument_token": "CT:TEST:NIFTY30DEC25500CE"
          }
        ],
        "payoff": {
          "spots": [
            22500,
            22583.33,
            22666.67,
            22750,
            22833.33,
            22916.67,
            23000,
            23083.33,
            23166.67,
            23250,
            23333.33,
            23416.67,
            23500,
            23583.33,
            23666.67,
            23750,
            23833.33,
            23916.67,
            24000,
            24083.33,
            24166.67,
            24250,
            24333.33,
            24416.67,
            24500,
            24583.33,
            24666.67,
            24750,
            24833.33,
            24916.67,
            25000,
            25083.33,
            25166.67,
            25250,
            25333.33,
            25416.67,
            25500,
            25583.33,
            25666.67,
            25750,
            25833.33,
            25916.67,
            26000,
            26083.33,
            26166.67,
            26250,
            26333.33,
            26416.67,
            26500,
            26583.33,
            26666.67,
            26750,
            26833.33,
            26916.67,
            27000,
            27083.33,
            27166.67,
            27250,
            27333.33,
            27416.67,
            27500
          ],
          "at_expiry": [
            438750,
            445000,
            451250,
            457500,
            463750,
            470000,
            476250,
            482500,
            488750,
            495000,
            501250,
            507500,
            513750,
            520000,
            526250,
            532500,
            538750,
            545000,
            551250,
            557500,
            563750,
            570000,
            576250,
            582500,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            588750,
            582500,
            576250,
            570000,
            563750,
            557500,
            551250,
            545000,
            538750,
            532500,
            526250,
            520000,
            513750,
            507500,
            501250,
            495000,
            488750,
            482500,
            476250,
            470000,
            463750,
            457500,
            451250,
            445000,
            438750
          ],
          "today": [
            126271,
            123228.12,
            120144.01,
            117019.1,
            113853.84,
            110648.65,
            107403.95,
            104120.17,
            100797.74,
            97437.08,
            94038.6,
            90602.72,
            87129.85,
            83620.41,
            80074.81,
            76493.45,
            72876.73,
            69225.05,
            65538.83,
            61818.44,
            58064.28,
            54276.75,
            50456.23,
            46603.11,
            42717.76,
            38800.57,
            34851.91,
            30872.15,
            26861.67,
            22820.83,
            18750,
            14649.53,
            10519.79,
            6361.13,
            2173.9,
            -2041.54,
            -6284.87,
            -10555.72,
            -14853.76,
            -19178.66,
            -23530.08,
            -27907.69,
            -32311.16,
            -36740.17,
            -41194.4,
            -45673.54,
            -50177.25,
            -54705.24,
            -59257.19,
            -63832.81,
            -68431.78,
            -73053.8,
            -77698.59,
            -82365.85,
            -87055.28,
            -91766.6,
            -96499.52,
            -101253.77,
            -106029.07,
            -110825.14,
            -115641.7
          ]
        },
        "scenario": {
          "spot_shifts_pct": [
            -5,
            -4,
            -3,
            -2,
            -1,
            0,
            1,
            2,
            3,
            4,
            5
          ],
          "iv_shifts": [
            -5,
            0,
            5
          ],
          "today_pnl": [
            [
              174552.44,
              161617.2,
              148329.8,
              134708.53,
              120771.05,
              106534.37,
              92014.86,
              77228.21,
              62189.48,
              46913.06,
              31412.71
            ],
            [
              76493.45,
              65538.83,
              54276.75,
              42717.76,
              30872.15,
              18750,
              6361.13,
              -6284.87,
              -19178.66,
              -32311.16,
              -45673.54
            ],
            [
              -33151.62,
              -43106.38,
              -53318.85,
              -63782.1,
              -74489.35,
              -85433.94,
              -96609.35,
              -108009.2,
              -119627.26,
              -131457.41,
              -143493.69
            ]
          ]
        }
      }
    },
    "missing": [
      {
        "account": "P2",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ],
    "triggers": []
  },
  "error": null
}
```

**Example: One strategy across two accounts (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategy": {
      "name": "momo.1",
      "label": "momo.1",
      "accounts": [
        {
          "account": "P1",
          "at": "2026-10-16T11:30:11.811504Z",
          "stale": false,
          "rows": [
            {
              "symbol": "RELIANCE",
              "tradingsymbol": "RELIANCE-EQ",
              "product": "MIS",
              "net_qty": 10,
              "avg_price": 2490,
              "realized": 150.5,
              "ltp": 2500,
              "ltp_stale": false,
              "unrealized": 100,
              "instrument_token": "CT:TEST:RELIANCE"
            }
          ],
          "totals": {
            "realized": 150.5,
            "unrealized": 100,
            "total": 250.5,
            "ltp_stale": false
          }
        },
        {
          "account": "P2",
          "at": "2026-10-16T11:30:11.812357Z",
          "stale": false,
          "rows": [
            {
              "symbol": "RELIANCE",
              "tradingsymbol": "RELIANCE-EQ",
              "product": "MIS",
              "net_qty": -4,
              "avg_price": 2510,
              "realized": 0,
              "ltp": 2500,
              "ltp_stale": false,
              "unrealized": 40,
              "instrument_token": "CT:TEST:RELIANCE"
            }
          ],
          "totals": {
            "realized": 0,
            "unrealized": 40,
            "total": 40,
            "ltp_stale": false
          }
        }
      ],
      "totals": {
        "realized": 150.5,
        "unrealized": 140,
        "total": 290.5,
        "ltp_stale": false
      },
      "recon": {
        "status": "ok",
        "note": "Matches the broker's positions."
      },
      "at": "2026-10-16T11:30:11.811504Z"
    },
    "missing": [
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ],
    "triggers": [
      {
        "trigger_id": "01M527M374SGJ48V6D45YMQWJ2",
        "account": "P1",
        "kind": "bracket",
        "state": "submitted",
        "ended": false
      }
    ]
  },
  "error": null
}
```

**Example: The unassigned rest (200)**

```json
{
  "status": "success",
  "data": {
    "trading_day": "2026-10-16",
    "available": true,
    "strategy": {
      "name": null,
      "label": "Unassigned",
      "accounts": [
        {
          "account": "P1",
          "at": "2026-10-16T11:30:11.811504Z",
          "stale": false,
          "rows": [
            {
              "symbol": "RELIANCE",
              "tradingsymbol": "RELIANCE-EQ",
              "product": "DELIVERY",
              "net_qty": 5,
              "avg_price": 2400,
              "realized": 0,
              "ltp": 2500,
              "ltp_stale": false,
              "unrealized": 500,
              "instrument_token": "CT:TEST:RELIANCE"
            }
          ],
          "totals": {
            "realized": 0,
            "unrealized": 500,
            "total": 500,
            "ltp_stale": false
          }
        }
      ],
      "totals": {
        "realized": 0,
        "unrealized": 500,
        "total": 500,
        "ltp_stale": false
      },
      "recon": {
        "status": "ok",
        "note": "Matches the broker's positions."
      },
      "at": "2026-10-16T11:30:11.811504Z"
    },
    "missing": [
      {
        "account": "Z1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "PF1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "U1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "5P1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "MO1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      },
      {
        "account": "TJ1",
        "code": "not_available_yet",
        "reason": "Not available yet: no strategy ledger for this account today. It appears once the account has a live broker session and its trades are read."
      }
    ],
    "triggers": []
  },
  "error": null
}
```

**Example: An unknown query parameter (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_query",
    "message": "invalid query string: unknown field `senario`, expected `scenario`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the read scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: `scenario` is 0 or 1 (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "scenario must be 0 or 1",
    "field": "scenario"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Baskets

Saved sets of orders you can place together later.

### GET /v1/baskets

Scope: `read`

List your baskets

```bash
curl "https://api.cirrus.trade/v1/baskets" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes | Basket id. |
| `[].name` | `string` | yes | Display name. |
| `[].leg_count` | `integer` | yes | Number of legs. |
| `[].updated_at` | `string (date-time)` | yes | Last update (RFC3339). |

**Example: Saved baskets, most recently updated first (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "bk_01m4xdf6cg5vt9yk1nmy8nemn9",
      "name": "Bear call",
      "leg_count": 2,
      "updated_at": "2026-10-14T14:36:10.512Z"
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the route's scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/baskets

Scope: `orders`

Save a basket

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name, 1-60 characters after trimming. |
| `legs` | `object[]` | yes | Legs, 1-20. |
| `legs[].underlying` | `string` | yes | Underlying symbol, e.g. `NIFTY`. |
| `legs[].instrument` | `string` | yes | Instrument kind. One of: `option`, `future`, `stock`. |
| `legs[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); required for options and futures. |
| `legs[].option_type` | `string \| null` | no | `CE` or `PE`; required for options. One of: `CE`, `PE`. |
| `legs[].strike` | `number (double) \| null` | no | Strike price; required (positive, finite) for options. |
| `legs[].side` | `string` | yes | Buy or sell. One of: `BUY`, `SELL`. |
| `legs[].lots` | `integer (int32)` | yes | Number of lots, at least 1. |
| `legs[].order_type` | `string` | yes | Order type. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `legs[].price` | `number (double) \| null` | no | Limit / trigger price, when the order type needs one. |
| `legs[].instrument_token` | `string \| null` | no | Resolved instrument token, if known. |
| `prefs` | `any` | no | Free-form client preferences, at most 2048 serialized bytes. |
| `strategy` | `string \| null` | no | Strategy to place this basket's orders under (send it as each order's `strategy` on `POST /v1/orders`): at most 64 letters, digits, `_`, `.`, `:` or `-`. |

```bash
curl -X POST "https://api.cirrus.trade/v1/baskets" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Bear call",
    "legs": [
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22500,
        "side": "BUY",
        "lots": 4,
        "order_type": "MARKET"
      },
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22400,
        "side": "SELL",
        "lots": 4,
        "order_type": "LIMIT",
        "price": 112.5
      }
    ],
    "prefs": {
      "mode": "auto",
      "timeout_ms": 5000,
      "partial": "filled_qty"
    }
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Basket id (`bk_...`). |

**Example: A two-leg bear call spread saved (201)**

```json
{
  "status": "success",
  "data": {
    "id": "bk_01m4xdf6chg4r9pswvwqp3y9cq"
  },
  "error": null
}
```

**Example: An unknown field is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `owner`, expected one of `name`, `legs`, `prefs`, `strategy`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the route's scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `orders` scope",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: A leg needs at least one lot (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "lots must be 1-10000",
    "field": "legs[0].lots"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/baskets/{basket_id}

Scope: `read`

Read a basket

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `basket_id` | `string` | yes | Basket id (`bk_...`). |

```bash
curl "https://api.cirrus.trade/v1/baskets/<basket_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Basket id. |
| `name` | `string` | yes | Display name. |
| `legs` | `object[]` | yes | Legs. |
| `legs[].underlying` | `string` | yes | Underlying symbol, e.g. `NIFTY`. |
| `legs[].instrument` | `string` | yes | Instrument kind. One of: `option`, `future`, `stock`. |
| `legs[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); required for options and futures. |
| `legs[].option_type` | `string \| null` | no | `CE` or `PE`; required for options. One of: `CE`, `PE`. |
| `legs[].strike` | `number (double) \| null` | no | Strike price; required (positive, finite) for options. |
| `legs[].side` | `string` | yes | Buy or sell. One of: `BUY`, `SELL`. |
| `legs[].lots` | `integer (int32)` | yes | Number of lots, at least 1. |
| `legs[].order_type` | `string` | yes | Order type. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `legs[].price` | `number (double) \| null` | no | Limit / trigger price, when the order type needs one. |
| `legs[].instrument_token` | `string \| null` | no | Resolved instrument token, if known. |
| `prefs` | `any` | yes | Client preferences. |
| `strategy` | `string \| null` | no | Strategy its orders are placed under; absent when none was saved. |
| `updated_at` | `string (date-time)` | yes | Last update (RFC3339). |

**Example: A saved basket with its legs (200)**

```json
{
  "status": "success",
  "data": {
    "id": "bk_01m4xdf6cg5vt9yk1nmy8nemn9",
    "name": "Bear call",
    "legs": [
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22500,
        "side": "BUY",
        "lots": 4,
        "order_type": "MARKET",
        "price": null,
        "instrument_token": null
      },
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22400,
        "side": "SELL",
        "lots": 4,
        "order_type": "LIMIT",
        "price": 112.5,
        "instrument_token": null
      }
    ],
    "prefs": {
      "mode": "auto",
      "timeout_ms": 5000,
      "partial": "filled_qty"
    },
    "updated_at": "2026-10-14T14:36:10.512Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the route's scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No basket with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "basket_not_found",
    "message": "no such basket",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `basket_not_found` | No saved basket with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PUT /v1/baskets/{basket_id}

Scope: `orders`

Replace a basket

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `basket_id` | `string` | yes | Basket id (`bk_...`). |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name, 1-60 characters after trimming. |
| `legs` | `object[]` | yes | Legs, 1-20. |
| `legs[].underlying` | `string` | yes | Underlying symbol, e.g. `NIFTY`. |
| `legs[].instrument` | `string` | yes | Instrument kind. One of: `option`, `future`, `stock`. |
| `legs[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); required for options and futures. |
| `legs[].option_type` | `string \| null` | no | `CE` or `PE`; required for options. One of: `CE`, `PE`. |
| `legs[].strike` | `number (double) \| null` | no | Strike price; required (positive, finite) for options. |
| `legs[].side` | `string` | yes | Buy or sell. One of: `BUY`, `SELL`. |
| `legs[].lots` | `integer (int32)` | yes | Number of lots, at least 1. |
| `legs[].order_type` | `string` | yes | Order type. One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `legs[].price` | `number (double) \| null` | no | Limit / trigger price, when the order type needs one. |
| `legs[].instrument_token` | `string \| null` | no | Resolved instrument token, if known. |
| `prefs` | `any` | no | Free-form client preferences, at most 2048 serialized bytes. |
| `strategy` | `string \| null` | no | Strategy to place this basket's orders under (send it as each order's `strategy` on `POST /v1/orders`): at most 64 letters, digits, `_`, `.`, `:` or `-`. |

```bash
curl -X PUT "https://api.cirrus.trade/v1/baskets/<basket_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Bear call (weekly)",
    "legs": [
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22500,
        "side": "BUY",
        "lots": 4,
        "order_type": "MARKET"
      },
      {
        "underlying": "NIFTY",
        "instrument": "option",
        "expiry": "2026-10-17",
        "option_type": "CE",
        "strike": 22400,
        "side": "SELL",
        "lots": 4,
        "order_type": "LIMIT",
        "price": 112.5
      }
    ],
    "prefs": {
      "mode": "auto",
      "timeout_ms": 5000,
      "partial": "filled_qty"
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Basket id (`bk_...`). |

**Example: Renamed, legs unchanged (200)**

```json
{
  "status": "success",
  "data": {
    "id": "bk_01m4xdf6cg7098z58t00emzhr3"
  },
  "error": null
}
```

**Example: An unknown field is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `owner`, expected one of `name`, `legs`, `prefs`, `strategy`",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the route's scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `orders` scope",
    "field": null
  }
}
```

**Example: No basket with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "basket_not_found",
    "message": "no such basket",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: A limit leg needs a positive price (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "price must be finite and not negative",
    "field": "legs[1].price"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `basket_not_found` | No saved basket with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/baskets/{basket_id}

Scope: `orders`

Delete a basket

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `basket_id` | `string` | yes | Basket id (`bk_...`). |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | no | Optional: a new UUID per request (1-128 printable characters). A repeat with the same key gets the first answer back instead of acting again. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/baskets/<basket_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (204):** no body.

**Example: The Idempotency-Key is not 1-128 printable characters (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_idempotency_key",
    "message": "Idempotency-Key must be 1-128 printable characters",
    "field": null
  }
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the route's scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `orders` scope",
    "field": null
  }
}
```

**Example: Already deleted, or never saved (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "basket_not_found",
    "message": "no such basket",
    "field": null
  }
}
```

**Example: An earlier request with this Idempotency-Key is still running (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is still being processed",
    "field": null
  }
}
```

**Example: The Idempotency-Key was already used for another request (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1-128 printable characters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `basket_not_found` | No saved basket with this id for the caller. |
| 409 | `request_in_progress` | A request with this `Idempotency-Key` is still being processed. |
| 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different request. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Shared with you

Portfolios and automated strategies other people shared with you, read-only.

### GET /v2/shared

Scope: `read`

Strategies shared with you

```bash
curl "https://api.cirrus.trade/v2/shared" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].strategy_id` | `string` | yes |  |
| `[].name` | `string` | yes |  |
| `[].description` | `string` | yes |  |
| `[].tags` | `string[]` | yes |  |
| `[].owner` | `string` | yes | The owner, shortened (`@prat…`). |
| `[].version` | `integer (int32)` | yes | The saved version shown (forks pin it). |
| `[].summary` | `string` | yes | The version in plain English. |
| `[].read_only` | `boolean` | yes | Always true: viewers cannot edit, deploy or see runs. |
| `[].document` | `object \| null` | no | The version's document, normalized. Absent in lists. |

**Example: Strategies shared with you (200)**

```json
{
  "status": "success",
  "data": [
    {
      "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
      "name": "NIFTY straddle",
      "description": "",
      "tags": [
        "weekly"
      ],
      "owner": "@user…",
      "version": 1,
      "summary": "",
      "read_only": true
    }
  ],
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v2/shared/strategies/{strategy_id}

Scope: `read`

Get a strategy shared with you

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes | Strategy id. |

```bash
curl "https://api.cirrus.trade/v2/shared/strategies/<strategy_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `strategy_id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `string` | yes |  |
| `tags` | `string[]` | yes |  |
| `owner` | `string` | yes | The owner, shortened (`@prat…`). |
| `version` | `integer (int32)` | yes | The saved version shown (forks pin it). |
| `summary` | `string` | yes | The version in plain English. |
| `read_only` | `boolean` | yes | Always true: viewers cannot edit, deploy or see runs. |
| `document` | `object \| null` | no | The version's document, normalized. Absent in lists. |

**Example: A version shared with you, read-only (200)**

```json
{
  "status": "success",
  "data": {
    "strategy_id": "01M59PAX5AS3FKX3W9D93JY57E",
    "name": "NIFTY straddle",
    "description": "",
    "tags": [
      "weekly"
    ],
    "owner": "@user…",
    "version": 1,
    "summary": "Sell 1 lot of an ATM call (weekly expiry) and sell 1 lot of an ATM put (weekly expiry) on NIFTY. Enter as soon as the window opens, between 09:30 and 13:30, Monday to Friday. Everything closes at 15:15. Size: the legs' lots times each account's multiplier, at most 10 lots per leg. Orders: hedges first when there are any, limit prices 0.5% beyond the market, chasing up to 1% slippage; if only some legs fill: close a sold option left without its hedge, keep the rest. Protection: stop at ₹3,000 loss; no per-leg stops; at most 3 trades a day. No re-entries.",
    "read_only": true,
    "document": {
      "$schema": "/v2/strategies/schema/1.json",
      "schema_version": 1,
      "kind": "cirrus.strategy",
      "metadata": {
        "name": "NIFTY straddle",
        "description": "",
        "tags": [
          "weekly"
        ]
      },
      "spec": {
        "underlying": {
          "name": "NIFTY",
          "exchange": "NFO",
          "reference": "future"
        },
        "watch": {},
        "positional": false,
        "legs": {
          "selector": "chain@1",
          "items": [
            {
              "id": "sc",
              "instrument": "option",
              "side": "sell",
              "option_type": "call",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            },
            {
              "id": "sp",
              "instrument": "option",
              "side": "sell",
              "option_type": "put",
              "expiry": {
                "kind": "weekly",
                "n": 0
              },
              "strike": {
                "by": "atm",
                "offset": 0
              },
              "lots": 1,
              "hedge_for": null,
              "risk": null,
              "liquidity": {
                "max_spread_pct": 3,
                "min_oi": 0,
                "min_premium": 1
              }
            }
          ]
        },
        "entry": {
          "source": {
            "type": "conditions@1",
            "when": null,
            "on": "close",
            "hold_s": 0
          },
          "window": {
            "start": "09:30",
            "end": "13:30"
          },
          "skip_first_min": 0,
          "skip_last_min": 0,
          "days": [
            "mon",
            "tue",
            "wed",
            "thu",
            "fri"
          ],
          "expiry_day": "normal",
          "active_from": null,
          "active_until": null
        },
        "exit": {
          "sources": [],
          "square_off": "15:15",
          "expiry_day_exit": null,
          "max_hold_min": null
        },
        "sizing": {
          "type": "fixed_lots@1",
          "max_lots": 10
        },
        "execution": {
          "type": "protected_limit@1",
          "order": "auto",
          "hedge_wait_ms": 5000,
          "stagger_ms": 0,
          "buffer_pct": 0.5,
          "chase": {
            "every_ms": 1000,
            "max_slippage_pct": 1,
            "max_attempts": 10
          },
          "on_incomplete": "safest",
          "retry_next_signal": null
        },
        "risk": {
          "per_leg": {
            "type": "per_leg@1",
            "stop_loss": null,
            "target": null,
            "trail": null,
            "on_leg_stop": "nothing",
            "at_broker": false
          },
          "combined": {
            "type": "combined@1",
            "scope": "account",
            "basis": "rupees",
            "stop_loss": {
              "rupees": 3000
            },
            "target": null,
            "breakeven": null,
            "trail": null,
            "lock_ladder": [],
            "deployment": null
          },
          "limits": {
            "type": "limits@1",
            "max_loss_per_day": null,
            "max_profit_per_day": null,
            "max_loss_per_week": null,
            "max_cycles_per_day": 3,
            "max_orders_per_day": 50,
            "exit_on_limit": true,
            "on_breaker": "nothing"
          }
        },
        "reentry": {
          "on": [],
          "max": 0,
          "delay_s": 300,
          "require_fresh_signal": true,
          "same_direction_only": true,
          "until": null
        },
        "adjustments": [],
        "notify": [],
        "costs": {
          "model": "auto",
          "slippage_pct": null
        }
      }
    }
  },
  "error": null
}
```

**Example: No longer shared with you (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "shared_not_found",
    "message": "Nothing is shared with you here: the share was stopped, or never existed.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `shared_not_found` | Nothing is shared with the caller here: the share was stopped or the link revoked, or it never existed. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |

### GET /v1/shared

Scope: `read`

List portfolios shared with you

```bash
curl "https://api.cirrus.trade/v1/shared" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].owner_username` | `string` | yes | Username of the owner (the path segment `{owner}` of the other shared routes). |
| `[].owner_name` | `string` | yes | The owner's display name (their username when none is set). |
| `[].scopes` | `string[]` | yes | What the owner shared. One of: `login`, `funds`, `positions`, `holdings`, `open_orders`, `order_history`. |
| `[].account_scope` | `string \| string[]` | yes | Which of the owner's accounts the share covers: `"all"` or a list of account ids. |

**Example: One owner shares everything (200)**

```json
{
  "status": "success",
  "data": [
    {
      "owner_username": "owner-01M3PGPS0YZSQCHVHW944RD3CK",
      "owner_name": "Asha Owner",
      "scopes": [
        "login",
        "funds",
        "positions",
        "holdings",
        "open_orders",
        "order_history"
      ],
      "account_scope": "all"
    }
  ],
  "error": null
}
```

**Example: Sharing is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Data sharing is not available right now.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/shared/consolidated

Scope: `read`

Every shared account on one screen

```bash
curl "https://api.cirrus.trade/v1/shared/consolidated" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `owners` | `object[]` | yes |  |
| `owners[].owner_username` | `string` | yes | Username of the owner. |
| `owners[].owner_name` | `string` | yes | The owner's display name. |
| `owners[].scopes` | `string[]` | yes | What the owner shared. One of: `login`, `funds`, `positions`, `holdings`, `open_orders`, `order_history`. |
| `owners[].account_scope` | `string \| string[]` | yes | Which of the owner's accounts the share covers: `"all"` or a list of account ids. |
| `owners[].accounts` | `object[]` | yes |  |
| `owners[].accounts[].client_id` | `string` | yes | The broker account id. |
| `owners[].accounts[].has_login` | `boolean` | yes | Whether `login` is shared (the account facts below are present). |
| `owners[].accounts[].has_funds` | `boolean` | yes | Whether `funds` is shared. |
| `owners[].accounts[].has_positions` | `boolean` | yes | Whether `positions` is shared. |
| `owners[].accounts[].opening_balance` | `number (double) \| null` | yes | Opening balance; `null` without funds data. |
| `owners[].accounts[].usable_balance` | `number (double) \| null` | yes | Available funds; `null` without funds data. |
| `owners[].accounts[].utilisation` | `number (double) \| null` | yes | Funds in use; `null` without funds data. |
| `owners[].accounts[].pnl` | `number (double) \| null` | yes | Sum of the positions' P&L; `null` without open positions. |
| `owners[].accounts[].positions_count` | `integer (int64)` | yes |  |
| `owners[].accounts[].holdings_count` | `integer (int64)` | yes |  |
| `owners[].accounts[].broker` | `string \| null` | no | With `login`: broker id. |
| `owners[].accounts[].account_tag` | `string \| null` | no | With `login`: the owner's label for the account (may be `null`). |
| `owners[].accounts[].last_login_at` | `string \| null` | no | With `login`: last broker login (Unix seconds as text; may be `null`). |
| `owners[].accounts[].logged_in_today` | `boolean \| null` | no | With `login`: logged in since 08:00 IST today. |
| `owners[].accounts[].updated_at` | `object` | yes | When each kind of the card's data was last read from the broker; `null` when not shared or no current data. |
| `owners[].accounts[].updated_at.positions` | `string (date-time) \| null` | yes |  |
| `owners[].accounts[].updated_at.margins` | `string (date-time) \| null` | yes |  |
| `owners[].accounts[].updated_at.holdings` | `string (date-time) \| null` | yes |  |
| `owners[].accounts[].missing` | `object` | yes | Shared kinds (`positions`, `margins`, `holdings`) with no data to show, and why; kinds with data or not shared are left out. |
| `owners[].accounts[].missing.positions` | `object` | no | Why a shared kind has no data on the card. |
| `owners[].accounts[].missing.positions.code` | `string` | yes | `no_session`, `not_supported` or `stale` (see `PortfolioMissing`). One of: `no_session`, `not_supported`, `stale`. |
| `owners[].accounts[].missing.positions.reason` | `string` | yes | Why, in plain English. |
| `owners[].accounts[].missing.margins` | `object` | no | Why a shared kind has no data on the card. |
| `owners[].accounts[].missing.margins.code` | `string` | yes | `no_session`, `not_supported` or `stale` (see `PortfolioMissing`). One of: `no_session`, `not_supported`, `stale`. |
| `owners[].accounts[].missing.margins.reason` | `string` | yes | Why, in plain English. |
| `owners[].accounts[].missing.holdings` | `object` | no | Why a shared kind has no data on the card. |
| `owners[].accounts[].missing.holdings.code` | `string` | yes | `no_session`, `not_supported` or `stale` (see `PortfolioMissing`). One of: `no_session`, `not_supported`, `stale`. |
| `owners[].accounts[].missing.holdings.reason` | `string` | yes | Why, in plain English. |
| `owners[].accounts[].open_orders_count` | `integer (int64) \| null` | yes | Today's orders not yet final; `null` without `open_orders` or without order data. |
| `owners[].accounts[].failed_orders_count` | `integer (int64) \| null` | yes | Today's orders rejected or never confirmed after retries; `null` without `order_history` or without order data. |
| `owners[].accounts[].orders_updated_at` | `string (date-time) \| null` | yes | When the orders were last read from the broker (the latest broker update of any order); `null` when unknown or not shared. |
| `positions` | `object[]` | yes | Every shared position, across owners and accounts. |
| `positions[].account` | `string` | yes |  |
| `positions[].instrument_token` | `string` | yes |  |
| `positions[].tradingsymbol` | `string` | yes |  |
| `positions[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `positions[].net_qty` | `integer (int64)` | yes | Positive long, negative short. |
| `positions[].buy_qty` | `integer (int64)` | yes |  |
| `positions[].sell_qty` | `integer (int64)` | yes |  |
| `positions[].buy_value` | `number (double)` | yes |  |
| `positions[].sell_value` | `number (double)` | yes |  |
| `positions[].average_price` | `number (double)` | yes |  |
| `positions[].buy_average_price` | `number (double)` | yes |  |
| `positions[].sell_average_price` | `number (double)` | yes |  |
| `positions[].pnl` | `number (double)` | yes | Mark-to-market P&L as reported by the broker (total: booked plus open). |
| `positions[].overnight_qty` | `integer (int64) \| null` | no | Quantity carried in from earlier sessions: positive long, negative short. Absent when the broker does not report it. |
| `positions[].day_buy_qty` | `integer (int64) \| null` | no | Bought today (absent when the broker does not report it). |
| `positions[].day_sell_qty` | `integer (int64) \| null` | no | Sold today (absent when the broker does not report it). |
| `positions[].day_buy_value` | `number (double) \| null` | no | Value bought today, in rupees (absent when not reported). |
| `positions[].day_sell_value` | `number (double) \| null` | no | Value sold today, in rupees (absent when not reported). |
| `positions[].realised_pnl` | `number (double) \| null` | no | Booked (closed) P&L as reported by the broker; absent when not reported. |
| `positions[].unrealised_pnl` | `number (double) \| null` | no | Open (unbooked) P&L as reported by the broker; absent when not reported. |
| `positions[].day_pnl` | `number (double) \| null` | no | Today's P&L, in rupees: a quantity carried in from earlier sessions counts from the previous close, not its original cost. Absent when not reported. |
| `positions[].lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `positions[].broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `positions[].exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `positions[].instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `positions[].symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `positions[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `positions[].strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `positions[].option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `positions[].sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `positions[].isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `positions[].indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `positions[].instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `positions[].ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `positions[].prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |
| `positions[].owner_username` | `string` | yes | Username of the owner. |
| `positions[].owner_name` | `string` | yes | The owner's display name. |

**Example: One owner, two shared accounts (one without data yet) (200)**

```json
{
  "status": "success",
  "data": {
    "owners": [
      {
        "owner_username": "owner-01M3PGPS0YZSQCHVHW944RD3CK",
        "owner_name": "Asha Owner",
        "scopes": [
          "login",
          "funds",
          "positions",
          "holdings",
          "open_orders",
          "order_history"
        ],
        "account_scope": "all",
        "accounts": [
          {
            "client_id": "O1",
            "has_login": true,
            "has_funds": true,
            "has_positions": true,
            "opening_balance": 100000,
            "usable_balance": 80000,
            "utilisation": 20000,
            "pnl": 120.5,
            "positions_count": 1,
            "holdings_count": 1,
            "updated_at": {
              "positions": "2026-10-14T09:17:41.800064Z",
              "margins": "2026-10-14T09:17:41.801539Z",
              "holdings": "2026-10-14T09:17:41.800912Z"
            },
            "missing": {},
            "open_orders_count": 1,
            "failed_orders_count": 0,
            "orders_updated_at": "2026-10-14T09:17:41.802298Z",
            "broker": "paper",
            "account_tag": "main",
            "last_login_at": "1790683341",
            "logged_in_today": true
          },
          {
            "client_id": "O2",
            "has_login": true,
            "has_funds": true,
            "has_positions": true,
            "opening_balance": null,
            "usable_balance": null,
            "utilisation": null,
            "pnl": null,
            "positions_count": 0,
            "holdings_count": 0,
            "updated_at": {
              "positions": null,
              "margins": null,
              "holdings": null
            },
            "missing": {
              "positions": {
                "code": "no_session",
                "reason": "No live data yet: the account has no active broker session (log in to the broker)"
              },
              "margins": {
                "code": "no_session",
                "reason": "No live data yet: the account has no active broker session (log in to the broker)"
              },
              "holdings": {
                "code": "no_session",
                "reason": "No live data yet: the account has no active broker session (log in to the broker)"
              }
            },
            "open_orders_count": null,
            "failed_orders_count": null,
            "orders_updated_at": null,
            "broker": "paper",
            "account_tag": null,
            "last_login_at": null,
            "logged_in_today": false
          }
        ]
      }
    ],
    "positions": [
      {
        "account": "O1",
        "tradingsymbol": "RELIANCE-EQ",
        "product": "MIS",
        "net_qty": 5,
        "buy_qty": 5,
        "sell_qty": 0,
        "buy_value": 12500,
        "sell_value": 0,
        "average_price": 2500,
        "buy_average_price": 2500,
        "sell_average_price": 0,
        "pnl": 120.5,
        "lot_size": 1,
        "exchange": "NSE",
        "instrument_type": "EQUITY",
        "symbol": "RELIANCE",
        "expiry": "",
        "strike": 0,
        "option_type": "",
        "ltp": 2500,
        "prev_close": 2490,
        "owner_username": "owner-01M3PGPS0YZSQCHVHW944RD3CK",
        "owner_name": "Asha Owner",
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ]
  },
  "error": null
}
```

**Example: Sharing is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Data sharing is not available right now.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/shared/consolidated/refresh

Scope: `read`

Refresh every shared portfolio

```bash
curl -X POST "https://api.cirrus.trade/v1/shared/consolidated/refresh" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `owners` | `integer (int64)` | yes | Owners sharing with the caller. |
| `dispatched` | `integer (int64)` | yes | Owners actually refreshed (the others were refreshed moments ago). |

**Example: Every owner refreshed (202)**

```json
{
  "status": "success",
  "data": {
    "owners": 1,
    "dispatched": 1
  },
  "error": null
}
```

**Example: Sharing is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Data sharing is not available right now.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/shared/consolidated/{view}

Scope: `read`

One view of every shared account

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `view` | `string` | yes | What to read: `positions`, `holdings`, `margins` (needs `funds`), `open_orders`, `order_history`. One of: `positions`, `holdings`, `margins`, `open_orders`, `order_history`. |

```bash
curl "https://api.cirrus.trade/v1/shared/consolidated/<view>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accounts` | `object[]` | yes | One entry per shared account with data. |
| `accounts[].owner_username` | `string` | yes | Username of the owner. |
| `accounts[].owner_name` | `string` | yes | The owner's display name. |
| `accounts[].account` | `string` | yes | The broker account id. |
| `accounts[].updated_at` | `string (date-time) \| null` | yes | When the data was last read from the broker (orders: the latest broker update); `null` when unknown. |
| `accounts[].data` | `object[] \| object[] \| object[] \| MarginRow` | yes | `positions`, `holdings`, `open_orders`, `order_history`: a list of rows (may be empty); `margins`: one object. |
| `accounts[].data.account` | `string` | yes | When `type` is `MarginRow`. |
| `accounts[].data.opening_balance` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.available` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.utilised` | `number (double)` | yes | When `type` is `MarginRow`. Share of the opening balance in use, as a percent (0-100). |
| `accounts[].data.used` | `number (double) \| null` | no | When `type` is `MarginRow`. Margin in use, in rupees; absent when the broker does not report it. |
| `accounts[].data.payin` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds added today, in rupees; absent when not reported. |
| `accounts[].data.payout` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds withdrawn today, in rupees; absent when not reported. |
| `accounts[].data.collateral` | `number (double) \| null` | no | When `type` is `MarginRow`. Collateral (pledged holdings) margin, in rupees; absent when not reported. |
| `accounts[].data.realised_pnl` | `number (double) \| null` | no | When `type` is `MarginRow`. Booked P&L for the day, in rupees; absent when not reported. |
| `missing` | `object[]` | yes | Shared accounts without data. |
| `missing[].owner_username` | `string` | yes | Username of the owner. |
| `missing[].owner_name` | `string` | yes | The owner's display name. |
| `missing[].account` | `string` | yes | The broker account id. |
| `missing[].code` | `string` | yes | `no_session`, `not_supported` or `stale` (see `PortfolioMissing`). One of: `no_session`, `not_supported`, `stale`. |
| `missing[].reason` | `string` | yes | Why, in plain English. |
| `skipped_owners` | `object[]` | yes | Owners left out, and why. |
| `skipped_owners[].owner_username` | `string` | yes | Username of the owner. |
| `skipped_owners[].reason` | `string` | yes | Why an owner sharing with the caller is left out of a consolidated view. One of: `not_shared`, `unavailable`. |

**Example: Every shared account's positions; the account without data is under missing (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "owner_username": "owner-01M4WV81FTX36BK617X9JFYPQ3",
        "owner_name": "Asha Owner",
        "account": "O1",
        "updated_at": "2026-10-14T09:17:41.800064Z",
        "data": [
          {
            "account": "O1",
            "tradingsymbol": "RELIANCE-EQ",
            "product": "MIS",
            "net_qty": 5,
            "buy_qty": 5,
            "sell_qty": 0,
            "buy_value": 12500,
            "sell_value": 0,
            "average_price": 2500,
            "buy_average_price": 2500,
            "sell_average_price": 0,
            "pnl": 120.5,
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": [
      {
        "owner_username": "owner-01M4WV81FTX36BK617X9JFYPQ3",
        "owner_name": "Asha Owner",
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ],
    "skipped_owners": []
  },
  "error": null
}
```

**Example: Not one of the views (`accounts` is per owner only) (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "view must be one of positions, holdings, margins, open_orders, order_history",
    "field": "view"
  }
}
```

**Example: Sharing is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Data sharing is not available right now.",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/shared/{owner}/refresh

Scope: `read`

Refresh a shared portfolio

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `owner` | `string` | yes | The owner's username (`owner_username` from `GET /v1/shared`). |

```bash
curl -X POST "https://api.cirrus.trade/v1/shared/<owner>/refresh" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (202)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `requested` | `integer (int64) \| null` | yes | Accounts asked to refresh; `null` when the owner was refreshed moments ago (within 10 seconds, by anyone) and nothing was asked. |

**Example: Both shared accounts asked to refresh (202)**

```json
{
  "status": "success",
  "data": {
    "requested": 2
  },
  "error": null
}
```

**Example: Refreshed moments ago: nothing asked (202)**

```json
{
  "status": "success",
  "data": {
    "requested": null
  },
  "error": null
}
```

**Example: That user shares nothing with the caller (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "not_shared",
    "message": "Not shared with you",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `not_shared` | That user has not shared this data with the caller. |
| 403 | `oms_not_enabled` | Order management is not enabled for this user. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/shared/{owner}/{view}

Scope: `read`

Read one view of a shared portfolio

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `owner` | `string` | yes | The owner's username (`owner_username` from `GET /v1/shared`). |
| `view` | `string` | yes | What to read: `accounts` (needs `login`), `positions`, `holdings`, `margins` (needs `funds`), `open_orders`, `order_history`. One of: `accounts`, `positions`, `holdings`, `margins`, `open_orders`, `order_history`. |

```bash
curl "https://api.cirrus.trade/v1/shared/<owner>/<view>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200) · SharedAccounts**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accounts` | `object[]` | yes |  |
| `accounts[].client_id` | `string` | yes | The broker account id. |
| `accounts[].broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `accounts[].account_tag` | `string \| null` | yes | The owner's label for the account. |
| `accounts[].last_login_at` | `string \| null` | yes | Last broker login, as text (Unix seconds); `null` when unknown. |
| `accounts[].logged_in_today` | `boolean` | yes | Logged in since the start of the current trading day (08:00 IST). |

SharedAccounts: `accounts` view: the shared accounts.

**Response `data` (200) · SharedPortfolio**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes | The broker account id. |
| `accounts[].updated_at` | `string (date-time) \| null` | yes | When the data was last read from the broker (orders: the latest broker update); `null` when unknown. |
| `accounts[].data` | `object[] \| object[] \| object[] \| MarginRow` | yes | `positions`, `holdings`, `open_orders`, `order_history`: a list of rows (may be empty); `margins`: one object. |
| `accounts[].data.account` | `string` | yes | When `type` is `MarginRow`. |
| `accounts[].data.opening_balance` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.available` | `number (double)` | yes | When `type` is `MarginRow`. |
| `accounts[].data.utilised` | `number (double)` | yes | When `type` is `MarginRow`. Share of the opening balance in use, as a percent (0-100). |
| `accounts[].data.used` | `number (double) \| null` | no | When `type` is `MarginRow`. Margin in use, in rupees; absent when the broker does not report it. |
| `accounts[].data.payin` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds added today, in rupees; absent when not reported. |
| `accounts[].data.payout` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds withdrawn today, in rupees; absent when not reported. |
| `accounts[].data.collateral` | `number (double) \| null` | no | When `type` is `MarginRow`. Collateral (pledged holdings) margin, in rupees; absent when not reported. |
| `accounts[].data.realised_pnl` | `number (double) \| null` | no | When `type` is `MarginRow`. Booked P&L for the day, in rupees; absent when not reported. |
| `missing` | `object[]` | yes |  |
| `missing[].account` | `string` | yes |  |
| `missing[].code` | `string` | yes | `no_session`: no active broker session (log in to the broker); `not_supported`: this broker is not supported yet; `stale`: the only data is from an earlier broker session (read before today's 04:00 IST), so it is not shown (log in to the broker). One of: `no_session`, `not_supported`, `stale`. |
| `missing[].reason` | `string` | yes | Why, in plain English (for `stale`: when the data was last read, e.g. `No data since 29 Sep 19:20 IST: log in to the broker`). |

SharedPortfolio: `positions`, `holdings`, `margins`, `open_orders` and `order_history`
views: one entry per shared account with data; the others under
`missing`.

**Example: The shared accounts (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "client_id": "O1",
        "broker": "paper",
        "account_tag": "main",
        "last_login_at": "1790683341",
        "logged_in_today": true
      },
      {
        "client_id": "O2",
        "broker": "paper",
        "account_tag": null,
        "last_login_at": null,
        "logged_in_today": false
      }
    ]
  },
  "error": null
}
```

**Example: Holdings (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-29T12:02:21.916874Z",
        "data": [
          {
            "account": "O1",
            "tradingsymbol": "RELIANCE-EQ",
            "product": "DELIVERY",
            "quantity": 2,
            "average_price": 2400,
            "invested_amount": 4800,
            "pnl": 200,
            "pnl_percent": 4.17,
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: Funds (one object per account) (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-29T12:02:21.918037Z",
        "data": {
          "account": "O1",
          "opening_balance": 100000,
          "available": 80000,
          "utilised": 20000
        }
      }
    ],
    "missing": [
      {
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: Today's orders not yet final (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-29T12:02:21.918596Z",
        "data": [
          {
            "broker_tag": null,
            "order_id": "PAPER-7",
            "parent_tag": null,
            "username": "owner-01M3PGPS1MDWS54TM40MNT7JP5",
            "account": "O1",
            "broker": "paper",
            "tradingsymbol": "RELIANCE-EQ",
            "side": "BUY",
            "order_type": "LIMIT",
            "product": "MIS",
            "quantity": 5,
            "price": 2500,
            "trigger_price": null,
            "state": "OPEN",
            "filled_qty": 0,
            "average_price": null,
            "status_message": null,
            "broker_updated_at": "2026-09-29T12:02:21.918545Z",
            "created_at": "2026-09-29T12:02:21.918545Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "OPEN",
                "at": "2026-09-29T12:02:21.918545Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3PGPS2Y3SZDBE6XPE2YYTJ2"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: Today's final orders (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-29T12:02:21.918596Z",
        "data": [
          {
            "broker_tag": null,
            "order_id": "PAPER-6",
            "parent_tag": null,
            "username": "owner-01M3PGPS1MDWS54TM40MNT7JP5",
            "account": "O1",
            "broker": "paper",
            "tradingsymbol": "RELIANCE-EQ",
            "side": "BUY",
            "order_type": "LIMIT",
            "product": "MIS",
            "quantity": 5,
            "price": 2500,
            "trigger_price": null,
            "state": "FILLED",
            "filled_qty": 5,
            "average_price": 2500,
            "status_message": null,
            "broker_updated_at": "2026-09-29T12:02:21.918596Z",
            "created_at": "2026-09-29T12:02:21.918596Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "FILLED",
                "at": "2026-09-29T12:02:21.918596Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3PGPS2YJ9SYSJD0P10Z0VT8"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: Positions; the account without data is under missing (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-29T12:02:21.916159Z",
        "data": [
          {
            "account": "O1",
            "tradingsymbol": "RELIANCE-EQ",
            "product": "MIS",
            "net_qty": 5,
            "buy_qty": 5,
            "sell_qty": 0,
            "buy_value": 12500,
            "sell_value": 0,
            "average_price": 2500,
            "buy_average_price": 2500,
            "sell_average_price": 0,
            "pnl": 120.5,
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 2490,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "code": "no_session",
        "reason": "No live data yet: the account has no active broker session (log in to the broker)"
      }
    ]
  },
  "error": null
}
```

**Example: That user shares nothing with the caller (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "not_shared",
    "message": "Not shared with you",
    "field": null
  }
}
```

**Example: Not one of the views (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "view must be one of accounts, positions, holdings, margins, open_orders, order_history",
    "field": "view"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `not_shared` | That user has not shared this data with the caller. |
| 403 | `oms_not_enabled` | Order management is not enabled for this user. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Instruments

Find the instrument token to trade, its lot size and expiries.

### GET /v1/instruments/expiries

Scope: `read`

Expiry dates of an underlying

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `underlying` | `string` | yes | Exact underlying symbol, e.g. `NIFTY`. |
| `exchange` | `string` | no | Only contracts on this exchange, as `GET /v1/instruments` returns it (`NFO` / `BFO` mean the contracts listed under `NSE` / `BSE`). |

```bash
curl "https://api.cirrus.trade/v1/instruments/expiries?underlying=<underlying>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `underlying` | `string` | yes | The underlying asked for, as given. |
| `expiries` | `string[]` | yes | Distinct expiry dates (`YYYY-MM-DD`), earliest first; empty when the underlying has no contracts. |

**Example: Expiries of an index's contracts (`NFO` finds those listed under NSE) (200)**

```json
{
  "status": "success",
  "data": {
    "underlying": "FINNIFTY",
    "expiries": [
      "2026-10-27"
    ]
  },
  "error": null
}
```

**Example: `underlying` is required (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "`underlying` is required, e.g. NIFTY",
    "field": "underlying"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

## Notifications

Telegram and in-app notifications, and which events go where.

### GET /v1/notifications

Scope: `read`

List notifications

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | no | Page size, 1-100 (default 50). |
| `before` | `string` | no | `next_cursor` of the previous page. |

```bash
curl "https://api.cirrus.trade/v1/notifications" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `notifications` | `object[]` | yes |  |
| `notifications[].id` | `string` | yes | Stable id (`ntf_` + 24 hex): the same on every redelivery, so de-duplicate on it. |
| `notifications[].event` | `string` | yes | An event type a user can route. Stored in preferences (Mongo) and sent in notices: values are only ever added (readers first). One of: `trigger_fired`, `trigger_refused`, `trigger_ended`, `exit_placed`, `exit_filled`, `exit_refused`, `order_filled`, `order_rejected`, `order_unconfirmed`, `protection_failed`, `entry_failed`, `strategy_entered`, `strategy_exited`, `risk_breached`, `session_expired`, `live_updates`, `account_setup`, `webhook_disabled`, `daily_summary`, `channel_broken`, `strategy_armed`, `strategy_refused`, `strategy_safety_pause`, `strategy_kill_switch`, `strategy_error`. |
| `notifications[].severity` | `string` | yes | How much a notice needs the user. One of: `info`, `warning`, `critical`. |
| `notifications[].title` | `string` | yes | One line, sentence case. |
| `notifications[].what_happened` | `string` | yes | What happened, in plain English. |
| `notifications[].what_we_did` | `string \| null` | yes | What was done about it (null when nothing needed doing). |
| `notifications[].what_you_should_do` | `string \| null` | yes | What the user should do (null when nothing). |
| `notifications[].account` | `string \| null` | yes | The broker account it is about. |
| `notifications[].strategy` | `string \| null` | yes | The strategy it is about. |
| `notifications[].trigger_id` | `string \| null` | yes | The v2 trigger it is about. |
| `notifications[].deployment_id` | `string \| null` | no | The automated-strategy deployment it is about (open it at `GET /v2/deployments/{deployment_id}`). Added later: absent on older notices. |
| `notifications[].cycle` | `object \| null` | no | The deployment's run (cycle) it is about. Added later: absent on older notices and when it is about no single run. |
| `notifications[].cycle.day` | `string` | yes | Trading day, `YYYY-MM-DD`. |
| `notifications[].cycle.n` | `integer (int32)` | yes | The run's number that day (1 = the first). |
| `notifications[].subtype` | `string \| null` | no | A finer kind within `event`, when there is one (other values may appear; ignore unknown ones): `strategy_adjusted` (a strategy adjusted its open trade; `event` `strategy_entered`), `risk_target` (a risk limit's profit target fired: `event` `trigger_fired`, or `risk_breached` for a last target that exits everything), `risk_trail` (a risk limit's profit trail armed or moved up; `event` `trigger_fired`). Added later: absent otherwise. |
| `notifications[].source` | `object \| null` | no | Why it happened (see `NotificationSource`). Added later: absent on older notices and when unknown. |
| `notifications[].source.kind` | `string` | yes | `strategy`, `trigger`, `protection`, `risk_limit`, `signal`, `manual`, `api` or `partner` (other values may appear). |
| `notifications[].source.name` | `string \| null` | no | Its name, when it has one (the strategy's name, the signal URL's "TradingView: my-url", the API key's or partner app's name, the trigger's kind). |
| `notifications[].source.role` | `string \| null` | no | The part this notice plays: `entry`, `exit`, `stop_loss`, `trailing_stop`, `target`, `alert`, `adjustment` (other values may appear). |
| `notifications[].source.rung` | `integer (int32) \| null` | no | For a ladder target: which one (1 = the first). |
| `notifications[].source.rungs` | `integer (int32) \| null` | no | For a ladder target: how many there are, when known. |
| `notifications[].order` | `object \| null` | no | The order it is about (see `NotificationOrder`). Added later: absent on older notices and when it is about no single order. |
| `notifications[].order.symbol` | `string` | yes | Trading symbol (`RELIANCE-EQ`, `NIFTY27OCT202622500PE`). |
| `notifications[].order.name` | `string \| null` | no | The instrument's name as people read it (`RELIANCE`, `NIFTY 27 Oct 22500 PE`), from the instrument master. |
| `notifications[].order.exchange` | `string \| null` | no |  |
| `notifications[].order.side` | `string \| null` | no | `BUY` or `SELL`. |
| `notifications[].order.product` | `string \| null` | no | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `notifications[].order.order_type` | `string \| null` | no | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `notifications[].order.quantity` | `integer (int64) \| null` | no | Ordered quantity. |
| `notifications[].order.filled_qty` | `integer (int64) \| null` | no | Filled so far. |
| `notifications[].order.average_price` | `number (double) \| null` | no | Average fill price. |
| `notifications[].account_tag` | `string \| null` | no | The account's name in the app (its tag), when it has one. Added later: absent on older notices. |
| `notifications[].realized_pnl` | `number (double) \| null` | no | Realised profit (positive) or loss (negative) in rupees of the position or leg an exit closed, only when it is known from its fills. Added later: absent otherwise. |
| `notifications[].thread` | `string \| null` | no | Groups notices about one thing (an order and its protection and exits, a trigger, a strategy run): the same value on each. Opaque (`order:…`, `trigger:…`, `cycle:…`). Added later: absent on older notices and on account-level ones. |
| `notifications[].origin` | `object` | yes | Where a notice came from (for support; never needed to read it). |
| `notifications[].origin.kind` | `string` | yes | `trigger_update`, `activity` or `anomaly` (other values may appear). |
| `notifications[].origin.id` | `string` | yes | The source record's id (a trigger update's `event_id`, an activity record's `id`). |
| `notifications[].at` | `string` | yes | When it happened; RFC 3339, India time. |
| `next_cursor` | `string \| null` | yes | Pass as `before` for the next page; null on the last page. |

**Example: The app inbox (200)**

```json
{
  "status": "success",
  "data": {
    "notifications": [
      {
        "id": "ntf_192dff593f9807f33bb4efe6",
        "event": "trigger_fired",
        "severity": "info",
        "title": "Stop-loss hit on RELIANCE",
        "what_happened": "RELIANCE traded at ₹2,474.50.",
        "what_we_did": "We're placing the exit order.",
        "what_you_should_do": null,
        "account": "P1",
        "strategy": "nifty-momentum",
        "trigger_id": "01JA0000000000000000000001",
        "source": {
          "kind": "strategy",
          "name": "nifty-momentum",
          "role": "stop_loss"
        },
        "thread": "trigger:01JA0000000000000000000001",
        "origin": {
          "kind": "trigger_update",
          "id": "01JA0000000000000000000001:3"
        },
        "at": "2026-10-16T10:45:30+05:30"
      }
    ],
    "next_cursor": null
  },
  "error": null
}
```

**Example: limit is not a number (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_query",
    "message": "invalid query string: invalid digit found in string",
    "field": null
  }
}
```

**Example: limit out of range (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "limit must be 1 to 100",
    "field": "limit"
  }
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/notifications/events

Scope: `read`

List notification event types

```bash
curl "https://api.cirrus.trade/v1/notifications/events" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].event` | `string` | yes | An event type a user can route. Stored in preferences (Mongo) and sent in notices: values are only ever added (readers first). One of: `trigger_fired`, `trigger_refused`, `trigger_ended`, `exit_placed`, `exit_filled`, `exit_refused`, `order_filled`, `order_rejected`, `order_unconfirmed`, `protection_failed`, `entry_failed`, `strategy_entered`, `strategy_exited`, `risk_breached`, `session_expired`, `live_updates`, `account_setup`, `webhook_disabled`, `daily_summary`, `channel_broken`, `strategy_armed`, `strategy_refused`, `strategy_safety_pause`, `strategy_kill_switch`, `strategy_error`. |
| `[].label` | `string` | yes |  |
| `[].description` | `string` | yes |  |
| `[].group` | `string` | yes | Filter group shown in the app. One of: `triggers`, `orders`, `strategies`, `errors`, `account`, `summary`. |
| `[].default_on` | `boolean` | yes | On for a channel unless the user turns it off. |
| `[].mandatory` | `boolean` | yes | Always delivered in the app (the app channel cannot turn it off). |
| `[].account_level` | `boolean` | yes | Sent whatever a channel's strategy / trigger scope (it is about an account, not a strategy). |
| `[].available` | `boolean` | yes | `false`: not sent yet (it can be routed ahead of the feature). |

**Example: Every event type and its defaults (200)**

```json
{
  "status": "success",
  "data": [
    {
      "event": "trigger_fired",
      "label": "Trigger fired",
      "description": "A stop-loss, target, alert or ladder rung was hit.",
      "group": "triggers",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "trigger_refused",
      "label": "Trigger refused or errored",
      "description": "A change to a trigger was refused, or the trigger stopped with an error.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "trigger_ended",
      "label": "Trigger ended",
      "description": "A trigger ended on its own at day end or contract expiry.",
      "group": "triggers",
      "default_on": false,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "exit_placed",
      "label": "Exit placed",
      "description": "A trigger placed its exit order.",
      "group": "triggers",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "exit_filled",
      "label": "Exit filled",
      "description": "An exit placed by a trigger filled.",
      "group": "triggers",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "exit_refused",
      "label": "Exit refused",
      "description": "An exit could not be placed, or the broker refused it.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "order_filled",
      "label": "Order filled",
      "description": "Any other order filled.",
      "group": "orders",
      "default_on": false,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "order_rejected",
      "label": "Order rejected",
      "description": "The broker refused an order.",
      "group": "errors",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "order_unconfirmed",
      "label": "Order not confirmed",
      "description": "The broker did not confirm whether an order went through.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "protection_failed",
      "label": "Protection failed",
      "description": "A stop-loss or target for a position could not be set up.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "entry_failed",
      "label": "Entry failed",
      "description": "An automated entry failed, may not have gone through, or is still resting unfilled.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "strategy_entered",
      "label": "Strategy entered",
      "description": "An automated strategy opened its positions.",
      "group": "strategies",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "strategy_exited",
      "label": "Strategy exited",
      "description": "An automated strategy closed its positions.",
      "group": "strategies",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "risk_breached",
      "label": "Risk limit breached",
      "description": "A loss or profit limit was reached: positions exited or the strategy paused.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "session_expired",
      "label": "Broker session expired",
      "description": "A broker login expired or was replaced; orders cannot be placed until you log in again.",
      "group": "account",
      "default_on": true,
      "mandatory": true,
      "account_level": true,
      "available": true
    },
    {
      "event": "live_updates",
      "label": "Live updates",
      "description": "Live updates from a broker stopped or came back.",
      "group": "account",
      "default_on": true,
      "mandatory": false,
      "account_level": true,
      "available": true
    },
    {
      "event": "account_setup",
      "label": "Account setup",
      "description": "An account is missing a setting a feature needs.",
      "group": "account",
      "default_on": true,
      "mandatory": false,
      "account_level": true,
      "available": true
    },
    {
      "event": "webhook_disabled",
      "label": "Webhook turned off",
      "description": "One of your webhooks was turned off after it kept failing.",
      "group": "account",
      "default_on": true,
      "mandatory": false,
      "account_level": true,
      "available": true
    },
    {
      "event": "daily_summary",
      "label": "Daily summary",
      "description": "Your day's trades and P&L after market close.",
      "group": "summary",
      "default_on": true,
      "mandatory": false,
      "account_level": true,
      "available": false
    },
    {
      "event": "channel_broken",
      "label": "Notifications not reaching you",
      "description": "A channel (such as Telegram) stopped delivering your updates.",
      "group": "account",
      "default_on": true,
      "mandatory": true,
      "account_level": true,
      "available": true
    },
    {
      "event": "strategy_armed",
      "label": "Strategy armed",
      "description": "An automated strategy started watching for its entry.",
      "group": "strategies",
      "default_on": false,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "strategy_refused",
      "label": "Strategy entry refused",
      "description": "An automated strategy's entry was refused; nothing was placed and it tries again as its rules allow.",
      "group": "strategies",
      "default_on": true,
      "mandatory": false,
      "account_level": false,
      "available": true
    },
    {
      "event": "strategy_safety_pause",
      "label": "Strategy paused for safety",
      "description": "An automated strategy paused itself: too many orders or losses too fast, positions that differ from the broker's, or an entry the broker did not confirm.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    },
    {
      "event": "strategy_kill_switch",
      "label": "All strategies stopped",
      "description": "Every automated strategy was stopped at once (the kill switch).",
      "group": "strategies",
      "default_on": true,
      "mandatory": true,
      "account_level": true,
      "available": true
    },
    {
      "event": "strategy_error",
      "label": "Strategy error",
      "description": "An automated strategy stopped with an error.",
      "group": "errors",
      "default_on": true,
      "mandatory": true,
      "account_level": false,
      "available": true
    }
  ],
  "error": null
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/notifications/channels

Scope: `read`

Get notification channels

```bash
curl "https://api.cirrus.trade/v1/notifications/channels" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `telegram` | `object` | yes |  |
| `telegram.available` | `boolean` | yes | Telegram can be used on this server. `false`: there is no bot, so nothing is sent to Telegram and it cannot be linked (see `unavailable_reason`); the app and webhooks still work. |
| `telegram.unavailable_reason` | `string \| null` | yes | Plain words to show when `available` is `false`; null otherwise. |
| `telegram.state` | `string` | yes | `not_linked`, `linked` (delivering) or `broken` (Telegram refuses our messages: see `broken_reason`). One of: `not_linked`, `linked`, `broken`. |
| `telegram.linked` | `boolean` | yes |  |
| `telegram.healthy` | `boolean` | yes | Linked and delivering. |
| `telegram.broken_reason` | `string \| null` | yes | Why Telegram refuses our messages (`broken` only). One of: `blocked_by_user`, `chat_not_found`. |
| `telegram.broken_at` | `string \| null` | yes |  |
| `telegram.linked_at` | `string \| null` | yes |  |
| `telegram.last_delivered_at` | `string \| null` | yes |  |
| `telegram.telegram_username` | `string \| null` | yes | The user's Telegram `@username`, when they have one. |
| `telegram.bot_username` | `string` | yes | The bot to talk to; empty when `available` is `false`. |
| `app_push` | `object` | yes |  |
| `app_push.healthy` | `boolean` | yes | Always true: the app inbox (`GET /v1/notifications`) keeps every notice for 30 days. |
| `app_push.stream_topic` | `string` | yes | Subscribe to this `/v1/stream` topic for live notices. |
| `webhook` | `object` | yes |  |
| `webhook.enabled` | `boolean` | yes | The webhook channel is on in the preferences. |
| `webhook.receivers` | `object[]` | yes |  |
| `webhook.receivers[].id` | `string` | yes |  |
| `webhook.receivers[].host` | `string` | yes | The receiver's host (never the path or query). |
| `webhook.receivers[].enabled` | `boolean` | yes |  |
| `webhook.receivers[].healthy` | `boolean` | yes | Enabled and its last delivery succeeded. |
| `webhook.receivers[].selected` | `boolean` | yes | Picked in the webhook channel's preference. |
| `webhook.receivers[].disabled_reason` | `string \| null` | yes |  |
| `prompts` | `object[]` | yes | What the app should show, most urgent first (empty: all well). |
| `prompts[].kind` | `string` | yes | Something the app should show the user. One of: `connect_telegram`, `telegram_broken`, `webhook_failing`. |
| `prompts[].severity` | `string` | yes | How much a notice needs the user. One of: `info`, `warning`, `critical`. |
| `prompts[].message` | `string` | yes | Plain words to show as is. |

**Example: Telegram linked and delivering (200)**

```json
{
  "status": "success",
  "data": {
    "telegram": {
      "available": true,
      "unavailable_reason": null,
      "state": "linked",
      "linked": true,
      "healthy": true,
      "broken_reason": null,
      "broken_at": null,
      "linked_at": "2026-10-18T13:27:56.350+05:30",
      "last_delivered_at": null,
      "telegram_username": "asha_trades",
      "bot_username": "desk_bot"
    },
    "app_push": {
      "healthy": true,
      "stream_topic": "notifications"
    },
    "webhook": {
      "enabled": false,
      "receivers": []
    },
    "prompts": []
  },
  "error": null
}
```

**Example: Telegram not linked yet: the app offers to connect it (200)**

```json
{
  "status": "success",
  "data": {
    "telegram": {
      "available": true,
      "unavailable_reason": null,
      "state": "not_linked",
      "linked": false,
      "healthy": false,
      "broken_reason": null,
      "broken_at": null,
      "linked_at": null,
      "last_delivered_at": null,
      "telegram_username": null,
      "bot_username": "desk_bot"
    },
    "app_push": {
      "healthy": true,
      "stream_topic": "notifications"
    },
    "webhook": {
      "enabled": false,
      "receivers": []
    },
    "prompts": [
      {
        "kind": "connect_telegram",
        "severity": "info",
        "message": "Get your trade updates on Telegram: connect it in one tap."
      }
    ]
  },
  "error": null
}
```

**Example: No Telegram bot on this server: the app and webhooks only (200)**

```json
{
  "status": "success",
  "data": {
    "telegram": {
      "available": false,
      "unavailable_reason": "Telegram isn't set up on this server yet",
      "state": "not_linked",
      "linked": false,
      "healthy": false,
      "broken_reason": null,
      "broken_at": null,
      "linked_at": null,
      "last_delivered_at": null,
      "telegram_username": null,
      "bot_username": ""
    },
    "app_push": {
      "healthy": true,
      "stream_topic": "notifications"
    },
    "webhook": {
      "enabled": false,
      "receivers": []
    },
    "prompts": []
  },
  "error": null
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/notifications/preferences

Scope: `read`

Get notification preferences

```bash
curl "https://api.cirrus.trade/v1/notifications/preferences" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `telegram` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `telegram.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `telegram.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `telegram.events.*` | `boolean` | no | Any key. |
| `telegram.scope` | `object` | no |  |
| `telegram.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `telegram.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `telegram.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `telegram.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `app_push` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `app_push.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `app_push.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `app_push.events.*` | `boolean` | no | Any key. |
| `app_push.scope` | `object` | no |  |
| `app_push.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `app_push.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `app_push.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `app_push.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `webhook` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `webhook.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `webhook.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `webhook.events.*` | `boolean` | no | Any key. |
| `webhook.scope` | `object` | no |  |
| `webhook.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `webhook.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `webhook.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `webhook.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |

**Example: Never changed: every default (200)**

```json
{
  "status": "success",
  "data": {
    "telegram": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    },
    "app_push": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    },
    "webhook": {
      "enabled": false,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    }
  },
  "error": null
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PUT /v1/notifications/preferences

Scope: `read`

Replace notification preferences

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `telegram` | `object \| null` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `telegram.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `telegram.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `telegram.events.*` | `boolean` | no | Any key. |
| `telegram.scope` | `object` | no |  |
| `telegram.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `telegram.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `telegram.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `telegram.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `app_push` | `object \| null` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `app_push.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `app_push.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `app_push.events.*` | `boolean` | no | Any key. |
| `app_push.scope` | `object` | no |  |
| `app_push.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `app_push.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `app_push.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `app_push.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `webhook` | `object \| null` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `webhook.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `webhook.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `webhook.events.*` | `boolean` | no | Any key. |
| `webhook.scope` | `object` | no |  |
| `webhook.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `webhook.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `webhook.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `webhook.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |

```bash
curl -X PUT "https://api.cirrus.trade/v1/notifications/preferences" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "telegram": {
      "enabled": true,
      "events": {
        "order_filled": true,
        "trigger_ended": false
      },
      "scope": {
        "mode": "selected",
        "strategies": [
          "nifty-momentum"
        ],
        "triggers": []
      }
    },
    "app_push": {
      "enabled": true,
      "events": {},
      "scope": {
        "mode": "all"
      }
    },
    "webhook": {
      "enabled": false
    }
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `telegram` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `telegram.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `telegram.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `telegram.events.*` | `boolean` | no | Any key. |
| `telegram.scope` | `object` | no |  |
| `telegram.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `telegram.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `telegram.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `telegram.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `app_push` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `app_push.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `app_push.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `app_push.events.*` | `boolean` | no | Any key. |
| `app_push.scope` | `object` | no |  |
| `app_push.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `app_push.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `app_push.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `app_push.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |
| `webhook` | `object` | no | One channel's preference as stored and as sent. Unknown fields and event names are kept out of the way (a newer pod may have written them), never refused. |
| `webhook.enabled` | `boolean` | no | Off: this channel gets nothing (the app still gets mandatory events). |
| `webhook.events` | `map<string, boolean>` | no | Event type (`trigger_fired`, ...) -> on / off. A missing event follows its default (`GET /v1/notifications/events`). |
| `webhook.events.*` | `boolean` | no | Any key. |
| `webhook.scope` | `object` | no |  |
| `webhook.scope.mode` | `string` | no | Which notices a channel gets. One of: `all`, `selected`. |
| `webhook.scope.strategies` | `string[]` | no | Strategy names (`selected` only). |
| `webhook.scope.triggers` | `string[]` | no | v2 trigger ids (`selected` only). |
| `webhook.webhook_ids` | `string[] \| null` | no | `webhook` only: the webhook ids to send to; null = every enabled webhook of the user. |

**Example: Fills on Telegram, one strategy only (200)**

```json
{
  "status": "success",
  "data": {
    "telegram": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": true,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "selected",
        "strategies": [
          "nifty-momentum"
        ],
        "triggers": []
      }
    },
    "app_push": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    },
    "webhook": {
      "enabled": false,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    }
  },
  "error": null
}
```

**Example: Not a preferences body (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: invalid length 0, expected struct PreferencesUpdate with 3 elements",
    "field": null
  }
}
```

**Example: An unknown or revoked API key (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Invalid API key",
    "field": null
  }
}
```

**Example: An API key without the `notifications` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `notifications` scope",
    "field": null
  }
}
```

**Example: An unknown event name is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "unknown event; see GET /v1/notifications/events",
    "field": "telegram.events.fills"
  }
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/notifications/telegram/link

Access: app session only (API keys and partner tokens get 403)

Start linking Telegram

```bash
curl -X POST "https://api.cirrus.trade/v1/notifications/telegram/link" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | yes | Open this (`https://t.me/<bot>?start=<token>`): Telegram opens the bot, the user taps Start, and the chat is linked. Works once. |
| `bot_username` | `string` | yes |  |
| `expires_at` | `string` | yes | When the link stops working; RFC 3339, India time. |
| `expires_in_seconds` | `integer (int64)` | yes |  |

**Example: A one-time link to the bot (201)**

```json
{
  "status": "success",
  "data": {
    "url": "https://t.me/desk_bot?start=tl_V8hLhHejvIyDpAQwWDcQNnGeI8IdEVIZ",
    "bot_username": "desk_bot",
    "expires_at": "2026-10-18T13:37:56.348541+05:30",
    "expires_in_seconds": 600
  },
  "error": null
}
```

**Example: An operator acting as the user cannot link (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "own_session_required",
    "message": "Only the account holder, signed in themselves, can do this",
    "field": null
  }
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Example: Telegram is not set up on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Telegram isn't set up on this server yet",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/notifications/telegram/link

Access: app session only (API keys and partner tokens get 403)

Unlink Telegram

```bash
curl -X DELETE "https://api.cirrus.trade/v1/notifications/telegram/link" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `unlinked` | `boolean` | yes | Always `true`. |

**Example: Telegram unlinked (200)**

```json
{
  "status": "success",
  "data": {
    "unlinked": true
  },
  "error": null
}
```

**Example: Telegram was not linked (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "telegram_not_linked",
    "message": "Telegram is not connected; connect it first",
    "field": null
  }
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `telegram_not_linked` | Telegram is not connected for the caller; start a link first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/notifications/telegram/test

Access: app session only (API keys and partner tokens get 403)

Send a Telegram test message

```bash
curl -X POST "https://api.cirrus.trade/v1/notifications/telegram/test" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `delivered` | `boolean` | yes |  |
| `message` | `string` | yes | Plain words for the user. |
| `broken_reason` | `string \| null` | yes | Why Telegram refused it, when it did for good. One of: `blocked_by_user`, `chat_not_found`. |

**Example: Telegram took the test message (200)**

```json
{
  "status": "success",
  "data": {
    "delivered": true,
    "message": "Sent. Check your Telegram chat with the bot.",
    "broken_reason": null
  },
  "error": null
}
```

**Example: Telegram is not linked (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "telegram_not_linked",
    "message": "Telegram is not connected; connect it first",
    "field": null
  }
}
```

**Example: Notifications are off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Notifications are not enabled on this server",
    "field": null
  }
}
```

**Example: Telegram is not set up on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "notifications_disabled",
    "message": "Telegram isn't set up on this server yet",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `telegram_not_linked` | Telegram is not connected for the caller; start a link first. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `notifications_disabled` | Notifications are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Signal URLs

Secret URLs that turn TradingView, Chartink or Kuberhunt alerts into orders.

### GET /v2/signal-modes

Scope: `read`

List signal modes

```bash
curl "https://api.cirrus.trade/v2/signal-modes" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].endpoint_id` | `string` | yes | The signal URL (`we_...`). |
| `[].kind` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `[].strategy_id` | `string` | yes | The Signals strategy the URL is for. |
| `[].mode` | `string` | yes | `direct`: alerts place orders from the strategy's settings (as before). `algo`: alerts become signals to `deployment_id`, which decides with its own rules and safety checks. One of: `direct`, `algo`, `unknown`. |
| `[].deployment_id` | `string \| null` | no | The bound deployment (`algo` only). |
| `[].switched_at` | `string \| null` | no | When the mode last changed (RFC 3339); null: never switched. |
| `[].can_use_algo` | `boolean` | yes | False for Kuberhunt URLs (they stay direct for now). |

**Example: One URL in algo mode, one direct (200)**

```json
{
  "status": "success",
  "data": [
    {
      "endpoint_id": "we_5aD4VrIDQtbAm8xA",
      "kind": "kuberhunt",
      "strategy_id": "kh-01M59MFHD1WNSQAQCAHHX3DEXZ",
      "mode": "direct",
      "deployment_id": null,
      "switched_at": null,
      "can_use_algo": false
    },
    {
      "endpoint_id": "we_q02Q5Ag9f6C6q0o5",
      "kind": "tradingview",
      "strategy_id": "nifty-alerts-01M59MFHCN0NJ023A39J4CQN2X",
      "mode": "algo",
      "deployment_id": "01M59MFHD6BJ6PM60WFMFAV16X",
      "switched_at": "2026-10-19T08:29:35.023+00:00",
      "can_use_algo": true
    }
  ],
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PUT /v2/signal-modes/{endpoint_id}

Scope: `strategies`

Switch a signal URL's mode

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL's `endpoint_id`. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `string` | yes | `direct` or `algo`. One of: `direct`, `algo`, `unknown`. |
| `deployment_id` | `string \| null` | no | `algo`: one of the caller's running deployments whose strategy takes alerts on a signal URL (`webhook@1`). Absent for `direct`. |

```bash
curl -X PUT "https://api.cirrus.trade/v2/signal-modes/<endpoint_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "algo",
    "deployment_id": "01M59MFHD6BJ6PM60WFMFAV16X"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL (`we_...`). |
| `kind` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `strategy_id` | `string` | yes | The Signals strategy the URL is for. |
| `mode` | `string` | yes | `direct`: alerts place orders from the strategy's settings (as before). `algo`: alerts become signals to `deployment_id`, which decides with its own rules and safety checks. One of: `direct`, `algo`, `unknown`. |
| `deployment_id` | `string \| null` | no | The bound deployment (`algo` only). |
| `switched_at` | `string \| null` | no | When the mode last changed (RFC 3339); null: never switched. |
| `can_use_algo` | `boolean` | yes | False for Kuberhunt URLs (they stay direct for now). |

**Example: Alerts now go to an automated strategy (200)**

```json
{
  "status": "success",
  "data": {
    "endpoint_id": "we_q02Q5Ag9f6C6q0o5",
    "kind": "tradingview",
    "strategy_id": "nifty-alerts-01M59MFHCN0NJ023A39J4CQN2X",
    "mode": "algo",
    "deployment_id": "01M59MFHD6BJ6PM60WFMFAV16X",
    "switched_at": "2026-10-19T08:29:35.023+00:00",
    "can_use_algo": true
  },
  "error": null
}
```

**Example: Back to placing orders directly (200)**

```json
{
  "status": "success",
  "data": {
    "endpoint_id": "we_q02Q5Ag9f6C6q0o5",
    "kind": "tradingview",
    "strategy_id": "nifty-alerts-01M59MFHCN0NJ023A39J4CQN2X",
    "mode": "direct",
    "deployment_id": null,
    "switched_at": "2026-10-19T08:29:35.032+00:00",
    "can_use_algo": true
  },
  "error": null
}
```

**Example: An unknown or revoked API key (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Invalid API key",
    "field": null
  }
}
```

**Example: An API key without the `strategies` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `strategies` scope",
    "field": null
  }
}
```

**Example: Another user's deployment (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "deployment_not_found",
    "message": "Deployment not found",
    "field": null
  }
}
```

**Example: A trade is open: switch later (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "not_flat",
    "message": "Close or wait for the open trade to finish before switching.",
    "field": null
  }
}
```

**Example: Kuberhunt URLs stay direct (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "Kuberhunt signal URLs can't use algo mode yet",
    "field": "endpoint_id"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 404 | `endpoint_not_found` | No signal URL with this id (or this secret) for the caller. |
| 404 | `deployment_not_found` | No deployment with this id for the caller. |
| 409 | `not_flat` | A signal strategy's mode can change only when flat: a position held by the signal strategy, or an open trade on the deployment, must close first. Nothing was changed. |
| 409 | `deployment_finished` | The deployment is stopped; deploy the strategy again to run it. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Watchlists

Your stock lists; a stock strategy can run on one.

### GET /v1/watchlists

Scope: `read`

List your watchlists

```bash
curl "https://api.cirrus.trade/v1/watchlists" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes | Watchlist id (24 hex characters). |
| `[].name` | `string` | yes |  |
| `[].count` | `integer` | yes | Number of members. |
| `[].updated_at` | `string (date-time) \| null` | yes | Last change (RFC3339); null when never recorded. |

**Example: The user's watchlists, by name (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "6adf47df14d372ca8aa51ea7",
      "name": "Breakouts",
      "count": 2,
      "updated_at": "2026-10-26T12:30:23.476Z"
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the `read` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/watchlists

Scope: `read`

Create a watchlist

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name, 1-60 characters after trimming, unique per user; not one of the app's built-in lists (`Indices`, `Nifty 50`, `Nifty Bank`). |
| `symbols` | `string[] \| null` | no | Members as instrument tokens (`instrument_token`), at most 50, in order. Every symbol added must be an NSE or BSE cash equity in today's instrument list. On create, absent means empty; on update, absent keeps the members, present replaces them (members kept from before are not checked again). |

```bash
curl -X POST "https://api.cirrus.trade/v1/watchlists" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Breakouts",
    "symbols": [
      "CT:EX:RELIANCE-EQ",
      "CT:EX:INFY-EQ"
    ]
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

**Example: Two NSE stocks (201)**

```json
{
  "status": "success",
  "data": {
    "id": "6adf47df14d372ca8aa51ea4"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the `watchlists` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `watchlists` scope",
    "field": null
  }
}
```

**Example: The user already has a watchlist with this name (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "watchlist_name_taken",
    "message": "you already have a watchlist with this name",
    "field": "name"
  }
}
```

**Example: An index is not a cash equity (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "NIFTY 50 is not an NSE or BSE cash equity",
    "field": "symbols[1]"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 409 | `watchlist_name_taken` | The caller already has a watchlist with this name. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

### GET /v1/watchlists/{watchlist_id}

Scope: `read`

Read a watchlist

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `watchlist_id` | `string` | yes | Watchlist id. |

```bash
curl "https://api.cirrus.trade/v1/watchlists/<watchlist_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Watchlist id (24 hex characters). |
| `name` | `string` | yes |  |
| `symbols` | `object[]` | yes | Members, in the order they were added. |
| `symbols[].instrument_token` | `string` | yes | Instrument token. |
| `symbols[].tradingsymbol` | `string \| null` | yes | Trading symbol; null when the instrument list does not know it. |
| `symbols[].stock` | `string \| null` | yes | The stock's symbol as strategies name it (`RELIANCE`); null when not listed. |
| `symbols[].exchange` | `string \| null` | yes | Exchange (`NSE`, `BSE`); null when not listed. |
| `symbols[].name` | `string \| null` | yes | Company name; null when not known. |
| `symbols[].status` | `string` | yes | Whether a member can be part of a stock strategy's universe today. One of: `tradable`, `not_listed`, `not_equity`, `not_allowed`. |
| `tradable` | `integer` | yes | Members a stock strategy would trade today (`status` `tradable`). |
| `updated_at` | `string (date-time) \| null` | yes | Last change (RFC3339); null when never recorded. |

**Example: Each member with its status today (200)**

```json
{
  "status": "success",
  "data": {
    "id": "6adf47df14d372ca8aa51ea7",
    "name": "Breakouts",
    "symbols": [
      {
        "tradingsymbol": "RELIANCE-EQ",
        "stock": "RELIANCE",
        "exchange": "NSE",
        "name": "Reliance Industries",
        "status": "not_allowed",
        "instrument_token": "CT:EX:RELIANCE-EQ"
      },
      {
        "tradingsymbol": "INFY-EQ",
        "stock": "INFY",
        "exchange": "NSE",
        "name": "Infosys",
        "status": "not_allowed",
        "instrument_token": "CT:EX:INFY-EQ"
      }
    ],
    "tradable": 0,
    "updated_at": "2026-10-26T12:30:23.476Z"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the `read` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `read` scope",
    "field": null
  }
}
```

**Example: No watchlist with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "watchlist_not_found",
    "message": "no such watchlist",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `watchlist_not_found` | No watchlist with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

### PUT /v1/watchlists/{watchlist_id}

Scope: `read`

Rename a watchlist or replace its members

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `watchlist_id` | `string` | yes | Watchlist id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name, 1-60 characters after trimming, unique per user; not one of the app's built-in lists (`Indices`, `Nifty 50`, `Nifty Bank`). |
| `symbols` | `string[] \| null` | no | Members as instrument tokens (`instrument_token`), at most 50, in order. Every symbol added must be an NSE or BSE cash equity in today's instrument list. On create, absent means empty; on update, absent keeps the members, present replaces them (members kept from before are not checked again). |

```bash
curl -X PUT "https://api.cirrus.trade/v1/watchlists/<watchlist_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Breakouts (large caps)",
    "symbols": [
      "CT:EX:RELIANCE-EQ",
      "CT:EX:INFY-EQ"
    ]
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

**Example: Renamed, INFY added (200)**

```json
{
  "status": "success",
  "data": {
    "id": "6adf47df14d372ca8aa51ea6"
  },
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the `watchlists` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `watchlists` scope",
    "field": null
  }
}
```

**Example: No watchlist with this id (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "watchlist_not_found",
    "message": "no such watchlist",
    "field": null
  }
}
```

**Example: A symbol not in today's instrument list (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "CT:EX:GONE-EQ is not in today's instrument list",
    "field": "symbols[0]"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `watchlist_not_found` | No watchlist with this id for the caller. |
| 409 | `watchlist_name_taken` | The caller already has a watchlist with this name. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |
| 503 | `instruments_unavailable` | The instrument master is not loaded yet; retry shortly. |

### DELETE /v1/watchlists/{watchlist_id}

Scope: `read`

Delete a watchlist

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `watchlist_id` | `string` | yes | Watchlist id. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/watchlists/<watchlist_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (204):** no body.

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Example: An API key without the `watchlists` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `watchlists` scope",
    "field": null
  }
}
```

**Example: Already deleted, or never made (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "watchlist_not_found",
    "message": "no such watchlist",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `live_not_enabled` | Live deployments need the `strategies_live` scope and a broker open for live automated strategies. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `watchlist_not_found` | No watchlist with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Partner apps (OAuth)

Apps that act for other users: register, exchange tokens, disconnect.

### PATCH /v1/partner-apps/{client_id}

Access: app session only (API keys and partner tokens get 403)

Set a partner app's webhook

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id (`cp_...`). |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `webhook_url` | `string \| null` | no | The receiver: an `https://` URL on a public host (private, loopback and link-local addresses are refused). The first one creates the webhook and its secret; `null` removes the webhook. |
| `webhook_events` | `string[] \| null` | no | Events to send, at least one of `order_update`, `trade`, `account_alert`, `connection_revoked` (all four when a webhook is created without this). One of: `order_update`, `trade`, `account_alert`, `connection_revoked`. |
| `webhook_enabled` | `boolean \| null` | no | `false` turns deliveries off; `true` turns them back on (also after the webhook was turned off for failing). |

```bash
curl -X PATCH "https://api.cirrus.trade/v1/partner-apps/<client_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://partner.example/hook",
    "webhook_events": [
      "order_update",
      "trade",
      "account_alert",
      "connection_revoked"
    ]
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | OAuth client id (`cp_...`). |
| `owner` | `string` | yes | Username of the user who registered the app. |
| `name` | `string` | yes | Name shown to users on the consent screen. |
| `verified` | `boolean` | yes | See `PartnerApp.verified`. |
| `logo_url` | `string \| null` | yes | HTTPS URL of the app's logo. |
| `redirect_uris` | `string[]` | yes | Exact redirect URIs a user may be sent back to. |
| `scopes` | `string[]` | yes | The most a user can grant this partner. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `created_at` | `string` | yes | RFC 3339 time of registration. |
| `disabled` | `boolean` | yes | Always `false` (a disabled app cannot be changed). |
| `webhook` | `object` | no | The app's webhook; absent after `webhook_url: null`. |
| `webhook.url` | `string` | yes | The receiver: an `https://` URL on a public host. |
| `webhook.events` | `string[]` | yes | What it gets: `order_update` and `trade` for orders the app placed, `account_alert` for accounts of connected users, and `connection_revoked` when a user's connection ends. One of: `order_update`, `trade`, `account_alert`, `connection_revoked`. |
| `webhook.enabled` | `boolean` | yes | Whether deliveries are sent. |
| `webhook.disabled_reason` | `string \| null` | yes | Why it is off: `disabled_by_user`, `too_many_failures` (50 failed attempts in a row) or `failing_for_24_hours`; null while enabled. |
| `webhook.consecutive_failures` | `integer (int32)` | yes | Failed attempts in a row (any success resets it). |
| `webhook.last_success_at` | `string \| null` | yes |  |
| `webhook.last_failure_at` | `string \| null` | yes |  |
| `webhook.previous_secret_expires_at` | `string \| null` | yes | While set, deliveries are also signed with the previous secret (for 24 hours after a rotation). |
| `webhook.secret_rotated_at` | `string \| null` | yes |  |
| `webhook_secret` | `string` | no | The webhook's signing secret (`whsec_...`), only in the response that created the webhook: store it now. Only a sealed copy is kept. |
| `note` | `string` | no | A reminder to store the secret (only with `webhook_secret`). |

**Example: First webhook URL: the secret is shown once (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_nTYrRysBvVEizO0aMNCX",
    "owner": "user-01M4Z65SRSKA2K7SGT33094P5V",
    "name": "Charts Co",
    "verified": false,
    "logo_url": "https://partner.example/logo.png",
    "redirect_uris": [
      "https://partner.example/callback"
    ],
    "scopes": [
      "read",
      "orders"
    ],
    "created_at": "2026-10-15T07:07:11.535867+00:00",
    "disabled": false,
    "webhook": {
      "url": "https://partner.example/hook",
      "events": [
        "order_update",
        "trade",
        "account_alert",
        "connection_revoked"
      ],
      "enabled": true,
      "disabled_reason": null,
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "previous_secret_expires_at": null,
      "secret_rotated_at": null
    },
    "webhook_secret": "whsec_…",
    "note": "Store the webhook secret now: it is not shown again."
  },
  "error": null
}
```

**Example: Deliveries turned off (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_nTYrRysBvVEizO0aMNCX",
    "owner": "user-01M4Z65SRSKA2K7SGT33094P5V",
    "name": "Charts Co",
    "verified": false,
    "logo_url": "https://partner.example/logo.png",
    "redirect_uris": [
      "https://partner.example/callback"
    ],
    "scopes": [
      "read",
      "orders"
    ],
    "created_at": "2026-10-15T07:07:11.535867+00:00",
    "disabled": false,
    "webhook": {
      "url": "https://partner.example/hook",
      "events": [
        "order_update",
        "trade",
        "account_alert",
        "connection_revoked"
      ],
      "enabled": false,
      "disabled_reason": "disabled_by_user",
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "previous_secret_expires_at": null,
      "secret_rotated_at": null
    }
  },
  "error": null
}
```

**Example: Not an active app of the user (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unknown_app",
    "message": "No such active app of yours",
    "field": null
  }
}
```

**Example: A plain HTTP receiver is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "url uses a scheme that is not allowed (an https:// URL on a public host is required)",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `unknown_app` | Unknown or disabled partner app. |
| 409 | `conflict` | A partner app's webhook was changed at the same time as this request; retry. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/partner-apps/{client_id}/rotate-webhook-secret

Access: app session only (API keys and partner tokens get 403)

Rotate a partner app's webhook secret

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id (`cp_...`). |

```bash
curl -X POST "https://api.cirrus.trade/v1/partner-apps/<client_id>/rotate-webhook-secret" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes |  |
| `webhook_secret` | `string` | yes | The new secret (`whsec_...`); store it now, it is never shown again. |
| `previous_secret_expires_at` | `string (date-time)` | yes | Until when deliveries are also signed with the previous secret. |

**Example: New secret; the old one signs for 24 hours (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_nTYrRysBvVEizO0aMNCX",
    "webhook_secret": "whsec_…",
    "previous_secret_expires_at": "2026-10-16T07:07:11.542Z"
  },
  "error": null
}
```

**Example: Not an active app of the user (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unknown_app",
    "message": "No such active app of yours",
    "field": null
  }
}
```

**Example: The app has no webhook yet (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "this app has no webhook: set `webhook_url` first",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 404 | `unknown_app` | Unknown or disabled partner app. |
| 409 | `conflict` | A partner app's webhook was changed at the same time as this request; retry. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/connections/{client_id}/left

Access: app session only (API keys and partner tokens get 403)

What a connected app left running

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |

```bash
curl "https://api.cirrus.trade/v1/connections/<client_id>/left" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `orders` | `object[]` | yes | Open orders it placed today, newest first. |
| `orders[].order_tag` | `string` | yes | The order's id, chosen when it was placed (a ULID). |
| `orders[].broker_tag` | `string \| null` | no | Tag as sent to the broker (format depends on the broker). |
| `orders[].order_id` | `string \| null` | no | Broker-assigned order id; `null` until the broker acknowledges. |
| `orders[].parent_tag` | `string \| null` | no | `order_tag` of the order this one belongs to (a protective leg's entry), if any. |
| `orders[].username` | `string` | yes |  |
| `orders[].account` | `string` | yes |  |
| `orders[].broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `orders[].instrument_token` | `string` | yes | Instrument id. |
| `orders[].tradingsymbol` | `string` | yes |  |
| `orders[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `orders[].order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `orders[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `orders[].quantity` | `integer (int64)` | yes |  |
| `orders[].price` | `number (double)` | yes | Limit price; `0` for market orders. |
| `orders[].trigger_price` | `number (double) \| null` | no |  |
| `orders[].state` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `orders[].filled_qty` | `integer (int64)` | yes |  |
| `orders[].average_price` | `number (double) \| null` | no |  |
| `orders[].status_message` | `string \| null` | no | The broker's latest status text (rejection reason, ...). |
| `orders[].broker_updated_at` | `string (date-time) \| null` | no | Broker time of the last update applied. |
| `orders[].created_at` | `string (date-time)` | yes |  |
| `orders[].status_history` | `object[]` | yes | Every state change so far, oldest first. |
| `orders[].status_history[].from` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `orders[].status_history[].to` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `orders[].status_history[].at` | `string (date-time)` | yes |  |
| `orders[].status_history[].message` | `string \| null` | yes | The broker's status text at this step, if any. |
| `orders[].client_tag` | `string \| null` | no | The caller's own reference, as given in `client_tag` when the order was placed; absent when none was given (or for orders placed elsewhere, e.g. in the broker's own app). |
| `orders[].partner_client_id` | `string \| null` | no | Client id of the partner app that placed the order; absent for orders placed any other way. |
| `orders[].chase` | `object \| null` | no | The order's `chase` settings and the limit it was first sent with; absent when it asked for none. |
| `orders[].chase.every_ms` | `integer (int64)` | yes |  |
| `orders[].chase.attempts` | `integer (int32)` | yes |  |
| `orders[].chase.max_slippage_pct` | `number (double)` | yes |  |
| `orders[].chase.on_give_up` | `string` | yes | What happens to a still-open order when the chase ends unfilled. One of: `rest`, `cancel`. |
| `orders[].chase.first_price` | `number (double)` | yes | The limit the order was first sent with. |
| `orders[].leg_role` | `string \| null` | no | For an exit leg of a bracket / cover order: `stop_loss`, `target` or the broker's `square_off` (its entry is `parent_tag`); absent for every other order. One of: `stop_loss`, `target`, `square_off`. |
| `orders[].chase_progress` | `object \| null` | no | How far the order's `chase` got (attempts used, how it ended); absent before its first re-price attempt. |
| `orders[].chase_progress.steps` | `integer (int32)` | yes | Re-price attempts used so far (of the plan's `attempts`). |
| `orders[].chase_progress.outcome` | `string` | yes | `running` (still re-pricing), `filled`, `gave_up` (stopped at its attempts or slippage bound and left resting, or its cancel failed), `cancelled` (stopped and cancelled as asked) or `stopped` (the order was changed, cancelled or rejected outside the chase). One of: `running`, `filled`, `gave_up`, `cancelled`, `stopped`. |
| `orders[].strategy` | `string \| null` | no | The strategy of the v2 trigger that placed the order; absent for every other order. |
| `orders[].split_from` | `object \| null` | no | When the broker split a placed order into several orders (its freeze-quantity cap, e.g. Pocketful at 500 units or Zerodha's autoslice) and lists only those: each is its own order (its own `order_id`, quantity and fills) and this names the order as placed. The first part keeps the placed `order_tag`. Change or cancel each part by its own `order_id`; the placed id still cancels every open part, or changes their price / trigger (not their quantity). Absent for every other order. |
| `orders[].split_from.order_id` | `string \| null` | yes | The broker order id the placement returned; null when it named none. |
| `orders[].split_from.order_tag` | `string` | yes | The `order_tag` the order was placed with (its first part keeps it). |
| `orders[].split_from.quantity` | `integer (int64)` | yes | Units the order was placed for. |
| `orders[].split_from.cirrus_tag` | `string` | no | **Deprecated.** The `order_tag` the order was placed with (its first part keeps it). |
| `orders[].lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `orders[].broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `orders[].exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `orders[].instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `orders[].symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `orders[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `orders[].strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `orders[].option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `orders[].sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `orders[].isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `orders[].indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `orders[].instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `orders[].ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `orders[].prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |
| `orders[].cirrus_token` | `string` | no | **Deprecated.** Instrument id. |
| `orders[].cirrus_tag` | `string` | no | **Deprecated.** The order's id, chosen when it was placed (a ULID). |
| `gtt` | `object[]` | yes | Active GTTs it placed (from the API's own records). |
| `gtt[].trigger_id` | `string` | yes | The broker's GTT id. |
| `gtt[].status` | `string` | yes | Broker status, lower-case (`active`, `triggered`, `cancelled`, ...). |
| `gtt[].instrument_token` | `string \| null` | yes | Instrument id; null when the broker's instrument is not known here. |
| `gtt[].tradingsymbol` | `string` | yes |  |
| `gtt[].exchange` | `string` | yes |  |
| `gtt[].side` | `string \| null` | yes | Side of the order(s) a trigger places. One of: `BUY`, `SELL`. |
| `gtt[].product` | `string \| null` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `gtt[].quantity` | `integer (int64)` | yes | Units. |
| `gtt[].last_price` | `number (double) \| null` | yes | LTP when the GTT was created / last modified. |
| `gtt[].legs` | `object[]` | yes | One leg (single) or two (stop-loss first, then target). |
| `gtt[].legs[].trigger` | `number (double)` | yes | Price that fires the leg. |
| `gtt[].legs[].price` | `number (double)` | yes | Limit price of the order it places. |
| `gtt[].created_at` | `string \| null` | yes |  |
| `gtt[].updated_at` | `string \| null` | yes |  |
| `gtt[].expires_at` | `string \| null` | yes |  |
| `triggers` | `object[]` | yes | Open v2 triggers it created. |
| `triggers[].trigger_id` | `string` | yes |  |
| `triggers[].account` | `string` | yes |  |
| `triggers[].strategy` | `string \| null` | yes |  |
| `triggers[].kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `triggers[].legs` | `object[]` | yes |  |
| `triggers[].legs[].instrument_token` | `string` | yes |  |
| `triggers[].legs[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `triggers[].legs[].quantity` | `integer (int64)` | yes |  |
| `triggers[].legs[].product` | `string \| null` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `triggers[].spec` | `object \| null` | yes | The spec as created; null when not known here. |
| `triggers[].valid_until` | `string \| null` | yes |  |
| `triggers[].state` | `string` | yes | `submitted` until the engine reports, then the machine's state: `armed`, `pending`, `active`, `t1` … (ladder rungs), `paused`, and the ended ones `fired`, `done`, `target_hit`, `sl_hit`, `breached`, `expired`, `cancelled`, `errored`; `cancel_pending` on broker-held protection whose cancel the broker refused while still holding it. A protection held as a v1 record is `pending` until its entry fills, then `active` (or `cancel_pending`); ended, it shows one of the ended states above (`target_hit`, `sl_hit`, `fired`, `done` when the position was exited another way, `cancelled`, or `errored` when it stopped protecting because something failed or why is not known), with v1's reason in `reason` (e.g. `stop_loss_executed_by_broker`). Other values may appear. |
| `triggers[].transition_seq` | `integer (int64)` | yes | Changes the engine made so far. |
| `triggers[].reason` | `string \| null` | yes | Why it last changed, when the engine said (errors, expiry, …). A trigger the server ended on its own is `cancelled` with `day_ended` (intraday, after its session's close) or `contract_expired`. |
| `triggers[].created_at` | `string \| null` | yes |  |
| `triggers[].updated_at` | `string` | yes |  |
| `triggers[].protection` | `map<string, object> \| null` | yes | What protects the position, part by part (`stop_loss`, `stop_condition`, `target`, `target_1` …, `target_condition`, `trail`): held by the broker (protects even when the server is down) or by the server. Null for alerts and risk limits, and for triggers created before protection was planned. |
| `triggers[].protection.*` | `object` | no | One part of a v2 trigger's protection. |
| `triggers[].protection.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `triggers[].protection.*.broker_order_id` | `string \| null` | yes | The broker's GTT or order id, when the broker holds the part. |
| `triggers[].protection.*.level` | `number (double) \| null` | yes | The part's price level, when it has one. |
| `triggers[].protection.*.adjusted_by_server` | `boolean` | yes | The server moves the broker's level (trailing, ladder advance). |
| `triggers[].protection.*.status` | `string` | yes | Where a part stands. One of: `protected`, `degraded`, `unknown`, `held_by_server`, `ended`. |
| `triggers[].protection.*.note` | `string \| null` | yes | Why, in words. |
| `triggers[].last_result` | `object` | no | The engine's answer to the last command it handled for this trigger (a refused modify, pause, resume or cancel shows here with its reason); absent until it answered one. |
| `triggers[].last_result.op` | `string` | yes | `create`, `modify`, `cancel`, `pause` or `resume`. |
| `triggers[].last_result.command_id` | `string` | yes | The command's id (`command_id` of the `202`). |
| `triggers[].last_result.outcome` | `string` | yes | `applied`, `duplicate` (already applied) or `rejected` (other values may appear). |
| `triggers[].last_result.reason` | `string \| null` | yes | Why it was refused; null otherwise. |
| `triggers[].last_result.at` | `string` | yes | When the command was sent; RFC 3339, India time. |
| `triggers[].entry` | `object` | no | The entry the server placed for this trigger (a bracket whose `entry` condition held): absent for triggers without one. `status` `outcome_unknown` means the entry may or may not be at the broker; it is never sent again (check the order book). |
| `triggers[].entry.status` | `string` | yes | Where the entry stands: `sending` (being placed), `accepted` (at the broker, not filled yet), `filled`, `partially_filled`, `failed` (refused or ended with no fill; the trigger is cancelled), `outcome_unknown` (may or may not be at the broker; never sent again). Other values may appear. One of: `sending`, `accepted`, `filled`, `partially_filled`, `failed`, `outcome_unknown`, `other`. |
| `triggers[].entry.filled_qty` | `integer (int64)` | yes | Units filled so far. |
| `triggers[].entry.avg_price` | `number (double) \| null` | yes | Average fill price; null until something filled. |
| `triggers[].entry.order_ids` | `string[]` | yes | The broker's order ids, one per slice once known. |
| `triggers[].entry.reason` | `string \| null` | yes | Why it failed, or what is unknown; null otherwise. |
| `triggers[].created_via` | `string` | no | How the trigger was made: `app` (the app), `api` (an API key or a partner app), `order_ticket` (the server, protecting a `POST /v1/orders` fill). Absent for triggers made before this was recorded. One of: `app`, `api`, `order_ticket`. |
| `triggers[].route` | `string` | no | For a `bracket` or `ladder`: where its protection lives. `gtt` (a broker GTT) or `stop_order` (a broker stop-loss order): held by the broker, it protects the position even when the server is down; `trigger`: the server evaluates it (parts the broker backs show in `protection`). Absent for alerts and risk limits. One of: `bracket`, `cover`, `gtt`, `trigger`, `stop_order`. |
| `triggers[].route_reason` | `string` | no | Why that route, in words, when known. |
| `triggers[].risk_progress` | `object` | no | For a `risk_limit` with `targets` or a `profit_trail`: which targets fired and where the trail stands, as of the trigger's last event. Absent for every other trigger. |
| `triggers[].risk_progress.targets` | `integer` | yes | Targets in the spec. |
| `triggers[].risk_progress.targets_fired` | `integer (int32)` | yes | Targets that fired: always the first ones (they fire in order, each once). |
| `triggers[].risk_progress.trail_armed` | `boolean` | yes | The profit trail armed (MTM reached `profit_trail.arm_at`). |
| `triggers[].risk_progress.trail_peak` | `number (double) \| null` | yes | The highest MTM since the trail armed (as of the last event: the trail's moves are reported once per trail distance); null before. |
| `triggers[].risk_progress.trail_floor` | `number (double) \| null` | yes | The trail's floor (only ever rises); null before it armed. |
| `triggers[].watch` | `object[]` | no | The instruments the conditions read but the trigger never trades, as created (`watch` of the create request, so a change round-trips). Absent when there are none. |
| `triggers[].watch[].name` | `string` | yes | How conditions name it (`nifty` in `nifty.rsi(14, '5m')`). |
| `triggers[].watch[].symbol` | `string` | yes | Its instrument token. |
| `triggers[].group` | `string` | no | The label given at create (`group`); absent when none. |
| `deployments` | `object[]` | yes | Strategy deployments it started that are not stopped. |
| `deployments[].id` | `string` | yes |  |
| `deployments[].strategy_id` | `string` | yes |  |
| `deployments[].strategy_name` | `string \| null` | no | The strategy's name when it was deployed (absent on deployments made before names were kept). |
| `deployments[].version` | `integer (int32)` | yes |  |
| `deployments[].mode` | `string` | yes | Paper (forward test on the Paper account) or live money. One of: `paper`, `live`, `unknown`. |
| `deployments[].status` | `string` | yes | `active`, `paused`, `error` or `stopped` (`unknown`: unreadable, treated as needing attention). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `deployments[].status_reason` | `string \| null` | no |  |
| `deployments[].strategy_tag` | `string` | yes | Stamped on every order (`auto.` + 12 characters of the id). |
| `deployments[].accounts` | `object[]` | yes |  |
| `deployments[].accounts[].account` | `string` | yes |  |
| `deployments[].accounts[].multiplier` | `number (double)` | yes |  |
| `deployments[].units` | `object[]` | yes |  |
| `deployments[].units[].account` | `string` | yes |  |
| `deployments[].units[].unit_id` | `string` | yes | The engine trigger running this account. |
| `deployments[].units[].status` | `string` | yes | Stored deployment and unit status (§3.2). One of: `active`, `paused`, `error`, `stopped`, `unknown`. |
| `deployments[].units[].status_reason` | `string \| null` | no |  |
| `deployments[].units[].stock` | `string \| null` | no | A watchlist deployment: the stock this unit trades (`RELIANCE`); absent otherwise. |
| `deployments[].units[].exchange` | `string \| null` | no | With `stock`: its exchange (`NSE`, `BSE`). |
| `deployments[].universe` | `object \| null` | no | A watchlist deployment's universe (`spec.stock.watchlist_id`); absent otherwise. |
| `deployments[].universe.watchlist_id` | `string` | yes |  |
| `deployments[].universe.stocks` | `string[]` | yes | Stocks with a unit (`NAME`). |
| `deployments[].universe.skipped` | `object[]` | yes | Members that got no unit. |
| `deployments[].universe.skipped[].instrument_token` | `string` | yes |  |
| `deployments[].universe.skipped[].name` | `string \| null` | no | The stock's symbol when the instrument list knows it. |
| `deployments[].universe.skipped[].reason` | `string` | yes | `not_listed` (not in the instrument list), `not_equity` (not an NSE / BSE cash equity) or `not_allowed` (not on the list of stocks automated strategies may trade); other values may be added. |
| `deployments[].start_on` | `string \| null` | no |  |
| `deployments[].end_on` | `string \| null` | no |  |
| `deployments[].acknowledged` | `string[]` | yes |  |
| `deployments[].compiled_hash` | `string` | yes |  |
| `deployments[].last_intent` | `object \| null` | no |  |
| `deployments[].last_intent.action` | `string` | yes | What the user last asked of a deployment. One of: `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown`. |
| `deployments[].last_intent.exit_now` | `boolean` | yes |  |
| `deployments[].last_intent.at` | `string` | yes |  |
| `deployments[].last_intent.delivered` | `boolean` | yes | Whether the engine took it (false: saved, not sent yet). |
| `deployments[].engine_connected` | `boolean` | yes | Whether intents reach the strategy engine yet. |
| `deployments[].created_at` | `string` | yes |  |
| `deployments[].updated_at` | `string` | yes |  |
| `deployments[].stopped_at` | `string \| null` | no |  |
| `deployments[].pnl` | `object \| null` | no | Today's P&L of its orders (from the strategy ledgers, by `strategy_tag`; `GET /v2/deployments/{id}` and the strategy's deployment list only); absent when the ledgers cannot be read just now. |
| `deployments[].pnl.trading_day` | `string` | yes | `YYYY-MM-DD` (IST). |
| `deployments[].pnl.realized` | `number (double)` | yes | Booked today (₹). |
| `deployments[].pnl.unrealized` | `number (double)` | yes | Open positions at our latest prices (₹); positions without a price count as 0 (see `ltp_stale`). |
| `deployments[].pnl.total` | `number (double)` | yes | `realized + unrealized` (₹). |
| `deployments[].pnl.ltp_stale` | `boolean` | yes | An open position's price is not current or missing: `unrealized` may be behind. |
| `deployments[].safety_pauses` | `object[] \| null` | no | Accounts whose safety pause (circuit breaker) has tripped, with what it saw (`GET /v2/deployments/{id}` and the strategy's deployment list only); empty when none has. Absent when the deployment is stopped or the engine is not connected or did not answer. |
| `deployments[].safety_pauses[].account` | `string` | yes |  |
| `deployments[].safety_pauses[].detector` | `string` | yes | `order_rate`, `entry_rate`, `refusals`, `loss_velocity`, ... |
| `deployments[].safety_pauses[].evidence` | `string` | yes | What it saw, in words and numbers. |
| `deployments[].safety_pauses[].at` | `integer (int64)` | yes | Epoch ms. |
| `deployments[].safety_pauses[].trips_today` | `integer (int32)` | yes |  |
| `deployments[].safety_pauses[].locked_until` | `string \| null` | no | After the second trip today: the trading day (`YYYY-MM-DD`) resume is allowed again; null while resume is allowed. |

**Example: An open order the app left behind, after it was disconnected (200)**

```json
{
  "status": "success",
  "data": {
    "orders": [
      {
        "broker_tag": null,
        "order_id": "250101000123",
        "parent_tag": null,
        "username": "user-01M66MX7P51T9S9V6RA68F15JR",
        "account": "P1",
        "broker": "paper",
        "tradingsymbol": "RELIANCE-EQ",
        "side": "BUY",
        "order_type": "LIMIT",
        "product": "MIS",
        "quantity": 5,
        "price": 2400,
        "trigger_price": null,
        "state": "OPEN",
        "filled_qty": 0,
        "average_price": null,
        "status_message": null,
        "broker_updated_at": null,
        "created_at": "2026-10-30T14:55:03.706582Z",
        "status_history": [],
        "partner_client_id": "cp_9oN8crnsKGBC0epxXm9q",
        "lot_size": 1,
        "exchange": "NSE",
        "instrument_type": "EQUITY",
        "symbol": "RELIANCE",
        "expiry": "",
        "strike": 0,
        "option_type": "",
        "ltp": 2500,
        "prev_close": 2490,
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M66MX90TMJPA92R4EKMZ55NR"
      }
    ],
    "gtt": [],
    "triggers": [],
    "deployments": []
  },
  "error": null
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/connections/{client_id}/limits

Access: app session only (API keys and partner tokens get 403)

A connection's limits

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id. |

```bash
curl "https://api.cirrus.trade/v1/connections/<client_id>/limits" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |
| `limits` | `object` | yes | The limits in force for `mcp` tokens. When none were set (`is_default`), the defaults of a dynamically registered client: paper accounts only, no account picked, a few orders a day, one deployment, nothing live. |
| `limits.trading` | `boolean` | yes | `false`: a read-only connection. |
| `limits.accounts` | `string[]` | yes | Accounts it may trade or deploy on (none until the user picks). |
| `limits.paper_only` | `boolean` | no | Act only on paper-broker accounts (orders, GTT, triggers and deployments alike). |
| `limits.max_order_value` | `integer (int64) \| null` | no | Largest order value, in whole rupees. |
| `limits.max_orders_per_day` | `integer (int32) \| null` | no | Orders it may place per IST trading day. |
| `limits.live_strategies` | `boolean` | no | May deploy live / go live (needs `paper_only` off). |
| `limits.max_deployments` | `integer (int32) \| null` | no | Most of the user's active deployments it may have started. |
| `is_default` | `boolean` | yes | `true` when the user never set limits on this connection. |
| `applies_to` | `string[]` | yes | The kinds of token these limits bind. Currently always `["mcp"]`: tokens issued to AI agents. Classic partner tokens are not limited by them. |

**Example: No limits were set: the defaults, `is_default: true` (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_uLZ1purc60GwZKfU8e0K",
    "limits": {
      "trading": true,
      "accounts": [],
      "paper_only": true,
      "max_order_value": null,
      "max_orders_per_day": 20,
      "live_strategies": false,
      "max_deployments": 1
    },
    "is_default": true,
    "applies_to": [
      "mcp"
    ]
  },
  "error": null
}
```

**Example: Limits the user set (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_uLZ1purc60GwZKfU8e0K",
    "limits": {
      "trading": true,
      "accounts": [
        "P1"
      ],
      "paper_only": true,
      "max_order_value": 10000,
      "max_orders_per_day": 5,
      "live_strategies": false,
      "max_deployments": 1
    },
    "is_default": false,
    "applies_to": [
      "mcp"
    ]
  },
  "error": null
}
```

**Example: No live connection to that app (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "connection_not_found",
    "message": "No live connection to that app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `connection_not_found` | No connected partner app with this client id. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PUT /v1/connections/{client_id}/limits

Access: app session only (API keys and partner tokens get 403)

Change a connection's limits

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trading` | `boolean` | yes | `false`: a read-only connection. |
| `accounts` | `string[]` | yes | Accounts it may trade or deploy on (none until the user picks). |
| `paper_only` | `boolean` | no | Act only on paper-broker accounts (orders, GTT, triggers and deployments alike). |
| `max_order_value` | `integer (int64) \| null` | no | Largest order value, in whole rupees. |
| `max_orders_per_day` | `integer (int32) \| null` | no | Orders it may place per IST trading day. |
| `live_strategies` | `boolean` | no | May deploy live / go live (needs `paper_only` off). |
| `max_deployments` | `integer (int32) \| null` | no | Most of the user's active deployments it may have started. |

```bash
curl -X PUT "https://api.cirrus.trade/v1/connections/<client_id>/limits" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trading": true,
    "accounts": [
      "P1"
    ],
    "paper_only": true,
    "max_order_value": 10000,
    "max_orders_per_day": 5,
    "max_deployments": 1
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |
| `limits` | `object` | yes | The limits in force for `mcp` tokens. When none were set (`is_default`), the defaults of a dynamically registered client: paper accounts only, no account picked, a few orders a day, one deployment, nothing live. |
| `limits.trading` | `boolean` | yes | `false`: a read-only connection. |
| `limits.accounts` | `string[]` | yes | Accounts it may trade or deploy on (none until the user picks). |
| `limits.paper_only` | `boolean` | no | Act only on paper-broker accounts (orders, GTT, triggers and deployments alike). |
| `limits.max_order_value` | `integer (int64) \| null` | no | Largest order value, in whole rupees. |
| `limits.max_orders_per_day` | `integer (int32) \| null` | no | Orders it may place per IST trading day. |
| `limits.live_strategies` | `boolean` | no | May deploy live / go live (needs `paper_only` off). |
| `limits.max_deployments` | `integer (int32) \| null` | no | Most of the user's active deployments it may have started. |
| `is_default` | `boolean` | yes | `true` when the user never set limits on this connection. |
| `applies_to` | `string[]` | yes | The kinds of token these limits bind. Currently always `["mcp"]`: tokens issued to AI agents. Classic partner tokens are not limited by them. |

**Example: Paper trading on one account, capped (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_uLZ1purc60GwZKfU8e0K",
    "limits": {
      "trading": true,
      "accounts": [
        "P1"
      ],
      "paper_only": true,
      "max_order_value": 10000,
      "max_orders_per_day": 5,
      "live_strategies": false,
      "max_deployments": 1
    },
    "is_default": false,
    "applies_to": [
      "mcp"
    ]
  },
  "error": null
}
```

**Example: A listed account is not the user's (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "account NOT-MINE is not one of yours",
    "field": "accounts"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `connection_not_found` | No connected partner app with this client id. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/oauth/register

Access: no authentication

Register an OAuth client (AI agents)

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_name` | `string \| null` | no | Name shown to the user on the consent screen (printable ASCII, at most 64 characters); unverified. A known AI vendor's name is refused unless every redirect URI other than loopback http or a known desktop app scheme is that vendor's. |
| `redirect_uris` | `string[]` | no | 1-5 redirect URIs: `https://` (any host), `http://localhost`, `http://127.0.0.1` or `http://[::1]` on any port (the port is ignored when matching), or a known desktop app scheme. No fragment, at most 512 characters each. |
| `grant_types` | `string[] \| null` | no | Ignored: the client is registered for `authorization_code` and `refresh_token`. |
| `response_types` | `string[] \| null` | no | Ignored: the client is registered for `code`. |
| `token_endpoint_auth_method` | `string \| null` | no | Ignored: the client is public (`none`). |

```bash
curl -X POST "https://api.cirrus.trade/v1/oauth/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Claude",
    "redirect_uris": [
      "https://claude.ai/api/mcp/auth_callback"
    ],
    "grant_types": [
      "authorization_code",
      "refresh_token"
    ],
    "response_types": [
      "code"
    ],
    "token_endpoint_auth_method": "none"
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | OAuth client id (`cp_...`). |
| `client_name` | `string` | yes |  |
| `redirect_uris` | `string[]` | yes |  |
| `token_endpoint_auth_method` | `string` | yes | Always `none`: send `client_id` (and the PKCE verifier) to the token endpoint, no secret. |
| `grant_types` | `string[]` | yes | Always `authorization_code` and `refresh_token`. |
| `response_types` | `string[]` | yes | Always `code`. |
| `client_id_issued_at` | `integer (int64)` | yes | Unix time (seconds) of the registration. |

**Example: A public client registered (no secret) (201)**

```json
{
  "client_id": "cp_RV0iQ1LU929D23gJ8DkW",
  "client_name": "Claude",
  "redirect_uris": [
    "https://claude.ai/api/mcp/auth_callback"
  ],
  "token_endpoint_auth_method": "none",
  "grant_types": [
    "authorization_code",
    "refresh_token"
  ],
  "response_types": [
    "code"
  ],
  "client_id_issued_at": 1793364132
}
```

**Example: A plain-HTTP redirect URI (not loopback) is refused (400)**

```json
{
  "error": "invalid_request",
  "error_description": "redirect URI `http://agent.example/cb` must be https, http on localhost / 127.0.0.1 / [::1], or a known desktop app scheme, without a fragment"
}
```

**Example: Too many registrations from one client IP (429)**

```json
{
  "error": "slow_down",
  "error_description": "too many registrations from this source; retry after the Retry-After delay"
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | Unreadable body, `client_name` over 64 characters or naming an AI vendor whose redirect URIs these are not, no or more than 5 `redirect_uris`, or a redirect URI that is not https, loopback http or a known desktop app scheme. |
| 404 | `—` | Agent access is not enabled on this server (no body). |
| 429 | `slow_down` | Too many registrations from this client IP (IPv6: its /64), or from all callers together this hour; retry after `Retry-After` seconds. |
| 503 | `temporarily_unavailable` | The store is temporarily unavailable; retry. |

### GET /.well-known/oauth-authorization-server

Access: no authentication

OAuth authorization server metadata

```bash
curl "https://api.cirrus.trade/.well-known/oauth-authorization-server"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `issuer` | `string` | yes | This server's issuer URL. |
| `authorization_endpoint` | `string` | yes | The consent page users approve an app on. |
| `token_endpoint` | `string` | yes | `POST /v1/oauth/token` on this server. |
| `registration_endpoint` | `string` | yes | `POST /v1/oauth/register` on this server. |
| `response_types_supported` | `string[]` | yes | Always `code`. |
| `grant_types_supported` | `string[]` | yes | `authorization_code` and `refresh_token`. |
| `code_challenge_methods_supported` | `string[]` | yes | Always `S256` (plain PKCE is refused). |
| `token_endpoint_auth_methods_supported` | `string[]` | yes | `none` (dynamically registered, public clients), `client_secret_basic` and `client_secret_post` (registered partner apps). |
| `scopes_supported` | `string[]` | yes | Scopes an agent may be granted here, then `offline_access`: MCP clients ask for refresh tokens with it only when it is listed. It grants nothing; every connection gets a refresh token anyway. |

**Example: Where agents register, send users and get tokens (200)**

```json
{
  "issuer": "https://api.test.example",
  "authorization_endpoint": "https://app.test.example/oauth/consent",
  "token_endpoint": "https://api.test.example/v1/oauth/token",
  "registration_endpoint": "https://api.test.example/v1/oauth/register",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_basic",
    "client_secret_post"
  ],
  "scopes_supported": [
    "read",
    "orders",
    "triggers",
    "strategies",
    "strategies_live",
    "offline_access"
  ]
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 404 | `—` | Agent access is not enabled on this server (no body). |

## Streaming

One WebSocket per client with your live orders and portfolio: a snapshot first, then every change.

- Needs the `read` scope. Authenticate with the `Authorization` header on the upgrade, or with a first message within 5 s. The token may be `ck_…:cs_…`, `token ck_…:cs_…` or `pa_…`. Tokens are never read from the URL.
- Server messages, each with a per-connection `seq`: `hello` (`user`, `accounts[]` with `account`, `broker`, `tag`); one `snapshot` per account and kind (`account`, `kind`, `data`); `snapshot_done`; then live `order_update` (`account`, full order `data`) and `portfolio` (`account`, `kind`, full `data`); `error` (`code`, `message`).
- Kinds: orders, positions, holdings, trades, margins.
- Events carry full state, never diffs: a missed message is repaired by the next one for the same order or kind. Send `{"type":"resync"}` for a fresh set of snapshots ending in `snapshot_done`.
- The server pings every 15 s. Credentials are re-checked every 10 s, so a revoked key or connection closes the stream.

**Close codes**

| Code | Reason | Meaning |
| --- | --- | --- |
| `4001` | unauthorized | Bad first message or credential, or revoked. |
| `4002` | auth timeout | No auth message within 5 s. |
| `1013` | slow consumer | You fell too far behind; reconnect. |
| `1001` | server shutdown | Rolling deploy; reconnect. |
| `1000` | idle | Nothing heard from the client for 45 s. |

Reconnect with backoff after any close except 4001; each new connection starts with a fresh snapshot.

**Node.js**

```js
const socket = new WebSocket('wss://api.cirrus.trade/v1/stream');
socket.onopen = () =>
  socket.send(JSON.stringify({ type: 'auth', token: process.env.CIRRUS_API_KEY }));
socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  // message.type: hello | snapshot | snapshot_done | order_update | portfolio | error
  console.log(message.seq, message.type, message.account);
};
socket.onclose = (event) => console.log('closed', event.code, event.reason);
```

### GET /v1/stream

Scope: `read`

Upgrade to a WebSocket with live orders, portfolio and new activity records. Authenticate on the upgrade (`Authorization` header) or with an `auth` message within 5 s.

```
GET https://api.cirrus.trade/v1/stream
```

**Response `data`:** no body.

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `—` | Not a valid WebSocket upgrade (missing or wrong `Connection`, `Upgrade`, `Sec-WebSocket-Version` or `Sec-WebSocket-Key` header): plain text. |
| 426 | `—` | Upgrade Required: the connection cannot be upgraded (e.g. an HTTP/1.0 or proxied connection that drops the upgrade); plain text. |

### Messages

**Server messages**

#### hello

First server message after authentication: who is connected and which
accounts the stream covers. `snapshot` messages follow.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number, from 1, +1 per message. A gap means messages were lost: send `resync`. |
| `type` | `string` | yes | The message type. Always `hello`. |
| `user` | `string` | yes |  |
| `accounts` | `object[]` | yes |  |
| `accounts[].account` | `string` | yes |  |
| `accounts[].broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `accounts[].tag` | `string \| null` | no | The user's own label for the account. |

**Example**

```json
{
  "seq": 1,
  "type": "hello",
  "user": "alice",
  "accounts": [
    {
      "account": "ZX1234",
      "broker": "zerodha",
      "tag": "Main"
    }
  ]
}
```

#### snapshot

The current state of one account's data kind, sent after `hello` (one
per account and kind that has data) and again after `resync`. Kinds
with no data yet are left out. `snapshot_done` ends the set.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `snapshot`. |
| `account` | `string` | yes |  |
| `kind` | `string` | yes | Snapshot kind: `orders` (today's order book) or a portfolio kind. One of: `orders`, `positions`, `holdings`, `trades`, `margins`. |
| `data` | `object[] \| object[] \| object[] \| object[] \| MarginRow` | yes | `orders`, `positions`, `holdings`, `trades`: a list of rows (may be empty); `margins`: one object. |
| `data.account` | `string` | yes | When `type` is `MarginRow`. |
| `data.opening_balance` | `number (double)` | yes | When `type` is `MarginRow`. |
| `data.available` | `number (double)` | yes | When `type` is `MarginRow`. |
| `data.utilised` | `number (double)` | yes | When `type` is `MarginRow`. Share of the opening balance in use, as a percent (0-100). |
| `data.used` | `number (double) \| null` | no | When `type` is `MarginRow`. Margin in use, in rupees; absent when the broker does not report it. |
| `data.payin` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds added today, in rupees; absent when not reported. |
| `data.payout` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds withdrawn today, in rupees; absent when not reported. |
| `data.collateral` | `number (double) \| null` | no | When `type` is `MarginRow`. Collateral (pledged holdings) margin, in rupees; absent when not reported. |
| `data.realised_pnl` | `number (double) \| null` | no | When `type` is `MarginRow`. Booked P&L for the day, in rupees; absent when not reported. |

**Example**

```json
{
  "seq": 2,
  "type": "snapshot",
  "account": "ZX1234",
  "kind": "positions",
  "data": [
    {
      "account": "ZX1234",
      "tradingsymbol": "RELIANCE-EQ",
      "product": "MIS",
      "net_qty": 4,
      "buy_qty": 4,
      "sell_qty": 0,
      "buy_value": 9999.2,
      "sell_value": 0,
      "average_price": 2499.8,
      "buy_average_price": 2499.8,
      "sell_average_price": 0,
      "pnl": 6.8,
      "lot_size": 1,
      "exchange": "NSE",
      "instrument_type": "EQ",
      "symbol": "RELIANCE-EQ",
      "expiry": "",
      "strike": 0,
      "option_type": "",
      "ltp": 2501.5,
      "prev_close": 2490,
      "instrument_token": "CT:1:2:RELIANCE-EQ"
    }
  ]
}
```

#### missing

Sent in the snapshot set instead of an account's `snapshot` messages
when the account has no live data: the same `code` and `reason` as
REST lists it under `missing` (`no_session`: no active broker session,
log in to the broker; `not_supported`: the broker is not supported
yet). An account whose broker session expired is reported this way,
not as an empty order list. With `kind`: only that kind is missing,
sent instead of its `snapshot` because its only data is from an earlier
broker session (`stale`, read before today's 04:00 IST; never shown as
current).

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `missing`. |
| `account` | `string` | yes |  |
| `kind` | `string` | no | Only this kind is missing (`positions`, `holdings`, `trades` or `margins`); absent when the whole account is. |
| `code` | `string` | yes | Why an account has no data, as a stable code. One of: `no_session`, `not_supported`, `stale`. |
| `reason` | `string` | yes | Why, in plain English. |

**Example**

```json
{
  "seq": 2,
  "type": "missing",
  "account": "ZGN479",
  "code": "no_session",
  "reason": "No live data yet: the account has no active broker session (log in to the broker)"
}
```

#### snapshot_done

Every snapshot has been sent; live messages follow.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `snapshot_done`. |

**Example**

```json
{
  "seq": 3,
  "type": "snapshot_done"
}
```

#### order_update

Live: an order changed (state, fills, broker id...). Carries the full
order, never a diff, so a missed message is repaired by the next one
for the same order.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `order_update`. |
| `account` | `string` | yes |  |
| `data` | `object` | yes | An order as it is delivered live: the tracked order plus instrument details and live prices (added when the instrument is known). `instrument_token` and `order_tag` are also sent under their legacy names, always with the same value; read the primary names. |
| `data.order_tag` | `string` | yes | The order's id, chosen when it was placed (a ULID). |
| `data.broker_tag` | `string \| null` | no | Tag as sent to the broker (format depends on the broker). |
| `data.order_id` | `string \| null` | no | Broker-assigned order id; `null` until the broker acknowledges. |
| `data.parent_tag` | `string \| null` | no | `order_tag` of the order this one belongs to (a protective leg's entry), if any. |
| `data.username` | `string` | yes |  |
| `data.account` | `string` | yes |  |
| `data.broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `data.instrument_token` | `string` | yes | Instrument id. |
| `data.tradingsymbol` | `string` | yes |  |
| `data.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `data.order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `data.product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `data.quantity` | `integer (int64)` | yes |  |
| `data.price` | `number (double)` | yes | Limit price; `0` for market orders. |
| `data.trigger_price` | `number (double) \| null` | no |  |
| `data.state` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.filled_qty` | `integer (int64)` | yes |  |
| `data.average_price` | `number (double) \| null` | no |  |
| `data.status_message` | `string \| null` | no | The broker's latest status text (rejection reason, ...). |
| `data.broker_updated_at` | `string (date-time) \| null` | no | Broker time of the last update applied. |
| `data.created_at` | `string (date-time)` | yes |  |
| `data.status_history` | `object[]` | yes | Every state change so far, oldest first. |
| `data.status_history[].from` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.status_history[].to` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.status_history[].at` | `string (date-time)` | yes |  |
| `data.status_history[].message` | `string \| null` | yes | The broker's status text at this step, if any. |
| `data.client_tag` | `string \| null` | no | The caller's own reference, as given in `client_tag` when the order was placed; absent when none was given (or for orders placed elsewhere, e.g. in the broker's own app). |
| `data.partner_client_id` | `string \| null` | no | Client id of the partner app that placed the order; absent for orders placed any other way. |
| `data.chase` | `object \| null` | no | The order's `chase` settings and the limit it was first sent with; absent when it asked for none. |
| `data.chase.every_ms` | `integer (int64)` | yes |  |
| `data.chase.attempts` | `integer (int32)` | yes |  |
| `data.chase.max_slippage_pct` | `number (double)` | yes |  |
| `data.chase.on_give_up` | `string` | yes | What happens to a still-open order when the chase ends unfilled. One of: `rest`, `cancel`. |
| `data.chase.first_price` | `number (double)` | yes | The limit the order was first sent with. |
| `data.leg_role` | `string \| null` | no | For an exit leg of a bracket / cover order: `stop_loss`, `target` or the broker's `square_off` (its entry is `parent_tag`); absent for every other order. One of: `stop_loss`, `target`, `square_off`. |
| `data.chase_progress` | `object \| null` | no | How far the order's `chase` got (attempts used, how it ended); absent before its first re-price attempt. |
| `data.chase_progress.steps` | `integer (int32)` | yes | Re-price attempts used so far (of the plan's `attempts`). |
| `data.chase_progress.outcome` | `string` | yes | `running` (still re-pricing), `filled`, `gave_up` (stopped at its attempts or slippage bound and left resting, or its cancel failed), `cancelled` (stopped and cancelled as asked) or `stopped` (the order was changed, cancelled or rejected outside the chase). One of: `running`, `filled`, `gave_up`, `cancelled`, `stopped`. |
| `data.strategy` | `string \| null` | no | The strategy of the v2 trigger that placed the order; absent for every other order. |
| `data.split_from` | `object \| null` | no | When the broker split a placed order into several orders (its freeze-quantity cap, e.g. Pocketful at 500 units or Zerodha's autoslice) and lists only those: each is its own order (its own `order_id`, quantity and fills) and this names the order as placed. The first part keeps the placed `order_tag`. Change or cancel each part by its own `order_id`; the placed id still cancels every open part, or changes their price / trigger (not their quantity). Absent for every other order. |
| `data.split_from.order_id` | `string \| null` | yes | The broker order id the placement returned; null when it named none. |
| `data.split_from.order_tag` | `string` | yes | The `order_tag` the order was placed with (its first part keeps it). |
| `data.split_from.quantity` | `integer (int64)` | yes | Units the order was placed for. |
| `data.split_from.cirrus_tag` | `string` | no | **Deprecated.** The `order_tag` the order was placed with (its first part keeps it). |
| `data.lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `data.exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `data.instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `data.symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `data.expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `data.strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `data.option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `data.sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `data.isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `data.indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `data.instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `data.ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `data.prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |
| `data.cirrus_token` | `string` | no | **Deprecated.** Instrument id. |
| `data.cirrus_tag` | `string` | no | **Deprecated.** The order's id, chosen when it was placed (a ULID). |

**Example**

```json
{
  "seq": 4,
  "type": "order_update",
  "account": "ZX1234",
  "data": {
    "broker_tag": "01K6D8ZQ4X9V2M3N",
    "order_id": "250928000123456",
    "parent_tag": null,
    "username": "alice",
    "account": "ZX1234",
    "broker": "zerodha",
    "tradingsymbol": "RELIANCE-EQ",
    "side": "BUY",
    "order_type": "LIMIT",
    "product": "MIS",
    "quantity": 10,
    "price": 2500,
    "trigger_price": null,
    "state": "PARTIALLY_FILLED",
    "filled_qty": 4,
    "average_price": 2499.8,
    "status_message": null,
    "broker_updated_at": "2026-09-28T04:05:07.120Z",
    "created_at": "2026-09-28T04:05:06.789Z",
    "status_history": [
      {
        "from": "SUBMITTED",
        "to": "OPEN",
        "at": "2026-09-28T04:05:06.789Z",
        "message": null
      },
      {
        "from": "OPEN",
        "to": "PARTIALLY_FILLED",
        "at": "2026-09-28T04:05:07.120Z",
        "message": null
      }
    ],
    "lot_size": 1,
    "exchange": "NSE",
    "instrument_type": "EQ",
    "symbol": "RELIANCE-EQ",
    "expiry": "",
    "strike": 0,
    "option_type": "",
    "ltp": 2501.5,
    "prev_close": 2490,
    "instrument_token": "CT:1:2:RELIANCE-EQ",
    "order_tag": "01K6D8ZQ4X9V2M3N5P7R8S9T0W"
  }
}
```

#### portfolio

Live: an account's positions, holdings, trades or margins changed.
Carries the full current data of that kind, never a diff.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `portfolio`. |
| `account` | `string` | yes |  |
| `kind` | `string` | yes | Portfolio data kinds kept as per-account snapshots. One of: `positions`, `holdings`, `trades`, `margins`. |
| `data` | `object[] \| object[] \| object[] \| MarginRow` | yes | `positions`, `holdings`, `trades`: a list of rows (may be empty); `margins`: one object. |
| `data.account` | `string` | yes | When `type` is `MarginRow`. |
| `data.opening_balance` | `number (double)` | yes | When `type` is `MarginRow`. |
| `data.available` | `number (double)` | yes | When `type` is `MarginRow`. |
| `data.utilised` | `number (double)` | yes | When `type` is `MarginRow`. Share of the opening balance in use, as a percent (0-100). |
| `data.used` | `number (double) \| null` | no | When `type` is `MarginRow`. Margin in use, in rupees; absent when the broker does not report it. |
| `data.payin` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds added today, in rupees; absent when not reported. |
| `data.payout` | `number (double) \| null` | no | When `type` is `MarginRow`. Funds withdrawn today, in rupees; absent when not reported. |
| `data.collateral` | `number (double) \| null` | no | When `type` is `MarginRow`. Collateral (pledged holdings) margin, in rupees; absent when not reported. |
| `data.realised_pnl` | `number (double) \| null` | no | When `type` is `MarginRow`. Booked P&L for the day, in rupees; absent when not reported. |

**Example**

```json
{
  "seq": 5,
  "type": "portfolio",
  "account": "ZX1234",
  "kind": "margins",
  "data": {
    "account": "ZX1234",
    "opening_balance": 100000,
    "available": 90000.8,
    "utilised": 9999.2
  }
}
```

#### activity

Live: a new activity-log record.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `activity`. |
| `data` | `object` | yes | One activity-log record: something that happened to the user's accounts, in plain English, with the per-account results. |
| `data.id` | `string` | yes | ULID (sorts by creation time). |
| `data.username` | `string` | yes |  |
| `data.at` | `string (date-time)` | yes | When the action started. For a dependent order (`depends_on`): when its wait for the parent ended and it was sent (or refused). |
| `data.kind` | `string` | yes | What happened. Serialized snake_case (`order_filled`, `gtt_create`...). One of: `place`, `bracket`, `modify`, `cancel`, `convert`, `order_filled`, `order_partially_filled`, `order_rejected`, `order_cancelled_by_broker`, `external_order`, `order_unknown`, `gtt_create`, `gtt_modify`, `gtt_delete`, `gtt_triggered`, `trigger_create`, `trigger_modify`, `trigger_delete`, `trigger_hit`, `trigger_exit`, `protection_armed`, `protection_failed`, `signal`, `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `portfolio_refresh`, `account_setup_incomplete`, `api_key_created`, `api_key_revoked`, `partner_connected`, `partner_revoked`, `partner_app_disabled`, `partner_app_secret_rotated`, `connection_limits_changed`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`, `strategy_created`, `strategy_updated`, `strategy_archived`, `strategy_version_saved`, `deployment_started`, `deployment_paused`, `deployment_resumed`, `deployment_stopped`, `deployment_went_live`, `deployment_exit_now`, `strategies_stop_all`, `backtest_submitted`, `risk_limits_changed`, `basket_saved`, `basket_deleted`. |
| `data.category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account`, `access` or `strategies`. One of: `orders`, `protection`, `signals`, `account`, `access`, `strategies`. |
| `data.source` | `object` | yes | Who or what started an activity. |
| `data.source.type` | `string` | yes | Who or what started it: `app` (signed-in app session), `api_key`, `partner`, `signal` (a signal URL), `trigger` (a stop-loss / target trigger), `broker` (the broker or exchange) or `system` (housekeeping). One of: `app`, `api_key`, `partner`, `signal`, `trigger`, `broker`, `system`. |
| `data.source.name` | `string \| null` | yes | API key name, partner app name, "TradingView: <strategy>", "Stop-loss" / "Target" / "Trailing SL", broker name. |
| `data.source.client_id` | `string \| null` | no | The partner connection that acted (`partner` sources only). |
| `data.source.via` | `string \| null` | no | The channel the request came in through (`app`, `api` or `mcp`); absent on records made before it was recorded. One of: `app`, `api`, `mcp`. |
| `data.source.agent` | `object \| null` | no | The AI agent, for `via: mcp`. |
| `data.source.agent.name` | `string` | yes | The agent's own name, e.g. "Claude". |
| `data.source.agent.version` | `string \| null` | no | Its version, when it said. |
| `data.source.agent.verified` | `boolean` | yes | Whether the name was verified (otherwise it is self-reported). |
| `data.source.tool` | `string \| null` | no | The MCP tool the agent called, for `via: mcp`. |
| `data.request_id` | `string \| null` | yes | The `x-request-id` of the API call that started it, if any. |
| `data.instrument` | `object \| null` | yes |  |
| `data.instrument.instrument_token` | `string` | yes | Instrument id (also returned as `instrument_token`). |
| `data.instrument.tradingsymbol` | `string` | yes |  |
| `data.instrument.exchange` | `string \| null` | yes |  |
| `data.side` | `string \| null` | yes | Order side. One of: `BUY`, `SELL`. |
| `data.order_type` | `string \| null` | yes | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `data.product` | `string \| null` | yes | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `data.quantity` | `integer (int64) \| null` | yes |  |
| `data.price` | `number (double) \| null` | yes |  |
| `data.trigger_price` | `number (double) \| null` | yes |  |
| `data.order_tag` | `string \| null` | no | The order this record is about, when it is about one order (a fill, a rejection, protection armed / failed for an entry). |
| `data.summary` | `string` | yes |  |
| `data.message` | `string \| null` | yes | Plain reason for records without a per-account result (an ignored signal, a lost session, protection that failed...). |
| `data.outcome` | `string` | yes | `ok` (every account succeeded), `partial` or `failed` (none did). One of: `ok`, `partial`, `failed`. |
| `data.duration_ms` | `integer (int64)` | yes | How long the action took. For a dependent order: from its send to the brokers' answer (the wait for its parent is not counted). |
| `data.accounts` | `object[]` | yes |  |
| `data.accounts[].account` | `string` | yes |  |
| `data.accounts[].broker` | `string` | yes |  |
| `data.accounts[].status` | `string` | yes | What happened in one account: `placed` (accepted by the broker and working), `done` (completed), `rejected` (refused by the broker or exchange), `failed` (could not be sent, or did not complete) or `unknown` (no answer in time; the broker may or may not have it). One of: `placed`, `done`, `rejected`, `failed`, `unknown`. |
| `data.accounts[].order_ids` | `string[]` | yes |  |
| `data.accounts[].message` | `string \| null` | yes | Plain-English reason, e.g. "Zerodha rejected it: not enough funds". |
| `data.accounts[].broker_message` | `string \| null` | yes | The broker's own text, unchanged. |
| `data.accounts[].duration_ms` | `integer (int64) \| null` | yes |  |
| `data.accounts[].route` | `string \| null` | no | How the broker call was sent: `direct` or `static_ip`. |
| `data.accounts[].static_ip` | `string \| null` | no | The account's static IP when used. |
| `data.accounts[].proxy_ms` | `integer (int64) \| null` | no | Latest connect time to that static-IP proxy, in milliseconds. |
| `data.depends_on` | `object \| null` | no | Only on records of a dependent order (`depends_on`). |
| `data.depends_on.order_ref` | `string` | yes | `id` of the parent order's activity record (same request). |
| `data.depends_on.tradingsymbol` | `string` | yes |  |
| `data.depends_on.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `data.depends_on.waited_ms` | `integer (int64) \| null` | yes | Longest wait for the parent in any account; `null` when the parent was never placed, so there was nothing to wait for. |
| `data.depends_on.outcome` | `string` | yes | How a dependent order's wait for its parent ended: `filled` (the parent filled; this order was sent), `partial` (sent for the parent's filled share, `depends_on_partial: filled_qty`), `failed` (the parent ended unfilled or was not placed) or `timeout` (the wait's deadline passed). `failed` and `timeout` reject the order with `dependency_failed`. One of: `filled`, `partial`, `failed`, `timeout`. |
| `data.timeline` | `object[] \| null` | no | Only on detail responses and in the archive. |
| `data.timeline[].at` | `string (date-time)` | yes |  |
| `data.timeline[].account` | `string` | yes |  |
| `data.timeline[].order_id` | `string \| null` | yes |  |
| `data.timeline[].state` | `string` | yes | `SUBMITTED`, `OPEN`, `FILLED`, `PARTIALLY_FILLED`, `CANCELLED`, ... |
| `data.timeline[].message` | `string \| null` | yes |  |
| `data.timeline[].filled_qty` | `integer (int64) \| null` | yes | Filled so far at this step (when the journal knows). |
| `data.timeline[].average_price` | `number (double) \| null` | yes |  |

**Example**

```json
{
  "seq": 6,
  "type": "activity",
  "data": {
    "id": "01K6D8ZQ4XA1B2C3D4E5F6G7H8",
    "username": "alice",
    "at": "2026-09-28T04:05:06.789Z",
    "kind": "place",
    "category": "orders",
    "source": {
      "type": "app",
      "name": null
    },
    "request_id": "req_01K6D8ZQ4X",
    "instrument": {
      "tradingsymbol": "RELIANCE-EQ",
      "exchange": "NSE",
      "instrument_token": "CT:1:2:RELIANCE-EQ"
    },
    "side": "BUY",
    "order_type": "LIMIT",
    "product": "MIS",
    "quantity": 10,
    "price": 2500,
    "trigger_price": null,
    "summary": "Buy 10 RELIANCE at 2500 in 1 account",
    "message": null,
    "outcome": "ok",
    "duration_ms": 84,
    "accounts": [
      {
        "account": "ZX1234",
        "broker": "zerodha",
        "status": "placed",
        "order_ids": [
          "250928000123456"
        ],
        "message": null,
        "broker_message": null,
        "duration_ms": 84,
        "route": "static_ip",
        "static_ip": "203.0.113.10",
        "proxy_ms": 6
      }
    ]
  }
}
```

#### error

The stream hit a problem it cannot recover from on this connection;
the connection ends after it. Reconnect.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `error`. |
| `code` | `string` | yes | Why the stream cannot go on: `unavailable` (live updates could not be set up; reconnect). Always `unavailable`. |
| `message` | `string` | yes | Plain-English explanation. |

**Example**

```json
{
  "seq": 7,
  "type": "error",
  "code": "unavailable",
  "message": "live updates unavailable, please reconnect"
}
```

#### subscribed

Opt-in only: the answer to `subscribe`, listing every topic the
connection now gets (empty when none was granted). Never sent to a
connection that did not send `subscribe`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `subscribed`. |
| `topics` | `string[]` | yes |  |

**Example**

```json
{
  "seq": 8,
  "type": "subscribed",
  "topics": [
    "trigger_update"
  ]
}
```

#### trigger_update

Opt-in only (topic `trigger_update`): a v2 trigger of the user changed,
or the engine refused a command for it. One message per change;
de-duplicate on `data.event_id`. Never sent to a connection that did
not subscribe, and never another user's trigger.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `trigger_update`. |
| `data` | `object` | yes | A v2 trigger changed (or the engine refused a command for it). |
| `data.event_id` | `string` | yes | This update's id: the same on every redelivery, so de-duplicate on it. `{trigger_id}:{transition_seq}` for a change, `{trigger_id}:cmd:{command_id}` for a refused command. |
| `data.trigger_id` | `string` | yes |  |
| `data.kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `data.account` | `string` | yes | The trigger's account (always the caller's own). |
| `data.strategy` | `string \| null` | yes |  |
| `data.from` | `string` | yes | The state before (same values as `TriggerV2.state`; other values may appear). |
| `data.to` | `string` | yes | The state after (equal to `from` for a refused command). |
| `data.transition_seq` | `integer (int64)` | yes | The trigger's change counter after this update. |
| `data.actions` | `object[]` | yes | What the engine asked the server to do, as it sent it (`type` `enter`, `exit`, `move_sl`, `notify`, `flatten`, `pause`, `book` (a risk limit's target exiting `pct`% of every position in `scope`); other types may appear). Empty for a refused command. |
| `data.tick` | `object \| null` | yes | The tick that caused the change; null for a command, the clock or a position change. |
| `data.tick.symbol` | `string` | yes | The leg's instrument (`instrument_token`). |
| `data.tick.ltp` | `number (double) \| null` | yes | Its last traded price, when the tick carried one. |
| `data.tick.event_ts` | `string \| null` | yes | When the exchange (or, without an exchange time, the tick processor) stamped the tick; RFC 3339, India time. |
| `data.reason` | `string \| null` | yes | Why, in words (errors, expiry), when the engine said; `day_ended` / `contract_expired` for a trigger the server ended on its own. |
| `data.result` | `object \| null` | yes | The refused command; null for a change. |
| `data.result.op` | `string` | yes | `create`, `modify`, `cancel`, `pause` or `resume`. |
| `data.result.command_id` | `string` | yes | The command's id (`command_id` of the `202` that accepted it). |
| `data.result.outcome` | `string` | yes | `rejected`. |
| `data.result.reason` | `string` | yes | Why, in words. |
| `data.protection` | `map<string, object> \| null` | yes | The trigger's protection map as it stands with this update (same shape as `TriggerV2.protection`); null when it has none. |
| `data.protection.*` | `object` | no | One part of a v2 trigger's protection. |
| `data.protection.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `data.protection.*.broker_order_id` | `string \| null` | yes | The broker's GTT or order id, when the broker holds the part. |
| `data.protection.*.level` | `number (double) \| null` | yes | The part's price level, when it has one. |
| `data.protection.*.adjusted_by_server` | `boolean` | yes | The server moves the broker's level (trailing, ladder advance). |
| `data.protection.*.status` | `string` | yes | Where a part stands. One of: `protected`, `degraded`, `unknown`, `held_by_server`, `ended`. |
| `data.protection.*.note` | `string \| null` | yes | Why, in words. |
| `data.at` | `string` | yes | When it happened; RFC 3339, India time. |
| `data.journey` | `object \| null` | yes | The journey entry this update adds (see `GET /v2/triggers/{trigger_id}/journey`). |
| `data.journey.entry_id` | `string` | yes |  |
| `data.journey.at` | `string` | yes | RFC 3339, India time. |
| `data.journey.seq` | `integer (int64)` | yes | The trigger's change counter this entry goes with (0 when none). |
| `data.journey.source` | `string` | yes | `api`, `engine`, `result`, `exit` or `protection` (other values may appear). |
| `data.journey.kind` | `string` | yes | What happened: `created`, `modified`, `paused`, `resumed`, `cancelled` (api); `transition`, `note` (engine); `rejected` (result); `exit_placed`, `exit_partly_filled`, `exit_filled`, `exit_refused`, `exit_unfilled`, `exit_repriced`, `exit_retried` (an action of the event not done yet), `exit_failed` (given up, dead-lettered) (exit); `gtt_placed`, `gtt_modified`, `gtt_cancelled`, `gtt_triggered`, `readback_matched`, `readback_mismatch`, `readback_unknown`, `degraded` (protection). Other values may appear. |
| `data.journey.summary` | `string` | yes |  |
| `data.journey.actor` | `object \| null` | yes | Who asked, as the owner sees it. |
| `data.journey.actor.user` | `string` | yes | The trigger's owner (the caller). |
| `data.journey.actor.credential` | `string` | yes | `app`, `api_key`, `partner` or `support` (an operator acting for the user; never named). |
| `data.journey.actor.api_key_id` | `string` | no | The API key's id (never its secret), when an API key was used. |
| `data.journey.actor.partner_client_id` | `string` | no | The partner app's client id, when a partner acted. |
| `data.journey.actor.ip` | `string` | no | The caller's own IP (app sessions and the user's API keys only). |
| `data.journey.details` | `object` | yes | Kind-specific detail (only the fields listed for the kind). |

**Example**

```json
{
  "seq": 9,
  "type": "trigger_update",
  "data": {
    "event_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W:3",
    "trigger_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "kind": "bracket",
    "account": "ZX1234",
    "strategy": null,
    "from": "active",
    "to": "sl_hit",
    "transition_seq": 3,
    "actions": [
      {
        "type": "exit",
        "legs": [
          0
        ],
        "qty": "all"
      }
    ],
    "tick": {
      "symbol": "CT:1:2:RELIANCE-EQ",
      "ltp": 2474.5,
      "event_ts": "2026-09-28T09:35:07.120+05:30"
    },
    "reason": null,
    "result": null,
    "protection": null,
    "at": "2026-09-28T09:35:07.120+05:30",
    "journey": {
      "entry_id": "evt:01K6D8ZQ4X9V2M3N5P7R8S9T0W:3",
      "at": "2026-09-28T09:35:07.120+05:30",
      "seq": 3,
      "source": "engine",
      "kind": "transition",
      "summary": "active -> sl_hit",
      "actor": null,
      "details": {
        "from": "active",
        "to": "sl_hit",
        "actions": [
          {
            "type": "exit",
            "legs": [
              0
            ],
            "qty": "all"
          }
        ],
        "tick": {
          "symbol": "CT:1:2:RELIANCE-EQ",
          "ltp": 2474.5
        },
        "reason": null,
        "explain": null
      }
    }
  }
}
```

#### notification

Opt-in only (topic `notifications`): a notice for the user, the same
as `GET /v1/notifications` lists it. De-duplicate on `data.id`. Never
sent to a connection that did not subscribe.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `seq` | `integer (int64)` | yes | Per-connection sequence number (see `hello`). |
| `type` | `string` | yes | The message type. Always `notification`. |
| `data` | `object` | yes | One notification. |
| `data.id` | `string` | yes | Stable id (`ntf_` + 24 hex): the same on every redelivery, so de-duplicate on it. |
| `data.event` | `string` | yes | An event type a user can route. Stored in preferences (Mongo) and sent in notices: values are only ever added (readers first). One of: `trigger_fired`, `trigger_refused`, `trigger_ended`, `exit_placed`, `exit_filled`, `exit_refused`, `order_filled`, `order_rejected`, `order_unconfirmed`, `protection_failed`, `entry_failed`, `strategy_entered`, `strategy_exited`, `risk_breached`, `session_expired`, `live_updates`, `account_setup`, `webhook_disabled`, `daily_summary`, `channel_broken`, `strategy_armed`, `strategy_refused`, `strategy_safety_pause`, `strategy_kill_switch`, `strategy_error`. |
| `data.severity` | `string` | yes | How much a notice needs the user. One of: `info`, `warning`, `critical`. |
| `data.title` | `string` | yes | One line, sentence case. |
| `data.what_happened` | `string` | yes | What happened, in plain English. |
| `data.what_we_did` | `string \| null` | yes | What was done about it (null when nothing needed doing). |
| `data.what_you_should_do` | `string \| null` | yes | What the user should do (null when nothing). |
| `data.account` | `string \| null` | yes | The broker account it is about. |
| `data.strategy` | `string \| null` | yes | The strategy it is about. |
| `data.trigger_id` | `string \| null` | yes | The v2 trigger it is about. |
| `data.deployment_id` | `string \| null` | no | The automated-strategy deployment it is about (open it at `GET /v2/deployments/{deployment_id}`). Added later: absent on older notices. |
| `data.cycle` | `object \| null` | no | The deployment's run (cycle) it is about. Added later: absent on older notices and when it is about no single run. |
| `data.cycle.day` | `string` | yes | Trading day, `YYYY-MM-DD`. |
| `data.cycle.n` | `integer (int32)` | yes | The run's number that day (1 = the first). |
| `data.subtype` | `string \| null` | no | A finer kind within `event`, when there is one (other values may appear; ignore unknown ones): `strategy_adjusted` (a strategy adjusted its open trade; `event` `strategy_entered`), `risk_target` (a risk limit's profit target fired: `event` `trigger_fired`, or `risk_breached` for a last target that exits everything), `risk_trail` (a risk limit's profit trail armed or moved up; `event` `trigger_fired`). Added later: absent otherwise. |
| `data.source` | `object \| null` | no | Why it happened (see `NotificationSource`). Added later: absent on older notices and when unknown. |
| `data.source.kind` | `string` | yes | `strategy`, `trigger`, `protection`, `risk_limit`, `signal`, `manual`, `api` or `partner` (other values may appear). |
| `data.source.name` | `string \| null` | no | Its name, when it has one (the strategy's name, the signal URL's "TradingView: my-url", the API key's or partner app's name, the trigger's kind). |
| `data.source.role` | `string \| null` | no | The part this notice plays: `entry`, `exit`, `stop_loss`, `trailing_stop`, `target`, `alert`, `adjustment` (other values may appear). |
| `data.source.rung` | `integer (int32) \| null` | no | For a ladder target: which one (1 = the first). |
| `data.source.rungs` | `integer (int32) \| null` | no | For a ladder target: how many there are, when known. |
| `data.order` | `object \| null` | no | The order it is about (see `NotificationOrder`). Added later: absent on older notices and when it is about no single order. |
| `data.order.symbol` | `string` | yes | Trading symbol (`RELIANCE-EQ`, `NIFTY27OCT202622500PE`). |
| `data.order.name` | `string \| null` | no | The instrument's name as people read it (`RELIANCE`, `NIFTY 27 Oct 22500 PE`), from the instrument master. |
| `data.order.exchange` | `string \| null` | no |  |
| `data.order.side` | `string \| null` | no | `BUY` or `SELL`. |
| `data.order.product` | `string \| null` | no | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `data.order.order_type` | `string \| null` | no | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `data.order.quantity` | `integer (int64) \| null` | no | Ordered quantity. |
| `data.order.filled_qty` | `integer (int64) \| null` | no | Filled so far. |
| `data.order.average_price` | `number (double) \| null` | no | Average fill price. |
| `data.account_tag` | `string \| null` | no | The account's name in the app (its tag), when it has one. Added later: absent on older notices. |
| `data.realized_pnl` | `number (double) \| null` | no | Realised profit (positive) or loss (negative) in rupees of the position or leg an exit closed, only when it is known from its fills. Added later: absent otherwise. |
| `data.thread` | `string \| null` | no | Groups notices about one thing (an order and its protection and exits, a trigger, a strategy run): the same value on each. Opaque (`order:…`, `trigger:…`, `cycle:…`). Added later: absent on older notices and on account-level ones. |
| `data.origin` | `object` | yes | Where a notice came from (for support; never needed to read it). |
| `data.origin.kind` | `string` | yes | `trigger_update`, `activity` or `anomaly` (other values may appear). |
| `data.origin.id` | `string` | yes | The source record's id (a trigger update's `event_id`, an activity record's `id`). |
| `data.at` | `string` | yes | When it happened; RFC 3339, India time. |

**Example**

```json
{
  "seq": 10,
  "type": "notification",
  "data": {
    "id": "ntf_6c5bee75ff79290eeee8c64a",
    "event": "trigger_fired",
    "severity": "info",
    "title": "Stop-loss hit on RELIANCE",
    "what_happened": "RELIANCE traded at ₹2,474.50.",
    "what_we_did": "We're placing the exit order.",
    "what_you_should_do": null,
    "account": "ZX1234",
    "strategy": null,
    "trigger_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "source": {
      "kind": "trigger",
      "name": "Bracket",
      "role": "stop_loss"
    },
    "thread": "trigger:01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "origin": {
      "kind": "trigger_update",
      "id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W:3"
    },
    "at": "2026-09-28T09:35:07.120+05:30"
  }
}
```

**Client messages**

#### auth

Client -> server, the first frame: authenticates the connection. Not
needed when the upgrade request already carried a credential (an
`Authorization` header, or the app's session cookie). Sent later it is
ignored. Without it within 5 s the server closes with 4002; a bad
credential closes with 4001.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | The message type. Always `auth`. |
| `token` | `string` | yes | An API key, access token or app session token (the same credentials `/v1` routes accept); needs the `read` scope. |

**Example**

```json
{
  "type": "auth",
  "token": "ck_live_4f1d0c9a7b2e"
}
```

#### resync

Client -> server: asks for a fresh snapshot (every account's
`snapshot` messages again, then `snapshot_done`). Use it after a gap
in `seq`. Any other frame the client sends only counts as activity
for the idle timeout.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | The message type. Always `resync`. |

**Example**

```json
{
  "type": "resync"
}
```

#### subscribe

Client -> server: opts in to messages a connection never gets unless
it asks. Topics: `trigger_update` (a v2 trigger of the user changed;
only for users v2 triggers are enabled for) and `notifications` (the
user's notices; only where notifications are on, and only for the
signed-in app session, like `GET /v1/notifications`). Unknown topics are
left out. Answered with `subscribed`. Opting in again is harmless.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | The message type. Always `subscribe`. |
| `topics` | `string[]` | yes |  |

**Example**

```json
{
  "type": "subscribe",
  "topics": [
    "trigger_update"
  ]
}
```

## Webhooks

Register up to 5 HTTPS endpoints of your own and receive signed events as they happen: order updates, trades, account alerts and position changes. Pick the events, and optionally the accounts, per endpoint. Manage them on API access, or with these app-session routes (keys and partner tokens get 403).

### Events

**Event types**

| type | About | Meaning |
| --- | --- | --- |
| `order_update` | order | Any change to one of your orders (state, fill, broker id). `data` is the full order: keep the one with the highest `filled_qty` / latest state. |
| `trade` | fill | The quantity an order filled since the previous update. The `quantity` values of an order’s trades add up to its `filled_qty`. |
| `account_alert` | account | `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `protection_failed`, `order_rejected`, `account_setup_incomplete`, `webhook_disabled`. |
| `positions` | account | An account’s positions changed. At most one per second per account (the latest). |
| `ping` | test | Sent only by the test route. |

### Delivery

- Each event is a `POST` with a JSON body: `id`, `type`, `created_at` (RFC 3339, UTC) and `data`. All keys are snake_case.
- `id` is the same on every retry and never reused (it is also the `webhook-id` header): use it to ignore duplicates.
- Events of one order can arrive out of order. Every `order_update` carries the full order, so keep the one with the highest `filled_qty` / latest state.
- Instruments and orders are identified by `instrument_token` and `order_tag`, the same values you use when placing orders.
- Alerts without an account (`webhook_disabled`) reach every webhook subscribed to `account_alert`, whatever its accounts.

### Retries and turning off

**Your answer**

| Status | Result | What happens |
| --- | --- | --- |
| `2xx` | delivered | Answer within 5 s. The body is ignored. |
| `408 429 5xx 3xx` | retried | Also timeouts and connection errors: retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts in about 4 hours), same `id` and body. |
| `other 4xx` | final | Not retried. |

- A webhook whose deliveries keep failing (50 failed attempts in a row, or failing for 24 hours without a success) is turned off: `enabled: false`, `disabled_reason` `too_many_failures` or `failing_for_24_hours`.
- You get a `webhook_disabled` entry in your activity log and an `account_alert` on your other webhooks. Fix the receiver, then turn it back on (`PATCH {"enabled": true}` or Resume on API access). Events from while it was off are not replayed.

**Every event**

```json
{
  "id": "evt_5f0c2a7d9b1e4c3a8f6d2b0e9a7c5d31",
  "type": "order_update",
  "created_at": "2026-09-28T09:15:03.482Z",
  "data": { }
}
```

**order_update · data**

```json
{
  "order_tag": "01J9X3K9ZC4N8Q2W6E5R7T1Y3U",
  "order_id": "250928000123456",
  "account": "AB1234",
  "broker": "zerodha",
  "instrument_token": "<instrument_token>",
  "tradingsymbol": "RELIANCE",
  "exchange": "NSE",
  "side": "BUY",
  "order_type": "LIMIT",
  "product": "MIS",
  "quantity": 10,
  "price": 2500.0,
  "trigger_price": null,
  "state": "PARTIALLY_FILLED",
  "filled_qty": 4,
  "average_price": 2499.5,
  "status_message": null,
  "status_history": [
    {"from": "OPEN", "to": "PARTIALLY_FILLED", "at": "2026-09-28T09:15:03.470Z", "message": null}
  ],
  "ltp": 2500.1
}
```

**trade · data**

```json
{
  "account": "AB1234",
  "order_id": "250928000123456",
  "order_tag": "01J9X3K9ZC4N8Q2W6E5R7T1Y3U",
  "instrument_token": "<instrument_token>",
  "tradingsymbol": "RELIANCE",
  "side": "BUY",
  "quantity": 6,
  "price": 2500.83,
  "filled_qty": 10,
  "order_quantity": 10,
  "average_price": 2500.3,
  "order_state": "FILLED",
  "filled_at": "2026-09-28T09:15:04.120Z"
}
```

**account_alert · data**

```json
{
  "activity_id": "01J9X3M0A6B7C8D9E0F1G2H3J4",
  "kind": "session_expired",
  "account": "AB1234",
  "broker": "zerodha",
  "summary": "Zerodha login expired: log in to Zerodha again",
  "message": "Orders and updates for this account stop until you log in again",
  "outcome": "failed",
  "at": "2026-09-28T09:20:00.000Z"
}
```

**positions · data**

```json
{
  "account": "AB1234",
  "positions": [{
    "instrument_token": "<instrument_token>",
    "tradingsymbol": "RELIANCE",
    "product": "MIS",
    "net_qty": 10,
    "average_price": 2500.3,
    "pnl": -2.0,
    "ltp": 2500.1
  }]
}
```

### Payloads

#### An order changed

`order_update`: any change to an order (state, fills, broker id). `data` is the full order, the same as the stream's `order_update`.

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same event always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `order_update`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | An order as it is delivered live: the tracked order plus instrument details and live prices (added when the instrument is known). `instrument_token` and `order_tag` are also sent under their legacy names, always with the same value; read the primary names. |
| `data.order_tag` | `string` | yes | The order's id, chosen when it was placed (a ULID). |
| `data.broker_tag` | `string \| null` | no | Tag as sent to the broker (format depends on the broker). |
| `data.order_id` | `string \| null` | no | Broker-assigned order id; `null` until the broker acknowledges. |
| `data.parent_tag` | `string \| null` | no | `order_tag` of the order this one belongs to (a protective leg's entry), if any. |
| `data.username` | `string` | yes |  |
| `data.account` | `string` | yes |  |
| `data.broker` | `string` | yes | Broker id (`zerodha`, `upstox`, ...). |
| `data.instrument_token` | `string` | yes | Instrument id. |
| `data.tradingsymbol` | `string` | yes |  |
| `data.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `data.order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `data.product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `data.quantity` | `integer (int64)` | yes |  |
| `data.price` | `number (double)` | yes | Limit price; `0` for market orders. |
| `data.trigger_price` | `number (double) \| null` | no |  |
| `data.state` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.filled_qty` | `integer (int64)` | yes |  |
| `data.average_price` | `number (double) \| null` | no |  |
| `data.status_message` | `string \| null` | no | The broker's latest status text (rejection reason, ...). |
| `data.broker_updated_at` | `string (date-time) \| null` | no | Broker time of the last update applied. |
| `data.created_at` | `string (date-time)` | yes |  |
| `data.status_history` | `object[]` | yes | Every state change so far, oldest first. |
| `data.status_history[].from` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.status_history[].to` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.status_history[].at` | `string (date-time)` | yes |  |
| `data.status_history[].message` | `string \| null` | yes | The broker's status text at this step, if any. |
| `data.client_tag` | `string \| null` | no | The caller's own reference, as given in `client_tag` when the order was placed; absent when none was given (or for orders placed elsewhere, e.g. in the broker's own app). |
| `data.partner_client_id` | `string \| null` | no | Client id of the partner app that placed the order; absent for orders placed any other way. |
| `data.chase` | `object \| null` | no | The order's `chase` settings and the limit it was first sent with; absent when it asked for none. |
| `data.chase.every_ms` | `integer (int64)` | yes |  |
| `data.chase.attempts` | `integer (int32)` | yes |  |
| `data.chase.max_slippage_pct` | `number (double)` | yes |  |
| `data.chase.on_give_up` | `string` | yes | What happens to a still-open order when the chase ends unfilled. One of: `rest`, `cancel`. |
| `data.chase.first_price` | `number (double)` | yes | The limit the order was first sent with. |
| `data.leg_role` | `string \| null` | no | For an exit leg of a bracket / cover order: `stop_loss`, `target` or the broker's `square_off` (its entry is `parent_tag`); absent for every other order. One of: `stop_loss`, `target`, `square_off`. |
| `data.chase_progress` | `object \| null` | no | How far the order's `chase` got (attempts used, how it ended); absent before its first re-price attempt. |
| `data.chase_progress.steps` | `integer (int32)` | yes | Re-price attempts used so far (of the plan's `attempts`). |
| `data.chase_progress.outcome` | `string` | yes | `running` (still re-pricing), `filled`, `gave_up` (stopped at its attempts or slippage bound and left resting, or its cancel failed), `cancelled` (stopped and cancelled as asked) or `stopped` (the order was changed, cancelled or rejected outside the chase). One of: `running`, `filled`, `gave_up`, `cancelled`, `stopped`. |
| `data.strategy` | `string \| null` | no | The strategy of the v2 trigger that placed the order; absent for every other order. |
| `data.split_from` | `object \| null` | no | When the broker split a placed order into several orders (its freeze-quantity cap, e.g. Pocketful at 500 units or Zerodha's autoslice) and lists only those: each is its own order (its own `order_id`, quantity and fills) and this names the order as placed. The first part keeps the placed `order_tag`. Change or cancel each part by its own `order_id`; the placed id still cancels every open part, or changes their price / trigger (not their quantity). Absent for every other order. |
| `data.split_from.order_id` | `string \| null` | yes | The broker order id the placement returned; null when it named none. |
| `data.split_from.order_tag` | `string` | yes | The `order_tag` the order was placed with (its first part keeps it). |
| `data.split_from.quantity` | `integer (int64)` | yes | Units the order was placed for. |
| `data.split_from.cirrus_tag` | `string` | no | **Deprecated.** The `order_tag` the order was placed with (its first part keeps it). |
| `data.lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `data.exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `data.instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `data.symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `data.expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `data.strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `data.option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `data.sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `data.isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `data.indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `data.instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `data.ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `data.prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |
| `data.cirrus_token` | `string` | no | **Deprecated.** Instrument id. |
| `data.cirrus_tag` | `string` | no | **Deprecated.** The order's id, chosen when it was placed (a ULID). |

**Example**

```json
{
  "id": "evt_b9af59b3434f9997a6fc3ece875ca056",
  "type": "order_update",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "broker_tag": "01K6D8ZQ4X9V2M3N",
    "order_id": "250928000123456",
    "parent_tag": null,
    "username": "alice",
    "account": "ZX1234",
    "broker": "zerodha",
    "tradingsymbol": "RELIANCE-EQ",
    "side": "BUY",
    "order_type": "LIMIT",
    "product": "MIS",
    "quantity": 10,
    "price": 2500,
    "trigger_price": null,
    "state": "PARTIALLY_FILLED",
    "filled_qty": 4,
    "average_price": 2499.8,
    "status_message": null,
    "broker_updated_at": "2026-09-28T04:05:07.120Z",
    "created_at": "2026-09-28T04:05:06.789Z",
    "status_history": [
      {
        "from": "SUBMITTED",
        "to": "OPEN",
        "at": "2026-09-28T04:05:06.789Z",
        "message": null
      },
      {
        "from": "OPEN",
        "to": "PARTIALLY_FILLED",
        "at": "2026-09-28T04:05:07.120Z",
        "message": null
      }
    ],
    "lot_size": 1,
    "exchange": "NSE",
    "instrument_type": "EQ",
    "symbol": "RELIANCE-EQ",
    "expiry": "",
    "strike": 0,
    "option_type": "",
    "ltp": 2501.5,
    "prev_close": 2490,
    "instrument_token": "CT:1:2:RELIANCE-EQ",
    "order_tag": "01K6D8ZQ4X9V2M3N5P7R8S9T0W"
  }
}
```

#### An order filled (fully or partly)

`trade`: one fill of an order.

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same event always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `trade`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | A fill: the quantity an order filled since its previous update, with the order's cumulative state after it. |
| `data.account` | `string` | yes |  |
| `data.broker` | `string` | yes | Broker id. |
| `data.order_id` | `string \| null` | no | Broker order id. |
| `data.order_tag` | `string` | yes |  |
| `data.instrument_token` | `string` | yes |  |
| `data.tradingsymbol` | `string` | yes |  |
| `data.side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `data.product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `data.order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `data.quantity` | `integer (int64)` | yes | Quantity of this fill. |
| `data.price` | `number (double) \| null` | no | Price of this fill (from the change in the order's average price); `null` when the average is unknown. |
| `data.filled_qty` | `integer (int64)` | yes | The order's total filled quantity after this fill. |
| `data.order_quantity` | `integer (int64)` | yes | The order's total quantity. |
| `data.average_price` | `number (double) \| null` | no | The order's average fill price after this fill. |
| `data.order_state` | `string` | yes | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). One of: `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN`. |
| `data.filled_at` | `string (date-time)` | yes |  |
| `data.cirrus_token` | `string` | no | **Deprecated.** |
| `data.cirrus_tag` | `string` | no | **Deprecated.** |

**Example**

```json
{
  "id": "evt_ae7b238d8eb79444044e61522187f090",
  "type": "trade",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "account": "ZX1234",
    "broker": "zerodha",
    "order_id": "250928000123456",
    "order_tag": "01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "instrument_token": "CT:1:2:RELIANCE-EQ",
    "tradingsymbol": "RELIANCE-EQ",
    "side": "BUY",
    "product": "MIS",
    "order_type": "LIMIT",
    "quantity": 4,
    "price": 2499.8,
    "filled_qty": 4,
    "order_quantity": 10,
    "average_price": 2499.8,
    "order_state": "PARTIALLY_FILLED",
    "filled_at": "2026-09-28T04:05:07.120Z"
  }
}
```

#### Something about an account needs the user

`account_alert`: something about an account that needs the user (see `WebhookAlertKind`).

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same event always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `account_alert`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | Something about an account that needs the user. |
| `data.activity_id` | `string` | yes | Id of the activity-log record this alert comes from. |
| `data.kind` | `string` | yes | Activity kinds sent as `account_alert`: `session_expired` (the broker login ended; log in again), `relogin_detected` (the account was logged in elsewhere), `live_updates_unavailable` / `live_updates_restored` (the broker's live order feed dropped / came back), `protection_failed` (a stop-loss / target could not be placed or kept), `order_rejected`, `account_setup_incomplete` (a field one feature needs is missing) and `webhook_disabled` (one of the user's webhooks was turned off after repeated failures). Partner app webhooks also get kind `account_removed` (the account is no longer linked to the user), which a user's own webhook never gets. One of: `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `protection_failed`, `order_rejected`, `account_setup_incomplete`, `webhook_disabled`. |
| `data.account` | `string \| null` | no | The account it is about (`null`: the user as a whole). |
| `data.broker` | `string \| null` | no | Broker id of that account. |
| `data.summary` | `string` | yes | One line in plain English. |
| `data.message` | `string \| null` | no | More detail in plain English, when there is any. |
| `data.outcome` | `string` | yes | `ok` (every account succeeded), `partial` or `failed` (none did). One of: `ok`, `partial`, `failed`. |
| `data.at` | `string (date-time)` | yes |  |

**Example**

```json
{
  "id": "evt_db006f409e35b14728f9b0bd51a7845d",
  "type": "account_alert",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "activity_id": "01K6D8ZR0MA1B2C3D4E5F6G7H8",
    "kind": "session_expired",
    "account": "ZX1234",
    "broker": "zerodha",
    "summary": "Zerodha login expired",
    "message": "Log in to Zerodha again to keep trading from this account",
    "outcome": "failed",
    "at": "2026-09-28T04:05:07.120Z"
  }
}
```

#### An account's positions changed

`positions`: an account's positions changed (full list; at most one per second per account).

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same event always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `positions`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | An account's full positions after a change. |
| `data.account` | `string` | yes |  |
| `data.positions` | `object[]` | yes |  |
| `data.positions[].account` | `string` | yes |  |
| `data.positions[].instrument_token` | `string` | yes |  |
| `data.positions[].tradingsymbol` | `string` | yes |  |
| `data.positions[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |
| `data.positions[].net_qty` | `integer (int64)` | yes | Positive long, negative short. |
| `data.positions[].buy_qty` | `integer (int64)` | yes |  |
| `data.positions[].sell_qty` | `integer (int64)` | yes |  |
| `data.positions[].buy_value` | `number (double)` | yes |  |
| `data.positions[].sell_value` | `number (double)` | yes |  |
| `data.positions[].average_price` | `number (double)` | yes |  |
| `data.positions[].buy_average_price` | `number (double)` | yes |  |
| `data.positions[].sell_average_price` | `number (double)` | yes |  |
| `data.positions[].pnl` | `number (double)` | yes | Mark-to-market P&L as reported by the broker (total: booked plus open). |
| `data.positions[].overnight_qty` | `integer (int64) \| null` | no | Quantity carried in from earlier sessions: positive long, negative short. Absent when the broker does not report it. |
| `data.positions[].day_buy_qty` | `integer (int64) \| null` | no | Bought today (absent when the broker does not report it). |
| `data.positions[].day_sell_qty` | `integer (int64) \| null` | no | Sold today (absent when the broker does not report it). |
| `data.positions[].day_buy_value` | `number (double) \| null` | no | Value bought today, in rupees (absent when not reported). |
| `data.positions[].day_sell_value` | `number (double) \| null` | no | Value sold today, in rupees (absent when not reported). |
| `data.positions[].realised_pnl` | `number (double) \| null` | no | Booked (closed) P&L as reported by the broker; absent when not reported. |
| `data.positions[].unrealised_pnl` | `number (double) \| null` | no | Open (unbooked) P&L as reported by the broker; absent when not reported. |
| `data.positions[].day_pnl` | `number (double) \| null` | no | Today's P&L, in rupees: a quantity carried in from earlier sessions counts from the previous close, not its original cost. Absent when not reported. |
| `data.positions[].lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.positions[].broker_tradingsymbol` | `string \| null` | no | The broker's own spelling of the symbol, only when it differs from `tradingsymbol` (which always comes from the instrument list). |
| `data.positions[].exchange` | `string \| null` | no | Exchange as the instrument list gives it (`NSE`, `BSE`, `MCX`, ...): index options and futures are listed under the cash exchange (`NSE` for NIFTY, `BSE` for SENSEX), so match on what `GET /v1/instruments` returns, not on `NFO` / `BFO`. |
| `data.positions[].instrument_type` | `string \| null` | no | Instrument type (`EQ`, `FUTIDX`, `OPTIDX`, `OPTSTK`, ...). |
| `data.positions[].symbol` | `string \| null` | no | Underlying symbol (the trading symbol for equities). |
| `data.positions[].expiry` | `string \| null` | no | Expiry date (`YYYY-MM-DD`); empty for non-derivatives. |
| `data.positions[].strike` | `number (double) \| null` | no | Strike price; `0` for non-options. |
| `data.positions[].option_type` | `string \| null` | no | `CE` / `PE`; empty for non-options. |
| `data.positions[].sector` | `string \| null` | no | Industry sector; absent when the instrument list has none. |
| `data.positions[].isin` | `string \| null` | no | ISIN; absent when the instrument list has none. |
| `data.positions[].indices` | `string[] \| null` | no | Index memberships (e.g. `NIFTY`, `NIFTY100`); absent when none. |
| `data.positions[].instrument_missing` | `boolean \| null` | no | Present (`true`) only when the instrument is not known; the instrument fields above are then absent. |
| `data.positions[].ltp` | `number (double) \| null` | no | Last traded price (added when a quote is available). |
| `data.positions[].prev_close` | `number (double) \| null` | no | Previous close (`0` when unknown), sent with `ltp`. |
| `data.positions[].cirrus_token` | `string` | no | **Deprecated.** |

**Example**

```json
{
  "id": "evt_014d471054c08189e7d7d67e7673474a",
  "type": "positions",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "account": "ZX1234",
    "positions": [
      {
        "account": "ZX1234",
        "tradingsymbol": "RELIANCE-EQ",
        "product": "MIS",
        "net_qty": 4,
        "buy_qty": 4,
        "sell_qty": 0,
        "buy_value": 9999.2,
        "sell_value": 0,
        "average_price": 2499.8,
        "buy_average_price": 2499.8,
        "sell_average_price": 0,
        "pnl": 6.8,
        "lot_size": 1,
        "exchange": "NSE",
        "instrument_type": "EQ",
        "symbol": "RELIANCE-EQ",
        "expiry": "",
        "strike": 0,
        "option_type": "",
        "ltp": 2501.5,
        "prev_close": 2490,
        "instrument_token": "CT:1:2:RELIANCE-EQ"
      }
    ]
  }
}
```

#### Test delivery

`ping`: a test delivery, sent only when the user asks for one (a new id every time).

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same event always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `ping`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | The test delivery's content. |
| `data.webhook_id` | `string` | yes | The webhook being tested. |
| `data.message` | `string` | yes |  |

**Example**

```json
{
  "id": "evt_789c8513c48f23ed0d0012dde1f53850",
  "type": "ping",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "webhook_id": "pb_01k6d8zq4x9v2m3n5p7r8s9t0w",
    "message": "Test event: your endpoint is reachable."
  }
}
```

#### A v2 trigger changed (webhooks that opted in only)

`trigger_update`: a v2 trigger of the user changed, or the engine refused a command for it. Sent only to webhooks that opted in (`trigger_updates: true`); `data` is the same as the stream's `trigger_update`.

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same update always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `trigger_update`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | A v2 trigger changed (or the engine refused a command for it). |
| `data.event_id` | `string` | yes | This update's id: the same on every redelivery, so de-duplicate on it. `{trigger_id}:{transition_seq}` for a change, `{trigger_id}:cmd:{command_id}` for a refused command. |
| `data.trigger_id` | `string` | yes |  |
| `data.kind` | `string` | yes | The machine that runs a trigger. One of: `alert`, `bracket`, `ladder`, `risk_limit`. |
| `data.account` | `string` | yes | The trigger's account (always the caller's own). |
| `data.strategy` | `string \| null` | yes |  |
| `data.from` | `string` | yes | The state before (same values as `TriggerV2.state`; other values may appear). |
| `data.to` | `string` | yes | The state after (equal to `from` for a refused command). |
| `data.transition_seq` | `integer (int64)` | yes | The trigger's change counter after this update. |
| `data.actions` | `object[]` | yes | What the engine asked the server to do, as it sent it (`type` `enter`, `exit`, `move_sl`, `notify`, `flatten`, `pause`, `book` (a risk limit's target exiting `pct`% of every position in `scope`); other types may appear). Empty for a refused command. |
| `data.tick` | `object \| null` | yes | The tick that caused the change; null for a command, the clock or a position change. |
| `data.tick.symbol` | `string` | yes | The leg's instrument (`instrument_token`). |
| `data.tick.ltp` | `number (double) \| null` | yes | Its last traded price, when the tick carried one. |
| `data.tick.event_ts` | `string \| null` | yes | When the exchange (or, without an exchange time, the tick processor) stamped the tick; RFC 3339, India time. |
| `data.reason` | `string \| null` | yes | Why, in words (errors, expiry), when the engine said; `day_ended` / `contract_expired` for a trigger the server ended on its own. |
| `data.result` | `object \| null` | yes | The refused command; null for a change. |
| `data.result.op` | `string` | yes | `create`, `modify`, `cancel`, `pause` or `resume`. |
| `data.result.command_id` | `string` | yes | The command's id (`command_id` of the `202` that accepted it). |
| `data.result.outcome` | `string` | yes | `rejected`. |
| `data.result.reason` | `string` | yes | Why, in words. |
| `data.protection` | `map<string, object> \| null` | yes | The trigger's protection map as it stands with this update (same shape as `TriggerV2.protection`); null when it has none. |
| `data.protection.*` | `object` | no | One part of a v2 trigger's protection. |
| `data.protection.*.held_by` | `string` | yes | Who holds a part. One of: `broker`, `server`. |
| `data.protection.*.broker_order_id` | `string \| null` | yes | The broker's GTT or order id, when the broker holds the part. |
| `data.protection.*.level` | `number (double) \| null` | yes | The part's price level, when it has one. |
| `data.protection.*.adjusted_by_server` | `boolean` | yes | The server moves the broker's level (trailing, ladder advance). |
| `data.protection.*.status` | `string` | yes | Where a part stands. One of: `protected`, `degraded`, `unknown`, `held_by_server`, `ended`. |
| `data.protection.*.note` | `string \| null` | yes | Why, in words. |
| `data.at` | `string` | yes | When it happened; RFC 3339, India time. |
| `data.journey` | `object \| null` | yes | The journey entry this update adds (see `GET /v2/triggers/{trigger_id}/journey`). |
| `data.journey.entry_id` | `string` | yes |  |
| `data.journey.at` | `string` | yes | RFC 3339, India time. |
| `data.journey.seq` | `integer (int64)` | yes | The trigger's change counter this entry goes with (0 when none). |
| `data.journey.source` | `string` | yes | `api`, `engine`, `result`, `exit` or `protection` (other values may appear). |
| `data.journey.kind` | `string` | yes | What happened: `created`, `modified`, `paused`, `resumed`, `cancelled` (api); `transition`, `note` (engine); `rejected` (result); `exit_placed`, `exit_partly_filled`, `exit_filled`, `exit_refused`, `exit_unfilled`, `exit_repriced`, `exit_retried` (an action of the event not done yet), `exit_failed` (given up, dead-lettered) (exit); `gtt_placed`, `gtt_modified`, `gtt_cancelled`, `gtt_triggered`, `readback_matched`, `readback_mismatch`, `readback_unknown`, `degraded` (protection). Other values may appear. |
| `data.journey.summary` | `string` | yes |  |
| `data.journey.actor` | `object \| null` | yes | Who asked, as the owner sees it. |
| `data.journey.actor.user` | `string` | yes | The trigger's owner (the caller). |
| `data.journey.actor.credential` | `string` | yes | `app`, `api_key`, `partner` or `support` (an operator acting for the user; never named). |
| `data.journey.actor.api_key_id` | `string` | no | The API key's id (never its secret), when an API key was used. |
| `data.journey.actor.partner_client_id` | `string` | no | The partner app's client id, when a partner acted. |
| `data.journey.actor.ip` | `string` | no | The caller's own IP (app sessions and the user's API keys only). |
| `data.journey.details` | `object` | yes | Kind-specific detail (only the fields listed for the kind). |

**Example**

```json
{
  "id": "evt_b47744999b9c89b900c37651373d0985",
  "type": "trigger_update",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "event_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W:3",
    "trigger_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "kind": "bracket",
    "account": "ZX1234",
    "strategy": null,
    "from": "active",
    "to": "sl_hit",
    "transition_seq": 3,
    "actions": [
      {
        "type": "exit",
        "legs": [
          0
        ],
        "qty": "all"
      }
    ],
    "tick": {
      "symbol": "CT:1:2:RELIANCE-EQ",
      "ltp": 2474.5,
      "event_ts": "2026-09-28T09:35:07.120+05:30"
    },
    "reason": null,
    "result": null,
    "protection": null,
    "at": "2026-09-28T09:35:07.120+05:30",
    "journey": {
      "entry_id": "evt:01K6D8ZQ4X9V2M3N5P7R8S9T0W:3",
      "at": "2026-09-28T09:35:07.120+05:30",
      "seq": 3,
      "source": "engine",
      "kind": "transition",
      "summary": "active -> sl_hit",
      "actor": null,
      "details": {
        "from": "active",
        "to": "sl_hit",
        "actions": [
          {
            "type": "exit",
            "legs": [
              0
            ],
            "qty": "all"
          }
        ],
        "tick": {
          "symbol": "CT:1:2:RELIANCE-EQ",
          "ltp": 2474.5
        },
        "reason": null,
        "explain": null
      }
    }
  }
}
```

#### A user's connection to your partner app ended (partner app webhooks only)

`connection_revoked`: a user's connection to the partner app ended; the app must ask the user to connect again.

Sent only to a partner app's webhook (set with `PATCH /v1/partner-apps/{client_id}`), for users connected to the app. Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, username, data}` (all keys snake_case; `username` is the user the event is about), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the app's `whsec_...` webhook secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. An app webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off (`PartnerApp.webhook.enabled` false, with `disabled_reason`) and the app's owner is told (their activity log, and an `account_alert` `webhook_disabled` to the owner's own webhooks that subscribe to `account_alert`); the owner turns it back on with `webhook_enabled: true`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters (also the `webhook-id` header). |
| `type` | `string` | yes | The message type. Always `connection_revoked`. |
| `created_at` | `string (date-time)` | yes | When it happened (RFC 3339, UTC, milliseconds). |
| `username` | `string` | yes | The user the event is about. |
| `data` | `object` | yes | A user's connection to the partner app ended. |
| `data.username` | `string` | yes |  |
| `data.client_id` | `string` | yes | The app's client id. |
| `data.reason` | `string` | yes | Why a connection ended. One of: `user_revoked`, `refresh_token_replay`, `signed_out_everywhere`. |

**Example**

```json
{
  "id": "evt_7fb290a485c105d06f1e8d9ab1352845",
  "type": "connection_revoked",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "username": "alice",
    "client_id": "cp_01k6d8zq4x9v2m3n5p7r8s9t0p",
    "reason": "user_revoked"
  },
  "username": "alice"
}
```

#### A notification (webhooks chosen in notification preferences only)

`notification`: a plain-English notice for the user, the same as `GET /v1/notifications` lists it. Sent only to the webhooks the user chose in their notification preferences (`webhook` channel).

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and a body `{id, type, created_at, data}` (all keys snake_case), signed per Standard Webhooks (https://www.standardwebhooks.com):
- `webhook-id`: the event `id` (same on every retry; de-duplicate on it);
- `webhook-timestamp`: Unix seconds when this attempt was sent;
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the webhook's `whsec_...` secret. While a rotated secret is still honoured, one signature per secret is sent, space separated; accept the message when any one matches.

Answer 2xx within 5 s. 408, 429, 5xx, timeouts and network errors are retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (7 attempts); other 4xx answers are not retried. A webhook that keeps failing (50 failures in a row, or failing for 24 h) is turned off and an `account_alert` (`webhook_disabled`) goes to the user's remaining webhooks that subscribe to `account_alert`.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Event id, `evt_` + 32 hex characters. Deterministic: the same notice always has the same id (it is also the `webhook-id` header), so receivers can de-duplicate on it. |
| `type` | `string` | yes | The message type. Always `notification`. |
| `created_at` | `string (date-time)` | yes | When it was sent (RFC 3339, UTC, milliseconds). |
| `data` | `object` | yes | One notification. |
| `data.id` | `string` | yes | Stable id (`ntf_` + 24 hex): the same on every redelivery, so de-duplicate on it. |
| `data.event` | `string` | yes | An event type a user can route. Stored in preferences (Mongo) and sent in notices: values are only ever added (readers first). One of: `trigger_fired`, `trigger_refused`, `trigger_ended`, `exit_placed`, `exit_filled`, `exit_refused`, `order_filled`, `order_rejected`, `order_unconfirmed`, `protection_failed`, `entry_failed`, `strategy_entered`, `strategy_exited`, `risk_breached`, `session_expired`, `live_updates`, `account_setup`, `webhook_disabled`, `daily_summary`, `channel_broken`, `strategy_armed`, `strategy_refused`, `strategy_safety_pause`, `strategy_kill_switch`, `strategy_error`. |
| `data.severity` | `string` | yes | How much a notice needs the user. One of: `info`, `warning`, `critical`. |
| `data.title` | `string` | yes | One line, sentence case. |
| `data.what_happened` | `string` | yes | What happened, in plain English. |
| `data.what_we_did` | `string \| null` | yes | What was done about it (null when nothing needed doing). |
| `data.what_you_should_do` | `string \| null` | yes | What the user should do (null when nothing). |
| `data.account` | `string \| null` | yes | The broker account it is about. |
| `data.strategy` | `string \| null` | yes | The strategy it is about. |
| `data.trigger_id` | `string \| null` | yes | The v2 trigger it is about. |
| `data.deployment_id` | `string \| null` | no | The automated-strategy deployment it is about (open it at `GET /v2/deployments/{deployment_id}`). Added later: absent on older notices. |
| `data.cycle` | `object \| null` | no | The deployment's run (cycle) it is about. Added later: absent on older notices and when it is about no single run. |
| `data.cycle.day` | `string` | yes | Trading day, `YYYY-MM-DD`. |
| `data.cycle.n` | `integer (int32)` | yes | The run's number that day (1 = the first). |
| `data.subtype` | `string \| null` | no | A finer kind within `event`, when there is one (other values may appear; ignore unknown ones): `strategy_adjusted` (a strategy adjusted its open trade; `event` `strategy_entered`), `risk_target` (a risk limit's profit target fired: `event` `trigger_fired`, or `risk_breached` for a last target that exits everything), `risk_trail` (a risk limit's profit trail armed or moved up; `event` `trigger_fired`). Added later: absent otherwise. |
| `data.source` | `object \| null` | no | Why it happened (see `NotificationSource`). Added later: absent on older notices and when unknown. |
| `data.source.kind` | `string` | yes | `strategy`, `trigger`, `protection`, `risk_limit`, `signal`, `manual`, `api` or `partner` (other values may appear). |
| `data.source.name` | `string \| null` | no | Its name, when it has one (the strategy's name, the signal URL's "TradingView: my-url", the API key's or partner app's name, the trigger's kind). |
| `data.source.role` | `string \| null` | no | The part this notice plays: `entry`, `exit`, `stop_loss`, `trailing_stop`, `target`, `alert`, `adjustment` (other values may appear). |
| `data.source.rung` | `integer (int32) \| null` | no | For a ladder target: which one (1 = the first). |
| `data.source.rungs` | `integer (int32) \| null` | no | For a ladder target: how many there are, when known. |
| `data.order` | `object \| null` | no | The order it is about (see `NotificationOrder`). Added later: absent on older notices and when it is about no single order. |
| `data.order.symbol` | `string` | yes | Trading symbol (`RELIANCE-EQ`, `NIFTY27OCT202622500PE`). |
| `data.order.name` | `string \| null` | no | The instrument's name as people read it (`RELIANCE`, `NIFTY 27 Oct 22500 PE`), from the instrument master. |
| `data.order.exchange` | `string \| null` | no |  |
| `data.order.side` | `string \| null` | no | `BUY` or `SELL`. |
| `data.order.product` | `string \| null` | no | `MIS`, `CARRYFORWARD`, `DELIVERY` or `MTF`. |
| `data.order.order_type` | `string \| null` | no | `MARKET`, `LIMIT`, `SL` or `SL_M`. |
| `data.order.quantity` | `integer (int64) \| null` | no | Ordered quantity. |
| `data.order.filled_qty` | `integer (int64) \| null` | no | Filled so far. |
| `data.order.average_price` | `number (double) \| null` | no | Average fill price. |
| `data.account_tag` | `string \| null` | no | The account's name in the app (its tag), when it has one. Added later: absent on older notices. |
| `data.realized_pnl` | `number (double) \| null` | no | Realised profit (positive) or loss (negative) in rupees of the position or leg an exit closed, only when it is known from its fills. Added later: absent otherwise. |
| `data.thread` | `string \| null` | no | Groups notices about one thing (an order and its protection and exits, a trigger, a strategy run): the same value on each. Opaque (`order:…`, `trigger:…`, `cycle:…`). Added later: absent on older notices and on account-level ones. |
| `data.origin` | `object` | yes | Where a notice came from (for support; never needed to read it). |
| `data.origin.kind` | `string` | yes | `trigger_update`, `activity` or `anomaly` (other values may appear). |
| `data.origin.id` | `string` | yes | The source record's id (a trigger update's `event_id`, an activity record's `id`). |
| `data.at` | `string` | yes | When it happened; RFC 3339, India time. |

**Example**

```json
{
  "id": "evt_fd94f3fb56c9587569d81f286b282dea",
  "type": "notification",
  "created_at": "2026-09-28T04:05:07.120Z",
  "data": {
    "id": "ntf_6c5bee75ff79290eeee8c64a",
    "event": "trigger_fired",
    "severity": "info",
    "title": "Stop-loss hit on RELIANCE",
    "what_happened": "RELIANCE traded at ₹2,474.50.",
    "what_we_did": "We're placing the exit order.",
    "what_you_should_do": null,
    "account": "ZX1234",
    "strategy": null,
    "trigger_id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "source": {
      "kind": "trigger",
      "name": "Bracket",
      "role": "stop_loss"
    },
    "thread": "trigger:01K6D8ZQ4X9V2M3N5P7R8S9T0W",
    "origin": {
      "kind": "trigger_update",
      "id": "01K6D8ZQ4X9V2M3N5P7R8S9T0W:3"
    },
    "at": "2026-09-28T09:35:07.120+05:30"
  }
}
```

### POST /v1/postbacks

Access: app session only (API keys and partner tokens get 403)

Create a webhook. The reply carries the signing `secret`, shown only once.

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | yes | The receiver: an `https://` URL on a public host (private, loopback and link-local addresses are refused). |
| `events` | `string[]` | yes | Events to send, at least one: `order_update`, `trade`, `account_alert`, `positions` (`ping` cannot be subscribed to). One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | no | Only these of the user's accounts (non-empty, the user's own); leave out or null for every account, including ones added later. |
| `description` | `string \| null` | no | A note for the owner, at most 200 characters. |
| `trigger_updates` | `boolean` | no | `true` also sends `trigger_update` (a v2 trigger of the user changed; see the document's `webhooks` section). Off unless set (also later with `PATCH`). |

- 409 `webhook_limit_reached` at 5 webhooks; 422 `invalid_request` names the `field`; 403 `own_session_required` when support staff act for you.

```bash
curl -X POST https://api.cirrus.trade/v1/postbacks \
  -H "Authorization: Bearer <app session>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/trading",
       "events": ["order_update", "trade"],
       "accounts": ["<account_id>"], "description": "my algo"}'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `url` | `string` | yes |  |
| `events` | `string[]` | yes | One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | yes | null = every account. |
| `description` | `string \| null` | yes |  |
| `enabled` | `boolean` | yes |  |
| `disabled_reason` | `string \| null` | yes | `disabled_by_user`, `too_many_failures` or `failing_for_24_hours`. |
| `disabled_at` | `string \| null` | yes |  |
| `consecutive_failures` | `integer (int32)` | yes |  |
| `last_success_at` | `string \| null` | yes |  |
| `last_failure_at` | `string \| null` | yes |  |
| `previous_secret_expires_at` | `string \| null` | yes | After a rotation: until then deliveries are also signed with the previous secret. |
| `secret_rotated_at` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `trigger_updates` | `boolean` | no | Present (and `true`) only when `trigger_update` deliveries (v2 trigger changes) are on; absent otherwise. |
| `secret` | `string` | yes | The signing secret (`whsec_...`): verify each delivery's `webhook-signature` with it. Store it now: it is never shown again. |
| `note` | `string` | yes | A reminder that the secret is shown only once. |

**Example: Order and trade events of one account (201)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m5yb0gy8qwqm4gbe8tg61nhf",
    "url": "http://127.0.0.1:61319/hooks/orders",
    "events": [
      "order_update",
      "trade"
    ],
    "accounts": [
      "P1"
    ],
    "description": "Order desk",
    "enabled": true,
    "disabled_reason": null,
    "disabled_at": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-27T09:28:08.904Z",
    "updated_at": "2026-10-27T09:28:08.904Z",
    "secret": "whsec_lW1M6I7AW+mbrgsUJkWvnLgrevwb/FI4DX+XFigSAuU=",
    "note": "Store the secret now: it is not shown again."
  },
  "error": null
}
```

**Example: Also v2 trigger changes (`trigger_update`), chosen at creation (201)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m5yatz1ebj0tfsb83zk0aaw2",
    "url": "http://127.0.0.1:53767/hooks/triggers",
    "events": [
      "order_update"
    ],
    "accounts": null,
    "description": null,
    "enabled": true,
    "disabled_reason": null,
    "disabled_at": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-27T09:25:06.734Z",
    "updated_at": "2026-10-27T09:25:06.734Z",
    "trigger_updates": true,
    "secret": "whsec_ddZXEjVZ/JUHLIS1YhmiVcxErjefrMRKK/0pW6YPR7Q=",
    "note": "Store the secret now: it is not shown again."
  },
  "error": null
}
```

**Example: The secret cannot be chosen (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown field `secret`, expected one of `url`, `events`, `accounts`, `description`, `trigger_updates`",
    "field": null
  }
}
```

**Example: An unknown or revoked API key (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Invalid API key",
    "field": null
  }
}
```

**Example: An API key without the `webhooks` scope (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "This credential lacks the `webhooks` scope",
    "field": null
  }
}
```

**Example: An operator acting as the user cannot create webhooks (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "own_session_required",
    "message": "Only the account holder, signed in themselves, can do this",
    "field": null
  }
}
```

**Example: An unknown event name is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "unknown event `fills`; expected one of order_update, trade, account_alert, positions",
    "field": "events"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 409 | `webhook_limit_reached` | The webhook limit is reached; delete one first. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/postbacks

Access: app session only (API keys and partner tokens get 403)

Your webhooks (never the secret).

```bash
curl "https://api.cirrus.trade/v1/postbacks" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].id` | `string` | yes | Webhook id. |
| `[].url` | `string` | yes | The receiver: an `https://` URL on a public host. |
| `[].events` | `string[]` | yes | The events it gets (see the document's `webhooks` section for each body). One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `[].accounts` | `string[] \| null` | yes | The accounts whose events it gets; null = every account of the user (including ones added later). Events about the user rather than one account go to every webhook subscribed to them. |
| `[].description` | `string \| null` | yes | The owner's note. |
| `[].enabled` | `boolean` | yes | Whether deliveries are sent. |
| `[].disabled_reason` | `string \| null` | yes | Why it is off: `disabled_by_user`, `too_many_failures` (50 failed attempts in a row) or `failing_for_24_hours`; null while enabled. |
| `[].disabled_at` | `string \| null` | yes |  |
| `[].consecutive_failures` | `integer (int32)` | yes | Failed attempts in a row (any success resets it). |
| `[].last_success_at` | `string \| null` | yes |  |
| `[].last_failure_at` | `string \| null` | yes |  |
| `[].previous_secret_expires_at` | `string \| null` | yes | While set, deliveries are also signed with the previous secret (for 24 hours after a rotation). |
| `[].secret_rotated_at` | `string \| null` | yes |  |
| `[].created_at` | `string` | yes |  |
| `[].updated_at` | `string` | yes |  |
| `[].trigger_updates` | `boolean` | no | Present (and `true`) only when the owner opted in to `trigger_update` deliveries (v2 trigger changes); absent otherwise. |

**Example: The user's webhooks (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "pb_01m5yb0gy8qwqm4gbe8tg61nhf",
      "url": "http://127.0.0.1:61319/hooks/orders",
      "events": [
        "order_update",
        "trade"
      ],
      "accounts": [
        "P1"
      ],
      "description": "Order desk",
      "enabled": true,
      "disabled_reason": null,
      "disabled_at": null,
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "previous_secret_expires_at": null,
      "secret_rotated_at": null,
      "created_at": "2026-10-27T09:28:08.904Z",
      "updated_at": "2026-10-27T09:28:08.904Z"
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/postbacks/{id}

Access: app session only (API keys and partner tokens get 403)

One webhook (never the secret): its settings and delivery health.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

```bash
curl "https://api.cirrus.trade/v1/postbacks/<id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Webhook id. |
| `url` | `string` | yes | The receiver: an `https://` URL on a public host. |
| `events` | `string[]` | yes | The events it gets (see the document's `webhooks` section for each body). One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | yes | The accounts whose events it gets; null = every account of the user (including ones added later). Events about the user rather than one account go to every webhook subscribed to them. |
| `description` | `string \| null` | yes | The owner's note. |
| `enabled` | `boolean` | yes | Whether deliveries are sent. |
| `disabled_reason` | `string \| null` | yes | Why it is off: `disabled_by_user`, `too_many_failures` (50 failed attempts in a row) or `failing_for_24_hours`; null while enabled. |
| `disabled_at` | `string \| null` | yes |  |
| `consecutive_failures` | `integer (int32)` | yes | Failed attempts in a row (any success resets it). |
| `last_success_at` | `string \| null` | yes |  |
| `last_failure_at` | `string \| null` | yes |  |
| `previous_secret_expires_at` | `string \| null` | yes | While set, deliveries are also signed with the previous secret (for 24 hours after a rotation). |
| `secret_rotated_at` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `trigger_updates` | `boolean` | no | Present (and `true`) only when the owner opted in to `trigger_update` deliveries (v2 trigger changes); absent otherwise. |

**Example: One webhook (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m5yb0gy8qwqm4gbe8tg61nhf",
    "url": "http://127.0.0.1:61319/hooks/orders",
    "events": [
      "order_update",
      "trade"
    ],
    "accounts": [
      "P1"
    ],
    "description": "Order desk",
    "enabled": true,
    "disabled_reason": null,
    "disabled_at": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-27T09:28:08.904Z",
    "updated_at": "2026-10-27T09:28:08.904Z"
  },
  "error": null
}
```

**Example: No such webhook (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "webhook_not_found",
    "message": "Webhook not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v1/postbacks/{id}

Access: app session only (API keys and partner tokens get 403)

Change `url`, `events`, `accounts`, `description` or `enabled`. `"enabled": false` pauses; `true` turns it back on and resets the failure count; `"accounts": null` means every account.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string \| null` | no | A new receiver URL (same rules as on creation). |
| `events` | `string[] \| null` | no | A new event list (replaces the old one; at least one). One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | no | A new account list; `null` goes back to every account. |
| `description` | `string \| null` | no | A new note; `null` or empty removes it. |
| `enabled` | `boolean \| null` | no | `false` turns deliveries off; `true` turns them back on (also after the webhook was turned off for failing). |
| `trigger_updates` | `boolean \| null` | no | `true` also sends `trigger_update` (a v2 trigger of the user changed; see the document's `webhooks` section); `false` stops it. A new webhook starts without it unless created with it. |

```bash
curl -X PATCH https://api.cirrus.trade/v1/postbacks/<id> \
  -H "Authorization: Bearer <app session>" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | Webhook id. |
| `url` | `string` | yes | The receiver: an `https://` URL on a public host. |
| `events` | `string[]` | yes | The events it gets (see the document's `webhooks` section for each body). One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | yes | The accounts whose events it gets; null = every account of the user (including ones added later). Events about the user rather than one account go to every webhook subscribed to them. |
| `description` | `string \| null` | yes | The owner's note. |
| `enabled` | `boolean` | yes | Whether deliveries are sent. |
| `disabled_reason` | `string \| null` | yes | Why it is off: `disabled_by_user`, `too_many_failures` (50 failed attempts in a row) or `failing_for_24_hours`; null while enabled. |
| `disabled_at` | `string \| null` | yes |  |
| `consecutive_failures` | `integer (int32)` | yes | Failed attempts in a row (any success resets it). |
| `last_success_at` | `string \| null` | yes |  |
| `last_failure_at` | `string \| null` | yes |  |
| `previous_secret_expires_at` | `string \| null` | yes | While set, deliveries are also signed with the previous secret (for 24 hours after a rotation). |
| `secret_rotated_at` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `trigger_updates` | `boolean` | no | Present (and `true`) only when the owner opted in to `trigger_update` deliveries (v2 trigger changes); absent otherwise. |

**Example: Every account, one more event, turned off (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m5yb0gy8qwqm4gbe8tg61nhf",
    "url": "http://127.0.0.1:61319/hooks/orders",
    "events": [
      "order_update",
      "trade",
      "positions"
    ],
    "accounts": null,
    "description": "Order desk",
    "enabled": false,
    "disabled_reason": "disabled_by_user",
    "disabled_at": "2026-10-27T09:28:08.908Z",
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-27T09:28:08.904Z",
    "updated_at": "2026-10-27T09:28:08.908Z"
  },
  "error": null
}
```

**Example: Only the user's own accounts (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "`NOT-MINE` is not one of your accounts",
    "field": "accounts"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/postbacks/{id}

Access: app session only (API keys and partner tokens get 403)

Delete a webhook. Events stop at once.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/postbacks/<id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `deleted` | `boolean` | yes | Always `true`. |

**Example: Deleted (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m3pgps5ctt0rdj2zq50g1dad",
    "deleted": true
  },
  "error": null
}
```

**Example: Already deleted (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "webhook_not_found",
    "message": "Webhook not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/postbacks/{id}/rotate-secret

Access: app session only (API keys and partner tokens get 403)

A new secret, shown once. For 24 hours every delivery carries two signatures (new and old); `previous_secret_expires_at` says when the old one stops.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

```bash
curl -X POST "https://api.cirrus.trade/v1/postbacks/<id>/rotate-secret" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `url` | `string` | yes |  |
| `events` | `string[]` | yes | One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `accounts` | `string[] \| null` | yes | null = every account. |
| `description` | `string \| null` | yes |  |
| `enabled` | `boolean` | yes |  |
| `disabled_reason` | `string \| null` | yes | `disabled_by_user`, `too_many_failures` or `failing_for_24_hours`. |
| `disabled_at` | `string \| null` | yes |  |
| `consecutive_failures` | `integer (int32)` | yes |  |
| `last_success_at` | `string \| null` | yes |  |
| `last_failure_at` | `string \| null` | yes |  |
| `previous_secret_expires_at` | `string \| null` | yes | After a rotation: until then deliveries are also signed with the previous secret. |
| `secret_rotated_at` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `trigger_updates` | `boolean` | no | Present (and `true`) only when `trigger_update` deliveries (v2 trigger changes) are on; absent otherwise. |
| `secret` | `string` | yes | The signing secret (`whsec_...`): verify each delivery's `webhook-signature` with it. Store it now: it is never shown again. |
| `note` | `string` | yes | A reminder that the secret is shown only once. |

**Example: A new secret; the old one signs for 24 hours (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m3pgps5ctt0rdj2zq50g1dad",
    "url": "http://127.0.0.1:63815/hooks/alerts",
    "events": [
      "account_alert"
    ],
    "accounts": null,
    "description": null,
    "enabled": true,
    "disabled_reason": null,
    "disabled_at": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": "2026-09-30T12:02:21.998Z",
    "secret_rotated_at": "2026-09-29T12:02:21.998Z",
    "created_at": "2026-09-29T12:02:21.996Z",
    "updated_at": "2026-09-29T12:02:21.998Z",
    "secret": "whsec_sesnDRH8eVMm3rJqiKEp0+QVa3wcLxUqhsXlqR2dqeQ=",
    "note": "Store the secret now: it is not shown again."
  },
  "error": null
}
```

**Example: No such webhook (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "webhook_not_found",
    "message": "Webhook not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/postbacks/{id}/test

Access: app session only (API keys and partner tokens get 403)

Send a signed `ping` now and report the result.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

```bash
curl -X POST "https://api.cirrus.trade/v1/postbacks/<id>/test" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event_id` | `string` | yes | The `ping` event's id (its `webhook-id` header). |
| `delivered` | `boolean` | yes | Whether the receiver answered 2xx. |
| `status_code` | `integer (int32) \| null` | yes | The receiver's HTTP status; null when no answer came. |
| `duration_ms` | `integer (int64)` | yes | How long the attempt took. |
| `error_kind` | `string \| null` | yes | Why it failed: `timeout`, `connect`, `blocked_address`, `http_status`, `redirect`...; null when delivered. |

**Example: The receiver answered 200 (200)**

```json
{
  "status": "success",
  "data": {
    "event_id": "evt_cc2f23548b726e8a6a8bf980be542540",
    "delivered": true,
    "status_code": 200,
    "duration_ms": 0,
    "error_kind": null
  },
  "error": null
}
```

**Example: No such webhook (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "webhook_not_found",
    "message": "Webhook not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/postbacks/{id}/deliveries

Access: app session only (API keys and partner tokens get 403)

Delivery attempts of the last 7 days, newest first. Payloads are not stored.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes | The webhook's `id`. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | no | Page size, 1-200 (default 50). |
| `cursor` | `string` | no | `next_cursor` of the previous page. |

```bash
curl "https://api.cirrus.trade/v1/postbacks/<id>/deliveries" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deliveries` | `object[]` | yes |  |
| `deliveries[].id` | `string` | yes | Attempt id (sorts by time). |
| `deliveries[].event_id` | `string` | yes | The event's id (its `webhook-id` header; the same on every retry). |
| `deliveries[].type` | `string` | yes | Event names. A receiver subscribes to any of `order_update` (any change to an order), `trade` (a fill), `account_alert` (something about an account that needs the user) and `positions` (an account's positions changed, at most once per second per account). `ping` is sent only by the webhook's test endpoint and cannot be subscribed to. One of: `order_update`, `trade`, `account_alert`, `positions`, `ping`. |
| `deliveries[].attempt` | `integer (int32)` | yes | 1 for the first attempt, then one more per retry. |
| `deliveries[].outcome` | `string` | yes | `delivered`, `retrying` (another attempt is scheduled), `failed` (no more attempts) or `dropped`. |
| `deliveries[].status_code` | `integer (int32) \| null` | yes | The receiver's HTTP status; null when no answer came. |
| `deliveries[].duration_ms` | `integer (int64)` | yes |  |
| `deliveries[].error_kind` | `string \| null` | yes | Why it failed: `timeout`, `connect`, `blocked_address`, `http_status`, `redirect`...; null when delivered. |
| `deliveries[].at` | `string` | yes | When the attempt was made (RFC 3339). |
| `next_cursor` | `string \| null` | yes | Pass as `cursor` for the next page; null on the last page. |

**Example: The test delivery (200)**

```json
{
  "status": "success",
  "data": {
    "deliveries": [
      {
        "id": "01M3PGPS5DF35SYE2PF2KKBM9V",
        "event_id": "evt_cc2f23548b726e8a6a8bf980be542540",
        "type": "ping",
        "attempt": 1,
        "outcome": "delivered",
        "status_code": 200,
        "duration_ms": 0,
        "error_kind": null,
        "at": "2026-09-29T12:02:21.997Z"
      }
    ],
    "next_cursor": null
  },
  "error": null
}
```

**Example: No such webhook (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "webhook_not_found",
    "message": "Webhook not found",
    "field": null
  }
}
```

**Example: At most 200 per page (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "limit must be 1 to 200",
    "field": "limit"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `webhook_not_found` | No webhook with this id for the caller. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `webhooks_disabled` | Webhooks are not enabled on this server. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Verifying webhooks

Every delivery is signed with your webhook's secret. Check the signature before trusting an event.

**Headers**

| Header | Value | Meaning |
| --- | --- | --- |
| `webhook-id` | string | The event `id`; the same on every retry. |
| `webhook-timestamp` | unix s | When this attempt was signed. |
| `webhook-signature` | v1,<base64> | During a secret rotation two, space separated. Accept when any one matches. |
| `content-type` | string | `application/json`. |
| `user-agent` | string | `<product>-Webhooks/1.0`. |

- The signature is `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))`, where `key` is the base64-decoded part of your secret after `whsec_`.
- Verify against the raw body, before any JSON parsing. Accept when any listed signature matches; compare in constant time.
- Reject timestamps more than 5 minutes from your clock, and ignore an `id` you already handled.
- Deliveries follow the Standard Webhooks specification, so its libraries (Node, Python, Go, Java, Ruby, PHP, Rust) verify them as they are.

Check your code against the specification's test vector: it must produce exactly that `webhook-signature`.

### Rotating the secret

- Rotate on API access or with `POST /v1/postbacks/{id}/rotate-secret`: the new secret is shown once.
- For the next 24 hours every delivery carries two signatures, new and old, so switch your receiver any time that day without missing an event. `previous_secret_expires_at` says when the old one stops.

**Node.js**

```js
const crypto = require("crypto");

function verifyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] || "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");
  return signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    return (
      version === "v1" &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    );
  });
}

// Express: verify the raw body, then parse it.
// app.post("/hooks/trading", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyWebhook(process.env.WEBHOOK_SECRET, req.headers, req.body.toString("utf8")))
//     return res.sendStatus(400);
//   const event = JSON.parse(req.body);
//   res.sendStatus(204);
// });
```

**Python**

```python
import base64, hashlib, hmac, time

def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers.get("webhook-signature", "")
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

**Test vector**

```
secret     whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek
timestamp  1614265330
body       {"test": 2432232314}
signature  v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
```

## Signal URLs

A signal URL lets TradingView, Chartink or Kuberhunt place orders for one of your strategies: `POST /v1/hooks/{provider}/{secret}`. The URL is the credential, so keep it private. Create one from a strategy in the app; manage them on API access or with these app-session routes.

### TradingView

- Set the alert’s webhook URL to your signal URL and its message to the JSON on the right. `entry` places the strategy’s legs; `exit` closes every leg the entries placed.
- `secret` is the `body_secret` from issue or rotate; missing or wrong answers 403. The same body within 5 seconds is a duplicate. Legs, product and accounts come from the strategy.

### Chartink

- `stocks` and `trigger_prices` are comma-separated (JSON arrays also work), read in the same order. Each stock becomes one LIMIT order at its trigger price plus the strategy’s buffer, on the tick.
- At most 50 stocks per alert; unknown stocks and stocks without a price are skipped. Chartink cannot sign requests: the URL is the only credential. The same alert within 10 minutes is a duplicate.

### Kuberhunt

- Signed with the webhook secret set on the strategy; the timestamp must be within ±300 s. `event_id` is deduplicated for 24 hours and `event_seq` must increase per `reco_id`.
- `reco.activated` enters (refused when older than 120 s); `reco.sl_hit`, `reco.target_reached`, `reco.exit` exit; `reco.invalidated`, `reco.cancelled` cancel; `reco.trail_updated`, `reco.updated` move the stop-loss or target.

### Signal bodies

#### TradingViewAlert

TradingView alert body, sent to `POST /v1/hooks/tradingview/{secret}`.
Put it in the alert's "Message" box as JSON. The URL secret and the
body `secret` must both match. Other fields are ignored. The same alert
arriving twice within 5 s is treated as one.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | TradingView alert type: `entry` places the strategy's legs, `exit` closes what the strategy's entries placed. Case and surrounding spaces are ignored (`"Entry "` works); any other type is acknowledged and ignored. One of: `entry`, `exit`. |
| `secret` | `string` | yes | The body secret (`tvs_...`) shown once when the URL was issued. A missing or wrong one is refused with 403. |

**Example**

```json
{
  "type": "entry",
  "secret": "tvs_3kq8v1n0x7c2m5z9"
}
```

#### ChartinkAlert

Chartink scanner alert body, sent by Chartink to
`POST /v1/hooks/chartink/{secret}`. Chartink cannot sign requests, so
the URL secret is the credential. Fields other than these are ignored
(`scan_url`, `webhook_url`, ...). The same alert (same `triggered_at`,
`stocks`, `scan_name`, `alert_name`) arriving again within 10 minutes is
treated as one.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `stocks` | `string \| (string \| number (double))[]` | yes | Stock symbols (NSE, else BSE), at most 50. Unknown symbols are skipped; the rest are still traded. |
| `trigger_prices` | `string \| (string \| number (double))[] \| null` | no | Trigger price of each stock, in the same order as `stocks`. A missing or zero price skips that stock. |
| `triggered_at` | `string \| null` | no | When the scan fired, as Chartink formats it (`"2:34 pm"`). |
| `scan_name` | `string \| null` | no |  |
| `alert_name` | `string \| null` | no |  |

**Example**

```json
{
  "stocks": "SBIN,RELIANCE",
  "trigger_prices": "523.4,2890",
  "triggered_at": "2:34 pm",
  "scan_name": "Breakouts",
  "alert_name": "Breakouts"
}
```

#### KuberhuntEvent

Kuberhunt event body, sent to `POST /v1/hooks/kuberhunt/{secret}` or
to the pre-v1 URL `POST /kuberhunt/execute-signal/{token}` (same body,
same headers; retired per strategy once its v1 URL has received an
accepted delivery, and answered with `Deprecation` / `Sunset` headers).

Headers: `X-Kuberhunt-Timestamp` (Unix seconds, within ±300 s of now)
and `X-Kuberhunt-Signature: v1=<hex HMAC-SHA256>` over
`{timestamp}.{raw body}`, keyed with the strategy's Kuberhunt webhook
secret. Every refusal is answered 200 (Kuberhunt retries any non-2xx);
only a failure worth retrying is answered 503. The same `event_id` is
handled once; an `event_seq` not above the last one seen for the
recommendation is skipped as stale.

**Fields**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | `string` | yes | Kuberhunt event name. `reco.activated` opens a position (entry); `reco.sl_hit`, `reco.target_reached` and `reco.exit` close it; `reco.invalidated` / `reco.cancelled` close it too; `reco.updated` / `reco.trail_updated` move its stop-loss / target; `reco.scheduled`, `reco.pending`, `webhook.test` and `reco.t1_reached` ... `reco.t5_reached` are acknowledged without action. Any other name is acknowledged and ignored. One of: `reco.activated`, `reco.scheduled`, `reco.pending`, `webhook.test`, `reco.trail_updated`, `reco.updated`, `reco.t1_reached`, `reco.t2_reached`, `reco.t3_reached`, `reco.t4_reached`, `reco.t5_reached`, `reco.sl_hit`, `reco.target_reached`, `reco.exit`, `reco.invalidated`, `reco.cancelled`. |
| `event_id` | `string \| number (double)` | yes | Delivery id (a string or a number); repeats are ignored for 24 h. |
| `event_seq` | `string \| number (double) \| null` | no | Per-recommendation sequence (an integer, a whole number, or a numeric string); anything else disables the ordering check. |
| `timestamp` | `string \| null` | no | When the event was produced (RFC 3339). An entry older than 120 s is skipped. |
| `reco` | `object` | yes | The recommendation an event is about. Prices may be numbers or numeric strings. `instrument_token` or its legacy name is accepted; send one (both with different values is refused). |
| `reco.reco_id` | `string \| number (double)` | yes | Recommendation id; every later event finds the position by it. An entry without one is skipped. |
| `reco.instrument_token` | `string \| number (double) \| null` | no | Instrument id to trade (a string, or an integer). An entry without one is skipped. |
| `reco.action` | `string \| null` | no | `BUY` or `SELL` (any case); anything else, or none, is `BUY`. |
| `reco.product` | `string \| null` | no | `INTRADAY` / `MIS` or `CARRYFORWARD` / `CNC`; used when the strategy mirrors the recommendation's product. |
| `reco.lower_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the lower price. |
| `reco.higher_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the higher price. |
| `reco.entry_price` | `string \| number (double) \| null` | no | Reference price (limit fallback, slippage reference). |
| `reco.stop_loss_price` | `string \| number (double) \| null` | no | Stop-loss price, placed when the strategy asks for it. |
| `reco.target_price` | `string \| number (double) \| null` | no | Target price, placed when the strategy asks for it. |
| `transition` | `any` | no | Provider detail about the state change; kept in the history, not used. |
| `meta` | `any` | no | Provider metadata; kept in the history, not used. |

**Example**

```json
{
  "event": "reco.activated",
  "event_id": "evt-1",
  "event_seq": 1,
  "timestamp": "2026-09-28T04:05:06.789+00:00",
  "reco": {
    "reco_id": "r1",
    "instrument_token": "CT:1:2:RELIANCE-EQ",
    "action": "BUY",
    "product": "INTRADAY",
    "lower_price": 2490,
    "entry_price": 2500,
    "stop_loss_price": 2450,
    "target_price": 2600
  }
}
```

### Replies and limits

**Replies**

| Status | Case | When |
| --- | --- | --- |
| `200` | received | The body says what happened. Most rejections are also 200, so providers that retry every non-2xx do not retry them; the deliveries list has the outcome. |
| `403` | TradingView | The body `secret` is missing or wrong. |
| `404` | unknown URL | Unknown, revoked or rotated URL, or the wrong provider. Every such case is the same. |
| `429` | rate limited | Over 10 requests a second on this URL. Not processed. |
| `503` | retry | A store was briefly unavailable, or a Kuberhunt entry / close can be retried. |

**Headers on every reply**

| Header | Value | Meaning |
| --- | --- | --- |
| `X-RateLimit-Limit` | integer | Requests allowed per second on this URL (10). |
| `X-RateLimit-Remaining` | integer | Left in the current second. |
| `Retry-After` | seconds | On 429 only. |
| `X-Request-Id` | string | Matches `request_id` in the deliveries list. |

### Rotate, deliveries and test

- Rotating gives a new URL with the same id and stops the old one at once on every server; revoking stops it for good.
- Deliveries of the last 7 days show each outcome with a plain reason and the order ids placed. Payloads are never stored.
- The dry run (test) checks a payload exactly like a real delivery and lists the orders it would place, without placing anything or changing any state.

**Delivery outcome**

| outcome | Orders | Meaning |
| --- | --- | --- |
| `placed` | orders | Every order it asked for was placed. |
| `partially_placed` | orders | Some orders were placed and some refused; `reason` gives the counts. |
| `failed` | none | Orders were attempted and none was placed (also when a store was briefly unavailable). |
| `processed` | none | Handled; no new orders needed (for example a stop-loss or target moved). |
| `ignored` | none | Valid, but the strategy’s rules meant nothing to do: paused, test mode, no open position, entries used up for the day… |
| `refused` | none | Wrong secret or signature, timestamp outside the window, or an unreadable payload. |
| `duplicate` | none | The same alert arrived again, or an older event after a newer one. |
| `rate_limited` | none | Over the URL’s rate limit. Recorded at most once a minute per URL. |

**Send a TradingView signal**

```bash
curl -X POST "https://api.cirrus.trade/v1/hooks/tradingview/<url-secret>" \
  -H "Content-Type: application/json" \
  -d '{"type": "entry", "secret": "tvs_xxxx"}'
```

**TradingView alert message**

```
// Alert message (JSON). "secret" is the body secret issued with the URL.
{"type": "entry", "secret": "tvs_…"}
{"type": "exit",  "secret": "tvs_…"}
```

**Chartink alert**

```json
{
  "stocks": "SBIN,RELIANCE",
  "trigger_prices": "812.5,2950.1",
  "triggered_at": "10:15 am",
  "scan_name": "Breakouts",
  "scan_url": "breakouts",
  "alert_name": "Breakout alert"
}
```

**Kuberhunt delivery**

```
X-Kuberhunt-Timestamp: 1759050000
X-Kuberhunt-Signature: v1=<hex HMAC-SHA256(secret, "{timestamp}.{raw body}")>

{
  "event": "reco.activated",
  "event_id": "evt_01J8…",
  "event_seq": 3,
  "timestamp": "2026-09-28T09:20:00Z",
  "reco": {
    "reco_id": "r-123",
    "action": "BUY",
    "product": "INTRADAY",
    "entry_price": 2500.0,
    "stop_loss_price": 2450.0,
    "target_price": 2600.0
  }
}
```

### POST /v1/hooks/{provider}/{secret}

Access: no authentication

The URL the provider calls. No `Authorization`: the secret in the URL is the credential (plus the TradingView body secret or the Kuberhunt signature). The body depends on the provider (see Signal bodies above).

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `string` | yes | The provider the URL was issued for. One of: `kuberhunt`, `tradingview`, `chartink`. |
| `secret` | `string` | yes | The URL secret (`wh_...`), from `path` when the URL was issued. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Kuberhunt-Signature` | `string` | no | Kuberhunt: `v1=<hex HMAC-SHA256>` of `{timestamp}.{raw body}`, keyed with the strategy's Kuberhunt webhook secret. |
| `X-Kuberhunt-Timestamp` | `string` | no | Kuberhunt: Unix seconds, within 300 s of now. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | When `type` is `TradingViewAlert`. TradingView alert type: `entry` places the strategy's legs, `exit` closes what the strategy's entries placed. Case and surrounding spaces are ignored (`"Entry "` works); any other type is acknowledged and ignored. One of: `entry`, `exit`. |
| `secret` | `string` | yes | When `type` is `TradingViewAlert`. The body secret (`tvs_...`) shown once when the URL was issued. A missing or wrong one is refused with 403. |
| `stocks` | `string \| (string \| number (double))[]` | yes | When `type` is `ChartinkAlert`. Stock symbols (NSE, else BSE), at most 50. Unknown symbols are skipped; the rest are still traded. |
| `trigger_prices` | `string \| (string \| number (double))[] \| null` | no | When `type` is `ChartinkAlert`. Trigger price of each stock, in the same order as `stocks`. A missing or zero price skips that stock. |
| `triggered_at` | `string \| null` | no | When `type` is `ChartinkAlert`. When the scan fired, as Chartink formats it (`"2:34 pm"`). |
| `scan_name` | `string \| null` | no | When `type` is `ChartinkAlert`. |
| `alert_name` | `string \| null` | no | When `type` is `ChartinkAlert`. |
| `event` | `string` | yes | When `type` is `KuberhuntEvent`. Kuberhunt event name. `reco.activated` opens a position (entry); `reco.sl_hit`, `reco.target_reached` and `reco.exit` close it; `reco.invalidated` / `reco.cancelled` close it too; `reco.updated` / `reco.trail_updated` move its stop-loss / target; `reco.scheduled`, `reco.pending`, `webhook.test` and `reco.t1_reached` ... `reco.t5_reached` are acknowledged without action. Any other name is acknowledged and ignored. One of: `reco.activated`, `reco.scheduled`, `reco.pending`, `webhook.test`, `reco.trail_updated`, `reco.updated`, `reco.t1_reached`, `reco.t2_reached`, `reco.t3_reached`, `reco.t4_reached`, `reco.t5_reached`, `reco.sl_hit`, `reco.target_reached`, `reco.exit`, `reco.invalidated`, `reco.cancelled`. |
| `event_id` | `string \| number (double)` | yes | When `type` is `KuberhuntEvent`. Delivery id (a string or a number); repeats are ignored for 24 h. |
| `event_seq` | `string \| number (double) \| null` | no | When `type` is `KuberhuntEvent`. Per-recommendation sequence (an integer, a whole number, or a numeric string); anything else disables the ordering check. |
| `timestamp` | `string \| null` | no | When `type` is `KuberhuntEvent`. When the event was produced (RFC 3339). An entry older than 120 s is skipped. |
| `reco` | `object` | yes | When `type` is `KuberhuntEvent`. The recommendation an event is about. Prices may be numbers or numeric strings. `instrument_token` or its legacy name is accepted; send one (both with different values is refused). |
| `reco.reco_id` | `string \| number (double)` | yes | Recommendation id; every later event finds the position by it. An entry without one is skipped. |
| `reco.instrument_token` | `string \| number (double) \| null` | no | Instrument id to trade (a string, or an integer). An entry without one is skipped. |
| `reco.action` | `string \| null` | no | `BUY` or `SELL` (any case); anything else, or none, is `BUY`. |
| `reco.product` | `string \| null` | no | `INTRADAY` / `MIS` or `CARRYFORWARD` / `CNC`; used when the strategy mirrors the recommendation's product. |
| `reco.lower_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the lower price. |
| `reco.higher_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the higher price. |
| `reco.entry_price` | `string \| number (double) \| null` | no | Reference price (limit fallback, slippage reference). |
| `reco.stop_loss_price` | `string \| number (double) \| null` | no | Stop-loss price, placed when the strategy asks for it. |
| `reco.target_price` | `string \| number (double) \| null` | no | Target price, placed when the strategy asks for it. |
| `transition` | `any` | no | When `type` is `KuberhuntEvent`. Provider detail about the state change; kept in the history, not used. |
| `meta` | `any` | no | When `type` is `KuberhuntEvent`. Provider metadata; kept in the history, not used. |

- Replies are made for the providers, not the REST envelope: 200 means received, whatever it came to (see the URL’s deliveries and the activity log); only 503 asks for the same delivery again.

```bash
curl -X POST "https://api.cirrus.trade/v1/hooks/<kind>/<secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "stocks": "TESTSU",
    "trigger_prices": "100.03",
    "triggered_at": "9:20 am",
    "scan_name": "Breakouts",
    "alert_name": "Breakout alert"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | `string` | yes | Every message a signal URL answers with status 200. The delivery was received; nothing more is promised (what it came to is in the URL's delivery log and the activity log). - `Webhook received`: handled (orders placed, closed, updated, or skipped by a strategy rule). - `Duplicate`: the same alert arrived twice; the copy was skipped. - `Stale`: an older Kuberhunt event arrived after a newer one. - `Signal is disabled` / `Strategy paused`: the strategy is paused. - `Strategy not found`: the strategy no longer exists. - `Invalid Payload` / `Invalid payload` / `Invalid JSON` / `Empty body`: the body could not be read. - `Invalid signature`, `Timestamp out of window`, `Webhook secret missing`: Kuberhunt signature checks. - `Only entry and exit signals are processed`: TradingView `type` was neither. - `Too many stocks in one alert`, `No valid stocks found`, `No accounts configured`: Chartink alerts that could not be traded. - `instrument_token ... differ; send one`, `order_tag ... differ; send one`, `exits ... differ; send one`: a field was sent under both its names with different values. - `Invalid ID`: the pre-v1 Kuberhunt URL's token is invalid. One of: `Webhook received`, `Duplicate`, `Stale`, `Signal is disabled`, `Strategy paused`, `Strategy not found`, `Invalid Payload`, `Invalid payload`, `Invalid JSON`, `Empty body`, `Invalid signature`, `Timestamp out of window`, `Webhook secret missing`, `Only entry and exit signals are processed`, `Too many stocks in one alert`, `No valid stocks found`, `No accounts configured`, `instrument_token and cirrus_token differ; send one`, `order_tag and cirrus_tag differ; send one`, `exits and cirrus_exits differ; send one`, `Invalid ID`. |

**Example: A Chartink alert placed its orders (200)**

```json
{
  "message": "Webhook received"
}
```

**Example: The same alert again: skipped (200)**

```json
{
  "message": "Duplicate"
}
```

**Example: A signed Kuberhunt entry placed its order (200)**

```json
{
  "message": "Webhook received"
}
```

**Example: A TradingView alert with the wrong body secret (403)**

```json
{
  "message": "Invalid secret"
}
```

**Example: An unknown or revoked URL (404)**

```json
{
  "status": "error",
  "message": "Unknown webhook"
}
```

**Example: Over the URL's rate limit (429)**

```json
{
  "status": "error",
  "message": "rate limited; retry in 988 ms"
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `—` | A TradingView alert with the wrong body secret |
| 403 | `oms_not_enabled` | Order management is not enabled for this user. |
| 404 | `—` | An unknown or revoked URL |
| 429 | `—` | Over the URL's rate limit |
| 503 | `—` | Temporarily unavailable; send the same delivery again later. |

### GET /v1/hooks/endpoints

Access: app session only (API keys and partner tokens get 403)

Your live URLs: `endpoint_id`, `kind`, `strategy_id`, `created_at`, `has_body_secret`. Never the secrets.

```bash
curl "https://api.cirrus.trade/v1/hooks/endpoints" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].endpoint_id` | `string` | yes | Public id (`we_...`), kept across rotations. |
| `[].kind` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `[].strategy_id` | `string` | yes | The strategy the URL's signals trade. |
| `[].created_at` | `string` | yes | When this URL (its current secret) was issued (RFC 3339). |
| `[].has_body_secret` | `boolean` | yes | TradingView: alerts must carry the body secret. |

**Example: The user's live URLs (200)**

```json
{
  "status": "success",
  "data": [
    {
      "endpoint_id": "we_6USUzj3Fc2VUfgXa",
      "kind": "tradingview",
      "strategy_id": "nifty-breakout-01M3PGPS2RED832PWZ7N5VS16P",
      "created_at": "2026-09-29T12:02:21.916811+00:00",
      "has_body_secret": true
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/hooks/endpoints

Access: app session only (API keys and partner tokens get 403)

Issue a URL for a strategy. Any older URL for the same strategy stops working.

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `string` | yes | The provider that will call the URL. One of: `kuberhunt`, `tradingview`, `chartink`. |
| `strategy_id` | `string` | yes | The strategy's id: 1-64 letters, digits, `-` or `_`. |

```bash
curl -X POST "https://api.cirrus.trade/v1/hooks/endpoints" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "tradingview",
    "strategy_id": "nifty-breakout-01M3PGPS2RED832PWZ7N5VS16P"
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | Public id (`we_...`), kept across rotations. |
| `kind` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `strategy_id` | `string` | yes |  |
| `created_at` | `string` | yes | When this URL was issued (RFC 3339). |
| `has_body_secret` | `boolean` | yes | TradingView: alerts must carry `body_secret`. |
| `path` | `string` | yes | The path to give the provider, prefixed with the API origin (`/v1/hooks/{kind}/wh_...`). It is the credential: keep it private. |
| `body_secret` | `string \| null` | yes | TradingView only: put `"secret": "<this>"` (`tvs_...`) in the alert message. Null for other providers. |
| `note` | `string` | yes | A reminder that the URL is shown only once. |

**Example: A TradingView URL (with a body secret) (201)**

```json
{
  "status": "success",
  "data": {
    "endpoint_id": "we_6USUzj3Fc2VUfgXa",
    "kind": "tradingview",
    "strategy_id": "nifty-breakout-01M3PGPS2RED832PWZ7N5VS16P",
    "created_at": "2026-09-29T12:02:21.916811+00:00",
    "has_body_secret": true,
    "path": "/v1/hooks/tradingview/wh_wDMkI4XGVWLhusCX9Bb0UatmpoJdHTLECRdRR9ggged",
    "body_secret": "tvs_bD1ZOvrvN4dKZrKmCkfTL7eg7i0LVOW0",
    "note": "Keep this URL private: anyone with it can send signals. Issuing a new one revokes this."
  },
  "error": null
}
```

**Example: An unknown provider is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown variant `telegram`, expected one of `kuberhunt`, `tradingview`, `chartink`",
    "field": null
  }
}
```

**Example: Strategy ids are letters, digits, - and _ (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "strategy_id must be 1-64 letters, digits, '-' or '_'",
    "field": "strategy_id"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/hooks/endpoints/{endpoint_id}/rotate

Access: app session only (API keys and partner tokens get 403)

A new URL (and TradingView body secret) with the same `endpoint_id`, shown once. The old URL stops working at once on every server.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL's `endpoint_id`. |

```bash
curl -X POST https://api.cirrus.trade/v1/hooks/endpoints/<endpoint_id>/rotate \
  -H "Authorization: Bearer <app session>"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | Public id (`we_...`), kept across rotations. |
| `kind` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `strategy_id` | `string` | yes |  |
| `created_at` | `string` | yes | When this URL was issued (RFC 3339). |
| `has_body_secret` | `boolean` | yes | TradingView: alerts must carry `body_secret`. |
| `path` | `string` | yes | The path to give the provider, prefixed with the API origin (`/v1/hooks/{kind}/wh_...`). It is the credential: keep it private. |
| `body_secret` | `string \| null` | yes | TradingView only: put `"secret": "<this>"` (`tvs_...`) in the alert message. Null for other providers. |
| `note` | `string` | yes | A reminder that the URL is shown only once. |

**Example: A new URL and body secret; the old URL is dead (200)**

```json
{
  "status": "success",
  "data": {
    "endpoint_id": "we_6USUzj3Fc2VUfgXa",
    "kind": "tradingview",
    "strategy_id": "nifty-breakout-01M3PGPS2RED832PWZ7N5VS16P",
    "created_at": "2026-09-29T12:02:21.926086+00:00",
    "has_body_secret": true,
    "path": "/v1/hooks/tradingview/wh_afgJybcPb09zIeVruYYtBlwm1paQkKo1VtryahqbOTB",
    "body_secret": "tvs_GKEskzlA7wA0oZ2TWs3ph7g0mHUeiPba",
    "note": "Give the provider this new URL now: the old one no longer works. It is shown only once."
  },
  "error": null
}
```

**Example: A revoked URL (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "endpoint_not_found",
    "message": "Webhook endpoint not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `endpoint_not_found` | No signal URL with this id (or this secret) for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/hooks/endpoints/{endpoint_id}

Access: app session only (API keys and partner tokens get 403)

Revoke the URL. It stops working at once; later calls on this id return 404.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL's `endpoint_id`. |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/hooks/endpoints/<endpoint_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes |  |
| `revoked` | `boolean` | yes | Always `true`. |

**Example: Revoked (200)**

```json
{
  "status": "success",
  "data": {
    "endpoint_id": "we_6USUzj3Fc2VUfgXa",
    "revoked": true
  },
  "error": null
}
```

**Example: Already revoked (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "endpoint_not_found",
    "message": "Webhook endpoint not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `endpoint_not_found` | No signal URL with this id (or this secret) for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/hooks/endpoints/{endpoint_id}/deliveries

Access: app session only (API keys and partner tokens get 403)

What the URL received in the last 7 days, newest first. Payloads are never stored.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL's `endpoint_id`. |

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `integer` | no | Page size, at least 1 (default 50; above 200 counts as 200). |
| `cursor` | `string` | no | `next_cursor` of the previous page. |

```bash
curl "https://api.cirrus.trade/v1/hooks/endpoints/<endpoint_id>/deliveries?limit=20" \
  -H "Authorization: Bearer <app session>"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | `object[]` | yes |  |
| `items[].delivery_id` | `string` | yes | Delivery id (sorts by arrival). |
| `items[].at` | `string (date-time)` | yes | When it arrived. |
| `items[].provider` | `string` | yes | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). One of: `kuberhunt`, `tradingview`, `chartink`. |
| `items[].outcome` | `string` | yes | A delivery's result in one word: `placed` (every order it asked for was placed), `partially_placed`, `failed` (orders were attempted, none placed), `processed` (handled without new orders: levels updated, lifecycle event...), `ignored` (valid, but the strategy's rules meant nothing to do), `refused` (wrong secret or signature, unreadable payload), `duplicate` (the same alert again; skipped) or `rate_limited` (over the URL's limit; not processed). One of: `placed`, `partially_placed`, `failed`, `processed`, `ignored`, `refused`, `duplicate`, `rate_limited`. |
| `items[].reason` | `string \| null` | yes | Why, in plain words; null when there is nothing to explain. |
| `items[].orders_placed` | `integer (int64)` | yes | Orders it placed (one per account slice). |
| `items[].order_ids` | `string[]` | yes | Broker order ids of the orders it placed. |
| `items[].http_status` | `integer (int32)` | yes | The HTTP status the provider was answered with. |
| `items[].request_id` | `string` | yes | The request's id, for support. |
| `next_cursor` | `string \| null` | yes | Pass as `cursor` for the next page; null on the last page. |

**Example: Newest first: rate limited, two duplicates, placed (200)**

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "delivery_id": "01M3PGPS5220JGR75R52C6M2XY",
        "at": "2026-09-29T12:02:21.986Z",
        "provider": "chartink",
        "outcome": "rate_limited",
        "reason": "too many alerts in a short time; extra ones were refused",
        "orders_placed": 0,
        "order_ids": [],
        "http_status": 429,
        "request_id": "01M3PGPS4YGSNDQW6RQGZXNDP9"
      },
      {
        "delivery_id": "01M3PGPS4YZ5CPAXJSB94WSXSQ",
        "at": "2026-09-29T12:02:21.982Z",
        "provider": "chartink",
        "outcome": "duplicate",
        "reason": "the same alert arrived twice; the copy was skipped",
        "orders_placed": 0,
        "order_ids": [],
        "http_status": 200,
        "request_id": "01M3PGPS4WS2X3TGFQEG16DYKD"
      },
      {
        "delivery_id": "01M3PGPS4WTGQ0EJZRCMW5RA52",
        "at": "2026-09-29T12:02:21.980Z",
        "provider": "chartink",
        "outcome": "duplicate",
        "reason": "the same alert arrived twice; the copy was skipped",
        "orders_placed": 0,
        "order_ids": [],
        "http_status": 200,
        "request_id": "01M3PGPS4V5PYNQGZ8ZC2A4FV2"
      },
      {
        "delivery_id": "01M3PGPS4VERYPXBJRKY6DFR6Y",
        "at": "2026-09-29T12:02:21.979Z",
        "provider": "chartink",
        "outcome": "placed",
        "reason": null,
        "orders_placed": 1,
        "order_ids": [
          "PAPER-1"
        ],
        "http_status": 200,
        "request_id": "01M3PGPS4KQG8PH1F4PJ1YC283"
      }
    ],
    "next_cursor": null
  },
  "error": null
}
```

**Example: No such signal URL (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "endpoint_not_found",
    "message": "Webhook endpoint not found",
    "field": null
  }
}
```

**Example: A cursor this route did not give (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "invalid cursor",
    "field": "cursor"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `invalid_query` | The query string does not fit the route's parameters. |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `endpoint_not_found` | No signal URL with this id (or this secret) for the caller. |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/hooks/endpoints/{endpoint_id}/test

Access: app session only (API keys and partner tokens get 403)

Dry run: the same checks, parsing, strategy rules and sizing as a real delivery, without placing orders or touching any state. An empty body checks a sample alert for the provider.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | `string` | yes | The signal URL's `endpoint_id`. |

**Headers**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Kuberhunt-Signature` | `string` | no | Kuberhunt only: `v1=<hex HMAC-SHA256>`; without it the signature is not checked (a warning says so). |
| `X-Kuberhunt-Timestamp` | `string` | no | Kuberhunt only: Unix seconds the signature was made at. |

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | yes | When `type` is `TradingViewAlert`. TradingView alert type: `entry` places the strategy's legs, `exit` closes what the strategy's entries placed. Case and surrounding spaces are ignored (`"Entry "` works); any other type is acknowledged and ignored. One of: `entry`, `exit`. |
| `secret` | `string` | yes | When `type` is `TradingViewAlert`. The body secret (`tvs_...`) shown once when the URL was issued. A missing or wrong one is refused with 403. |
| `stocks` | `string \| (string \| number (double))[]` | yes | When `type` is `ChartinkAlert`. Stock symbols (NSE, else BSE), at most 50. Unknown symbols are skipped; the rest are still traded. |
| `trigger_prices` | `string \| (string \| number (double))[] \| null` | no | When `type` is `ChartinkAlert`. Trigger price of each stock, in the same order as `stocks`. A missing or zero price skips that stock. |
| `triggered_at` | `string \| null` | no | When `type` is `ChartinkAlert`. When the scan fired, as Chartink formats it (`"2:34 pm"`). |
| `scan_name` | `string \| null` | no | When `type` is `ChartinkAlert`. |
| `alert_name` | `string \| null` | no | When `type` is `ChartinkAlert`. |
| `event` | `string` | yes | When `type` is `KuberhuntEvent`. Kuberhunt event name. `reco.activated` opens a position (entry); `reco.sl_hit`, `reco.target_reached` and `reco.exit` close it; `reco.invalidated` / `reco.cancelled` close it too; `reco.updated` / `reco.trail_updated` move its stop-loss / target; `reco.scheduled`, `reco.pending`, `webhook.test` and `reco.t1_reached` ... `reco.t5_reached` are acknowledged without action. Any other name is acknowledged and ignored. One of: `reco.activated`, `reco.scheduled`, `reco.pending`, `webhook.test`, `reco.trail_updated`, `reco.updated`, `reco.t1_reached`, `reco.t2_reached`, `reco.t3_reached`, `reco.t4_reached`, `reco.t5_reached`, `reco.sl_hit`, `reco.target_reached`, `reco.exit`, `reco.invalidated`, `reco.cancelled`. |
| `event_id` | `string \| number (double)` | yes | When `type` is `KuberhuntEvent`. Delivery id (a string or a number); repeats are ignored for 24 h. |
| `event_seq` | `string \| number (double) \| null` | no | When `type` is `KuberhuntEvent`. Per-recommendation sequence (an integer, a whole number, or a numeric string); anything else disables the ordering check. |
| `timestamp` | `string \| null` | no | When `type` is `KuberhuntEvent`. When the event was produced (RFC 3339). An entry older than 120 s is skipped. |
| `reco` | `object` | yes | When `type` is `KuberhuntEvent`. The recommendation an event is about. Prices may be numbers or numeric strings. `instrument_token` or its legacy name is accepted; send one (both with different values is refused). |
| `reco.reco_id` | `string \| number (double)` | yes | Recommendation id; every later event finds the position by it. An entry without one is skipped. |
| `reco.instrument_token` | `string \| number (double) \| null` | no | Instrument id to trade (a string, or an integer). An entry without one is skipped. |
| `reco.action` | `string \| null` | no | `BUY` or `SELL` (any case); anything else, or none, is `BUY`. |
| `reco.product` | `string \| null` | no | `INTRADAY` / `MIS` or `CARRYFORWARD` / `CNC`; used when the strategy mirrors the recommendation's product. |
| `reco.lower_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the lower price. |
| `reco.higher_price` | `string \| number (double) \| null` | no | Limit price for strategies entering at the higher price. |
| `reco.entry_price` | `string \| number (double) \| null` | no | Reference price (limit fallback, slippage reference). |
| `reco.stop_loss_price` | `string \| number (double) \| null` | no | Stop-loss price, placed when the strategy asks for it. |
| `reco.target_price` | `string \| number (double) \| null` | no | Target price, placed when the strategy asks for it. |
| `transition` | `any` | no | When `type` is `KuberhuntEvent`. Provider detail about the state change; kept in the history, not used. |
| `meta` | `any` | no | When `type` is `KuberhuntEvent`. Provider metadata; kept in the history, not used. |

- A Kuberhunt signature is checked only if you send the `X-Kuberhunt-Signature` and `X-Kuberhunt-Timestamp` headers; a TradingView body secret only if you send your own payload.

```bash
curl -X POST https://api.cirrus.trade/v1/hooks/endpoints/<endpoint_id>/test \
  -H "Authorization: Bearer <app session>" \
  -H "Content-Type: application/json" \
  -d '{"stocks": "SBIN", "trigger_prices": "812.5", "triggered_at": "10:15 am"}'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `valid` | `boolean` | yes | True when `errors` is empty: a real delivery would be acted on. |
| `errors` | `string[]` | yes | Why a real delivery would place nothing, in plain words. |
| `warnings` | `string[]` | yes | What it would skip, or could not check (e.g. an unsigned Kuberhunt test, a sample payload, test mode on the strategy). |
| `would_place` | `object[]` | yes | The orders it would place. |
| `would_place[].account` | `string` | yes | Account id. |
| `would_place[].symbol` | `string` | yes | Trading symbol. |
| `would_place[].exchange` | `string` | yes |  |
| `would_place[].side` | `string` | yes | Order side. One of: `BUY`, `SELL`. |
| `would_place[].qty` | `integer (int64)` | yes | Quantity (units, not lots). |
| `would_place[].order_type` | `string` | yes | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). One of: `MARKET`, `LIMIT`, `SL`, `SL_M`. |
| `would_place[].price` | `number (double)` | yes | Limit price (0 for market orders). |
| `would_place[].product` | `string` | yes | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). One of: `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`. |

**Example: A Chartink alert would place one order (200)**

```json
{
  "status": "success",
  "data": {
    "valid": true,
    "errors": [],
    "warnings": [],
    "would_place": [
      {
        "account": "P1",
        "symbol": "TESTSU-EQ",
        "exchange": "NSE",
        "side": "BUY",
        "qty": 3,
        "order_type": "LIMIT",
        "price": 100.55,
        "product": "MIS"
      }
    ]
  },
  "error": null
}
```

**Example: A TradingView alert with the wrong body secret would be refused (200)**

```json
{
  "status": "success",
  "data": {
    "valid": false,
    "errors": [
      "the alert's secret did not match this strategy"
    ],
    "warnings": [],
    "would_place": []
  },
  "error": null
}
```

**Example: No such signal URL (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "endpoint_not_found",
    "message": "Webhook endpoint not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `endpoint_not_found` | No signal URL with this id (or this secret) for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## API keys

Create and revoke the API keys scripts use (`Authorization: token <key_id>:<secret>`). Most people use API access in the app; these are the routes behind it.

### GET /v1/api-keys

Access: app session only (API keys and partner tokens get 403)

Your active API keys (revoked ones are not listed). Secrets are never returned.

```bash
curl "https://api.cirrus.trade/v1/api-keys" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].key_id` | `string` | yes | Public key id (`ck_...`): the part before `:` in `Authorization: token <key_id>:<key_secret>`. |
| `[].name` | `string` | yes | The owner's label for the key. |
| `[].scopes` | `string[]` | yes | What the key may do. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `[].ip_allowlist` | `string[]` | yes | IP addresses the key may be used from; empty = any. |
| `[].created_at` | `string` | yes | When the key was created (RFC 3339). |
| `[].last_used_at` | `string \| null` | yes | When the key was last used (RFC 3339, updated at most once a minute); null when never used. |

**Example: The user's active keys (200)**

```json
{
  "status": "success",
  "data": [
    {
      "key_id": "ck_KD6UFTQOmSAeXuOp1skp",
      "name": "Trading bot",
      "scopes": [
        "read",
        "orders"
      ],
      "ip_allowlist": [
        "203.0.113.7"
      ],
      "created_at": "2026-10-30T07:27:47.011712+00:00",
      "last_used_at": null
    }
  ],
  "error": null
}
```

**Example: No credential (401)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unauthorized",
    "message": "Missing access token",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/api-keys

Access: app session only (API keys and partner tokens get 403)

Create a key with the chosen scopes (and optionally an IP allowlist). The secret is in this reply only. At most 10 active keys.

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | A label for the key, 1-64 characters (trimmed). |
| `scopes` | `string[]` | yes | What the key may do; at least one. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `ip_allowlist` | `string[]` | no | IP addresses (v4 or v6) the key may be used from, at most 20; empty or absent = any. |

- An operator acting as the user gets 403 `own_session_required`.

```bash
curl -X POST https://api.cirrus.trade/v1/api-keys \
  -H "Authorization: Bearer <app session>" \
  -H "Content-Type: application/json" \
  -d '{"name": "my bot", "scopes": ["read", "orders"]}'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key_id` | `string` | yes | Public key id (`ck_...`). |
| `name` | `string` | yes |  |
| `scopes` | `string[]` | yes | One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `ip_allowlist` | `string[]` | yes | IP addresses the key may be used from; empty = any. |
| `created_at` | `string` | yes | When the key was created (RFC 3339). |
| `last_used_at` | `string \| null` | yes | Always null for a new key. |
| `api_secret` | `string` | yes | The key secret (`cs_...`). Store it now: it is never shown again. Authenticate with `Authorization: token <key_id>:<api_secret>`. |
| `note` | `string` | yes | A reminder that the secret is shown only once. |

**Example: A key for reads and orders from one IP (201)**

```json
{
  "status": "success",
  "data": {
    "key_id": "ck_KD6UFTQOmSAeXuOp1skp",
    "name": "Trading bot",
    "scopes": [
      "read",
      "orders"
    ],
    "ip_allowlist": [
      "203.0.113.7"
    ],
    "created_at": "2026-10-30T07:27:47.011712+00:00",
    "last_used_at": null,
    "api_secret": "cs_VitRnJ2TL0fQitoxmXL1yk0mhQo66fmZXe0VFNXL",
    "note": "Store the secret now: it is not shown again."
  },
  "error": null
}
```

**Example: An unknown scope is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown variant `admin`, expected one of `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`",
    "field": null
  }
}
```

**Example: An operator acting as the user cannot create keys (403)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "own_session_required",
    "message": "Only the account holder, signed in themselves, can do this",
    "field": null
  }
}
```

**Example: A key needs at least one scope (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "choose at least one scope",
    "field": "api_key"
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/api-keys/{key_id}

Access: app session only (API keys and partner tokens get 403)

Revoke a key at once: every server refuses it within a few seconds.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key_id` | `string` | yes | The key's `key_id` (`ck_...`). |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/api-keys/<key_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key_id` | `string` | yes |  |
| `revoked` | `boolean` | yes | Always `true`. |

**Example: Revoked (200)**

```json
{
  "status": "success",
  "data": {
    "key_id": "ck_KD6UFTQOmSAeXuOp1skp",
    "revoked": true
  },
  "error": null
}
```

**Example: Already revoked (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "api_key_not_found",
    "message": "API key not found",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `api_key_not_found` | No API key with this id for the caller. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Partner apps & connections

Register OAuth apps, rotate their secrets, and manage which apps may act for you. The consent screen uses the two `/v1/oauth` routes. See OAuth for partner apps for the whole flow.

### GET /v1/partner-apps

Access: app session only (API keys and partner tokens get 403)

The partner apps you registered, oldest first, disabled ones included.

```bash
curl "https://api.cirrus.trade/v1/partner-apps" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].client_id` | `string` | yes | OAuth client id (`cp_...`). |
| `[].owner` | `string` | yes | Username of the user who registered the app. |
| `[].name` | `string` | yes | Name shown to users on the consent screen. |
| `[].verified` | `boolean` | yes | Whether the operator has confirmed that the app's name belongs to its owner; never set through the API. Unverified apps are flagged on the consent screen. |
| `[].logo_url` | `string \| null` | yes | HTTPS URL of the app's logo. |
| `[].redirect_uris` | `string[]` | yes | Exact redirect URIs a user may be sent back to. |
| `[].scopes` | `string[]` | yes | The most a user can grant this partner. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `[].created_at` | `string` | yes | RFC 3339 time of registration. |
| `[].disabled` | `boolean` | yes | `true` once the owner disabled the app (its tokens stop working). |
| `[].webhook` | `object` | no | The app's webhook (never its secret); absent until one is set with `PATCH /v1/partner-apps/{client_id}`. |
| `[].webhook.url` | `string` | yes | The receiver: an `https://` URL on a public host. |
| `[].webhook.events` | `string[]` | yes | What it gets: `order_update` and `trade` for orders the app placed, `account_alert` for accounts of connected users, and `connection_revoked` when a user's connection ends. One of: `order_update`, `trade`, `account_alert`, `connection_revoked`. |
| `[].webhook.enabled` | `boolean` | yes | Whether deliveries are sent. |
| `[].webhook.disabled_reason` | `string \| null` | yes | Why it is off: `disabled_by_user`, `too_many_failures` (50 failed attempts in a row) or `failing_for_24_hours`; null while enabled. |
| `[].webhook.consecutive_failures` | `integer (int32)` | yes | Failed attempts in a row (any success resets it). |
| `[].webhook.last_success_at` | `string \| null` | yes |  |
| `[].webhook.last_failure_at` | `string \| null` | yes |  |
| `[].webhook.previous_secret_expires_at` | `string \| null` | yes | While set, deliveries are also signed with the previous secret (for 24 hours after a rotation). |
| `[].webhook.secret_rotated_at` | `string \| null` | yes |  |

**Example: The user's apps (200)**

```json
{
  "status": "success",
  "data": [
    {
      "client_id": "cp_FALC8i71s3F8cUwwozJ3",
      "owner": "user-01M65VA95TDN35YPSTZ49AQXKA",
      "name": "Charts Co",
      "verified": false,
      "logo_url": "https://partner.example/logo.png",
      "redirect_uris": [
        "https://partner.example/callback"
      ],
      "scopes": [
        "read",
        "orders"
      ],
      "created_at": "2026-10-30T07:27:47.011712+00:00",
      "disabled": false
    }
  ],
  "error": null
}
```

**Example: Partner access is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Partner access is not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/partner-apps

Access: app session only (API keys and partner tokens get 403)

Register an OAuth app (at most 10 active). The reply carries `client_secret` once. New apps are unverified.

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes | Name shown to users on the consent screen (1-80 characters). |
| `redirect_uris` | `string[]` | yes | 1-10 redirect URIs, matched exactly (no prefixes, no wildcards): `https://`, or `http://localhost` / `http://127.0.0.1` for development; no fragment, at most 512 characters. |
| `scopes` | `string[]` | yes | The most a user can grant the app (at least one). One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `logo_url` | `string \| null` | no | HTTPS URL of the app's logo. |

```bash
curl -X POST "https://api.cirrus.trade/v1/partner-apps" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Charts Co",
    "redirect_uris": [
      "https://partner.example/callback"
    ],
    "scopes": [
      "read",
      "orders"
    ],
    "logo_url": "https://partner.example/logo.png"
  }'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | OAuth client id (`cp_...`). |
| `owner` | `string` | yes | Username of the user who registered the app. |
| `name` | `string` | yes | Name shown to users on the consent screen. |
| `verified` | `boolean` | yes | Always `false` for a new app (see `PartnerApp.verified`). |
| `logo_url` | `string \| null` | yes | HTTPS URL of the app's logo. |
| `redirect_uris` | `string[]` | yes | Exact redirect URIs a user may be sent back to. |
| `scopes` | `string[]` | yes | The most a user can grant this partner. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `created_at` | `string` | yes | RFC 3339 time of registration. |
| `disabled` | `boolean` | yes | Always `false` for a new app. |
| `client_secret` | `string` | yes | The client secret (`cps_...`). Shown only in this response: store it now. Only its keyed hash is kept. |
| `note` | `string` | yes | A reminder to store the secret. |

**Example: Registered; the client secret is shown once (201)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_FALC8i71s3F8cUwwozJ3",
    "owner": "user-01M65VA95TDN35YPSTZ49AQXKA",
    "name": "Charts Co",
    "verified": false,
    "logo_url": "https://partner.example/logo.png",
    "redirect_uris": [
      "https://partner.example/callback"
    ],
    "scopes": [
      "read",
      "orders"
    ],
    "created_at": "2026-10-30T07:27:47.011712+00:00",
    "disabled": false,
    "client_secret": "cps_CwtJDmZDiRmYlwiK6Vysz7KlDl8VGnJ6vDK6H5avLE328NAl",
    "note": "Store the client secret now: it is not shown again."
  },
  "error": null
}
```

**Example: An unknown scope name is refused (400)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "malformed_json",
    "message": "invalid request body: unknown variant `admin`, expected one of `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`",
    "field": null
  }
}
```

**Example: A plain-HTTP redirect URI (not localhost) is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "redirect URI `http://partner.example/cb` must be https (http only for localhost), without a fragment",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `malformed_json` | The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 422 | `invalid_request` | The request is well-formed but not valid; `field` names the input. |
| 422 | `invalid_scope` | OAuth: a requested scope is unknown or not allowed for the app. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/partner-apps/{client_id}/rotate-secret

Access: app session only (API keys and partner tokens get 403)

A new client secret, shown once. The previous secret keeps working until `previous_secret_expires_at` (24 hours).

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id (`cp_...`). |

```bash
curl -X POST https://api.cirrus.trade/v1/partner-apps/<client_id>/rotate-secret \
  -H "Authorization: Bearer <app session>"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes |  |
| `client_secret` | `string` | yes | The new secret (`cps_...`); store it now, it is never shown again. |
| `previous_secret_expires_at` | `string (date-time)` | yes | Until when the previous secret is still accepted. |

**Example: New secret; the old one works for 24 hours (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_thoAw2SKE8Q8mv2sno0m",
    "client_secret": "cps_eu0PNR6P8S3lBc6aiWLr7fHtwZjdBiNSF9jmPF6zd9G1zosO",
    "previous_secret_expires_at": "2026-09-30T12:02:21Z"
  },
  "error": null
}
```

**Example: Not an active app of the user (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unknown_app",
    "message": "No such active app of yours",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `own_session_required` | Credentials can only be created from the user's own signed-in session. |
| 404 | `unknown_app` | Unknown or disabled partner app. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/partner-apps/{client_id}

Access: app session only (API keys and partner tokens get 403)

Disable one of your apps: every token of every connected user stops working at once. Cannot be undone.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id (`cp_...`). |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/partner-apps/<client_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes |  |
| `disabled` | `boolean` | yes | Always `true`. |

**Example: Disabled; its tokens stop working (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_FALC8i71s3F8cUwwozJ3",
    "disabled": true
  },
  "error": null
}
```

**Example: Already disabled (or not the user's app) (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unknown_app",
    "message": "No such active app of yours",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `unknown_app` | Unknown or disabled partner app. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/connections

Access: app session only (API keys and partner tokens get 403)

The partner apps you connected (live connections only).

```bash
curl "https://api.cirrus.trade/v1/connections" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].client_id` | `string` | yes | The partner app's client id. |
| `[].app_name` | `string \| null` | yes | The app's name; `null` when the app has since been disabled. |
| `[].verified` | `boolean` | yes | Whether the app is verified (see `PartnerApp.verified`). |
| `[].dynamic` | `boolean` | no | `true` when the app registered itself dynamically (an MCP client, see `PartnerApp.dynamic`); `false` for classic partner apps and for a disabled app. |
| `[].scopes` | `string[]` | yes | Scopes the user granted. One of: `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists`. |
| `[].connected_at` | `string` | yes | RFC 3339 time of the first connection. |
| `[].updated_at` | `string` | yes | RFC 3339 time of the latest grant. |
| `[].last_used_at` | `string \| null` | yes | RFC 3339 time of the app's latest request, refreshed at most once a minute; `null` when it has made none (or none in the last 90 days). |
| `[].calls_today` | `integer (int32)` | yes | Requests the app made today (IST day) that were not refused by its rate limit. |
| `[].revoked_at` | `string \| null` | no | RFC 3339 time the connection was revoked; absent while it is live. Revoked connections are listed for 30 days. |

**Example: One connected app (200)**

```json
{
  "status": "success",
  "data": [
    {
      "client_id": "cp_ZY0ZTp7e7G4JH2oGkHgr",
      "app_name": "Charts Co",
      "verified": false,
      "dynamic": false,
      "scopes": [
        "read",
        "orders"
      ],
      "connected_at": "2026-10-30T14:25:07.285908+00:00",
      "updated_at": "2026-10-30T14:25:07.285908+00:00",
      "last_used_at": null,
      "calls_today": 0
    }
  ],
  "error": null
}
```

**Example: Partner access is off on this server (503)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "service_unavailable",
    "message": "Partner access is not enabled on this server",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/connections/{client_id}

Access: app session only (API keys and partner tokens get 403)

Disconnect an app: its access tokens stop at once and its refresh tokens are deleted.

**Path parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The app's client id (`cp_...`). |

```bash
curl -X DELETE "https://api.cirrus.trade/v1/connections/<client_id>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes |  |
| `revoked` | `boolean` | yes | Always `true`. |

**Example: Disconnected; every token of the app for this user dies (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_Ay2T4dfVLByf9ct7XzAH",
    "revoked": true
  },
  "error": null
}
```

**Example: No live connection to that app (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "connection_not_found",
    "message": "No live connection to that app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 401 | `unauthorized` | No credential, or the credential is invalid, expired or revoked. |
| 403 | `forbidden` | The credential may not use this route (missing scope, app-session-only route, IP not allowed). |
| 404 | `connection_not_found` | No connected partner app with this client id. |
| 429 | `rate_limited` | Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Values reference

Every set of allowed values in requests, responses, stream messages and webhook bodies, from the OpenAPI spec. Values are case-sensitive; send them exactly as listed. Where a field is documented, its values also show next to it.

| Name | Values | Description | Used in |
| --- | --- | --- | --- |
| `AccountReadiness` | `ready`, `login_required`, `setup_incomplete`, `unsupported` | Whether the account can trade now: `ready`, `login_required` (log in to the broker again), `setup_incomplete` (a required field is missing, see `missing`) or `unsupported` (broker not supported for trading). | `GET /v1/accounts response items[].status`, `GET /v1/accounts/{account} response status` |
| `AccountStatus` | `placed`, `done`, `rejected`, `failed`, `unknown` | What happened in one account: `placed` (accepted by the broker and working), `done` (completed), `rejected` (refused by the broker or exchange), `failed` (could not be sent, or did not complete) or `unknown` (no answer in time; the broker may or may not have it). | `GET /v1/activity response items[].accounts[].status`, `GET /v1/activity/{id} response accounts[].status`, `stream activity data.accounts[].status` |
| `AccountUpdates` | `stream`, `postback`, `polling` | How order updates arrive right now: `stream` (the broker's live stream), `postback` (the broker calls back) or `polling` (the order book is read every few seconds). | `GET /v1/accounts response items[].updates`, `GET /v1/accounts/{account} response updates` |
| `ActivityCategory` | `orders`, `protection`, `signals`, `account`, `access`, `strategies` | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account`, `access` or `strategies`. | `GET /v1/activity response items[].category`, `GET /v1/activity/{id} response category`, `stream activity data.category` |
| `ActivityDependencyOutcome` | `filled`, `partial`, `failed`, `timeout` | How a dependent order's wait for its parent ended: `filled` (the parent filled; this order was sent), `partial` (sent for the parent's filled share, `depends_on_partial: filled_qty`), `failed` (the parent ended unfilled or was not placed) or `timeout` (the wait's deadline passed). `failed` and `timeout` reject the order with `dependency_failed`. | `GET /v1/activity response items[].depends_on.outcome`, `GET /v1/activity/{id} response depends_on.outcome`, `stream activity data.depends_on.outcome` |
| `ActivityKind` | `place`, `bracket`, `modify`, `cancel`, `convert`, `order_filled`, `order_partially_filled`, `order_rejected`, `order_cancelled_by_broker`, `external_order`, `order_unknown`, `gtt_create`, `gtt_modify`, `gtt_delete`, `gtt_triggered`, `trigger_create`, `trigger_modify`, `trigger_delete`, `trigger_hit`, `trigger_exit`, `protection_armed`, `protection_failed`, `signal`, `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `portfolio_refresh`, `account_setup_incomplete`, `api_key_created`, `api_key_revoked`, `partner_connected`, `partner_revoked`, `partner_app_disabled`, `partner_app_secret_rotated`, `connection_limits_changed`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`, `strategy_created`, `strategy_updated`, `strategy_archived`, `strategy_version_saved`, `deployment_started`, `deployment_paused`, `deployment_resumed`, `deployment_stopped`, `deployment_went_live`, `deployment_exit_now`, `strategies_stop_all`, `backtest_submitted`, `risk_limits_changed`, `basket_saved`, `basket_deleted` | What happened. Serialized snake_case (`order_filled`, `gtt_create`...). | `GET /v1/activity response items[].kind`, `GET /v1/activity/{id} response kind`, `stream activity data.kind` |
| `ActivityOutcome` | `ok`, `partial`, `failed` | `ok` (every account succeeded), `partial` or `failed` (none did). | `GET /v1/activity response items[].outcome`, `GET /v1/activity/{id} response outcome`, `webhook Something about an account needs the user data.outcome`, `stream activity data.outcome` |
| `ActivityPageSource` | `hot`, `archive` | Where a page was read from: `hot` (the live store) or `archive` (the day's archive, for older days). | `GET /v1/activity response source` |
| `ActivitySourceType` | `app`, `api_key`, `partner`, `signal`, `trigger`, `broker`, `system` | Who or what started it: `app` (signed-in app session), `api_key`, `partner`, `signal` (a signal URL), `trigger` (a stop-loss / target trigger), `broker` (the broker or exchange) or `system` (housekeeping). | `GET /v1/activity response items[].source.type`, `GET /v1/activity/{id} response source.type`, `stream activity data.source.type` |
| `ActivityVia` | `app`, `api`, `mcp` | The channel a request came in through: `app` (signed-in app session), `api` (an API key or partner token) or `mcp` (an AI agent through an MCP connection). | `GET /v1/activity response items[].source.via`, `GET /v1/activity/{id} response source.via`, `stream activity data.source.via` |
| `AutoCreditKind` | `grant`, `purchase`, `sale`, `unknown` |  | `GET /v2/credits response entries[].kind` |
| `AutoCyclePhase` | `waiting`, `armed`, `entering`, `open`, `managing`, `adjusting`, `exiting`, `cooldown`, `finished`, `unknown` | Cycle phase (§5), projected from engine events. | `GET /v2/deployments/{deployment_id}/cycles response [].phase`, `GET /v2/deployments/{deployment_id}/cycles/{day}/{n} response [].cycle.phase` |
| `AutoDecision` | `would_wait`, `would_enter`, `would_skip`, `cannot_decide`, `invalid` | What the strategy would do right now. | `POST /v2/strategies/dry-run response decision` |
| `AutoDeploymentStatus` | `active`, `paused`, `error`, `stopped`, `unknown` | Stored deployment and unit status (§3.2). | `GET /v2/strategies/{strategy_id}/deployments response [].status`, `GET /v2/strategies/{strategy_id}/deployments response [].units[].status`, `POST /v2/strategies/{strategy_id}/deployments response status`, `POST /v2/strategies/{strategy_id}/deployments response units[].status`, `GET /v2/deployments/{deployment_id} response status`, `GET /v2/deployments/{deployment_id} response units[].status`, `POST /v2/deployments/{deployment_id}/pause response status`, `POST /v2/deployments/{deployment_id}/pause response units[].status`, `POST /v2/deployments/{deployment_id}/resume response status`, `POST /v2/deployments/{deployment_id}/resume response units[].status`, `POST /v2/deployments/{deployment_id}/stop response status`, `POST /v2/deployments/{deployment_id}/stop response units[].status`, `POST /v2/deployments/{deployment_id}/exit-now response status`, `POST /v2/deployments/{deployment_id}/exit-now response units[].status`, `POST /v2/deployments/{deployment_id}/go-live response status`, `POST /v2/deployments/{deployment_id}/go-live response units[].status`, `GET /v1/connections/{client_id}/left response deployments[].status`, `GET /v1/connections/{client_id}/left response deployments[].units[].status` |
| `AutoDisplayStatus` | `draft`, `active`, `paused`, `stopped`, `error` | The strategy's display status, derived from its deployments (§3.2). | `GET /v2/strategies response [].display_status`, `POST /v2/strategies response display_status`, `GET /v2/strategies/{strategy_id} response display_status`, `PATCH /v2/strategies/{strategy_id} response display_status`, `DELETE /v2/strategies/{strategy_id} response display_status`, `POST /v2/strategies/{strategy_id}/clone response display_status`, `POST /v2/strategies/import response strategy.display_status`, `POST /v2/gallery/{listing_id}/copy response strategy.display_status`, `POST /v2/gallery/{listing_id}/purchase response copy.strategy.display_status` |
| `AutoGalleryBadge` | `paper`, `live`, `unknown` | Whose results the performance is (decision 2), shown as a badge. | `POST /v2/strategies/{strategy_id}/gallery request badge`, `POST /v2/strategies/{strategy_id}/gallery response listing.badge`, `GET /v2/gallery response [].badge`, `GET /v2/gallery/mine response [].listing.badge`, `GET /v2/gallery/{listing_id} response badge`, `DELETE /v2/gallery/{listing_id} response listing.badge`, `GET /v2/strategies/{strategy_id}/gallery/eligibility response badges[].badge` |
| `AutoIntentAction` | `deploy`, `pause`, `resume`, `stop`, `exit_now`, `unknown` | What the user last asked of a deployment. | `GET /v2/strategies/{strategy_id}/deployments response [].last_intent.action`, `POST /v2/strategies/{strategy_id}/deployments response last_intent.action`, `GET /v2/deployments/{deployment_id} response last_intent.action`, `POST /v2/deployments/{deployment_id}/pause response last_intent.action`, `POST /v2/deployments/{deployment_id}/resume response last_intent.action`, `POST /v2/deployments/{deployment_id}/stop response last_intent.action`, `POST /v2/deployments/{deployment_id}/exit-now response last_intent.action`, `POST /v2/deployments/{deployment_id}/go-live response last_intent.action`, `GET /v1/connections/{client_id}/left response deployments[].last_intent.action` |
| `AutoMarginMethod` | `defined_risk`, `contract_value` | What a margin estimate is based on. | `POST /v2/strategies/dry-run response margin_estimate.method` |
| `AutoMode` | `paper`, `live`, `unknown` | Paper (forward test on the Paper account) or live money. | `GET /v2/strategies/{strategy_id}/deployments response [].mode`, `POST /v2/strategies/{strategy_id}/deployments response mode`, `GET /v2/deployments/{deployment_id} response mode`, `POST /v2/deployments/{deployment_id}/pause response mode`, `POST /v2/deployments/{deployment_id}/resume response mode`, `POST /v2/deployments/{deployment_id}/stop response mode`, `POST /v2/deployments/{deployment_id}/exit-now response mode`, `POST /v2/deployments/{deployment_id}/go-live response mode`, `GET /v1/connections/{client_id}/left response deployments[].mode` |
| `AutoModeInput` | `paper`, `live` | Deploy mode to check against (default `paper`). | `POST /v2/strategies/validate request mode`, `POST /v2/strategies/{strategy_id}/deployments request mode` |
| `AutoShareLinkAction` | `create`, `revoke` | `create` a new link (the old one stops working) or `revoke` it. | `PUT /v2/strategies/{strategy_id}/sharing request link` |
| `AutoSignalAction` | `enter`, `exit`, `exit_legs` |  | `POST /v2/deployments/{deployment_id}/signals request action` |
| `AutoStockCheckState` | `clear`, `blocked`, `unknown` | The outcome of one check. | `GET /v2/strategies/stock-status response stocks[].new_entries`, `GET /v2/strategies/stock-status response stocks[].fno_ban.state`, `GET /v2/strategies/stock-status response stocks[].ex_date.state`, `GET /v2/strategies/stock-status response stocks[].circuit.state` |
| `AutoVisibility` | `private`, `users`, `link`, `public`, `unknown` | Who may see a strategy (§24.1). Only `private` is writable in phase A. | `POST /v2/strategies response visibility`, `GET /v2/strategies/{strategy_id} response visibility`, `PATCH /v2/strategies/{strategy_id} response visibility`, `DELETE /v2/strategies/{strategy_id} response visibility`, `POST /v2/strategies/{strategy_id}/clone response visibility`, `POST /v2/strategies/import response strategy.visibility`, `PUT /v2/strategies/{strategy_id}/sharing request visibility`, `PUT /v2/strategies/{strategy_id}/sharing response visibility`, `DELETE /v2/strategies/{strategy_id}/sharing response visibility`, `POST /v2/gallery/{listing_id}/copy response strategy.visibility`, `POST /v2/gallery/{listing_id}/purchase response copy.strategy.visibility` |
| `BacktestChargesPreset` | `india`, `zero` | Which charges a backtest applies. | `GET /v2/backtests response [].costs.charges`, `POST /v2/backtests request costs.charges`, `POST /v2/backtests response costs.charges`, `GET /v2/backtests/{backtest_id} response costs.charges`, `POST /v2/backtests/{backtest_id}/cancel response costs.charges`, `GET /v2/backtests/sweeps response [].costs.charges`, `POST /v2/backtests/sweeps request costs.charges`, `POST /v2/backtests/sweeps response costs.charges`, `GET /v2/backtests/sweeps/{sweep_id} response costs.charges`, `DELETE /v2/backtests/sweeps/{sweep_id} response costs.charges` |
| `BacktestPurpose` | `entry`, `exit` | What a fill was for. | `GET /v2/backtests/{backtest_id}/trades response items[].purpose` |
| `BacktestStatus` | `queued`, `running`, `done`, `failed`, `cancelled`, `unknown` | Where a backtest is. | `GET /v2/backtests response [].status`, `POST /v2/backtests response status`, `GET /v2/backtests/{backtest_id} response status`, `POST /v2/backtests/{backtest_id}/cancel response status`, `GET /v2/backtests/sweeps response [].status`, `GET /v2/backtests/sweeps response [].versions[].status`, `POST /v2/backtests/sweeps response status`, `POST /v2/backtests/sweeps response versions[].status`, `GET /v2/backtests/sweeps/{sweep_id} response status`, `GET /v2/backtests/sweeps/{sweep_id} response versions[].status`, `DELETE /v2/backtests/sweeps/{sweep_id} response status`, `DELETE /v2/backtests/sweeps/{sweep_id} response versions[].status` |
| `BracketOrderKind` | `BO`, `CO` | `BO` (bracket: entry + stop-loss + target) or `CO` (cover: entry + stop-loss). | `POST /v1/orders/bracket request kind`, `PATCH /v1/orders/bracket/{order_id} request kind` |
| `ChaseOutcome` | `running`, `filled`, `gave_up`, `cancelled`, `stopped` | `running` (still re-pricing), `filled`, `gave_up` (stopped at its attempts or slippage bound and left resting, or its cancel failed), `cancelled` (stopped and cancelled as asked) or `stopped` (the order was changed, cancelled or rejected outside the chase). | `GET /v1/orders/{order_id} response chase_progress.outcome`, `GET /v1/connections/{client_id}/left response orders[].chase_progress.outcome`, `webhook An order changed data.chase_progress.outcome`, `stream order_update data.chase_progress.outcome` |
| `ConditionCheckOn` | `close`, `tick` | How often the condition is checked. | `POST /v2/strategies/condition-values request on`, `POST /v2/strategies/condition-values response on` |
| `ConditionPlace` | `entry`, `exit` | Which part of the strategy the condition belongs to. | `POST /v2/strategies/condition-values request place` |
| `ConnectionRevokedReason` | `user_revoked`, `refresh_token_replay`, `signed_out_everywhere` | Why a connection ended. | `webhook A user's connection to your partner app ended (partner app webhooks only) data.reason` |
| `DependsOnPartial` | `reject`, `filled_qty` | What a dependent order does when its parent filled only part of its quantity by the end of the wait. | `POST /v1/orders request orders[].depends_on_partial`, `POST /v1/margins/orders request orders[].depends_on_partial` |
| `ErrorCode` | `malformed_json`, `invalid_query`, `bad_request`, `idempotency_key_required`, `invalid_idempotency_key`, `unauthorized`, `forbidden`, `own_session_required`, `live_not_enabled`, `oms_not_enabled`, `not_shared`, `account_not_found`, `instrument_not_found`, `basket_not_found`, `watchlist_not_found`, `trigger_not_found`, `gone`, `api_key_not_found`, `connection_not_found`, `unknown_app`, `endpoint_not_found`, `webhook_not_found`, `telegram_not_linked`, `activity_not_found`, `strategy_not_found`, `version_not_found`, `shared_not_found`, `listing_not_found`, `deployment_not_found`, `backtest_not_found`, `schema_not_found`, `order_not_found`, `request_in_progress`, `trigger_busy`, `trigger_fired`, `broker_held`, `watchlist_name_taken`, `webhook_limit_reached`, `conflict`, `already_flat`, `stale_quote`, `bracket_position`, `position_protected`, `order_not_open`, `ambiguous_order`, `strategy_busy`, `strategy_in_use`, `deployment_finished`, `deployment_limit_reached`, `backtest_limit_reached`, `backtest_finished`, `no_saved_version`, `not_enough_cycles`, `listing_pending`, `purchase_required`, `insufficient_credits`, `purchase_failed`, `purchase_in_progress`, `not_flat`, `payload_too_large`, `invalid_request`, `invalid_document`, `not_deployable`, `acknowledgement_required`, `idempotency_key_reused`, `partial_lot_position`, `not_supported_by_broker`, `gtt_refused`, `archive_day_too_large`, `invalid_client`, `invalid_grant`, `invalid_client_metadata`, `unsupported_grant_type`, `slow_down`, `invalid_scope`, `invalid_target`, `temporarily_unavailable`, `rate_limited`, `broker_refused`, `broker_outcome_unknown`, `service_unavailable`, `credits_unavailable`, `instruments_unavailable`, `trigger_worker_down`, `webhooks_disabled`, `notifications_disabled`, `unavailable` | Stable error code: branch on this, never on the message. \| code \| HTTP status \| meaning \| \|---\|---\|---\| \| `malformed_json` \| 400 \| The body is not JSON, or does not fit the request shape (unknown or missing fields, wrong types). \| \| `invalid_query` \| 400 \| The query string does not fit the route's parameters. \| \| `bad_request` \| 400 \| The request could not be read before reaching the route (e.g. an unreadable path segment); `message` says what. \| \| `idempotency_key_required` \| 400 \| The `Idempotency-Key` header is missing (use a new UUID per order request). \| \| `invalid_idempotency_key` \| 400 \| The `Idempotency-Key` header is not 1-128 printable characters. \| \| `unauthorized` \| 401 \| No credential, or the credential is invalid, expired or revoked. \| \| `forbidden` \| 403 \| The credential may not use this route (missing scope, app-session-only route, IP not allowed). \| \| `own_session_required` \| 403 \| Credentials can only be created from the user's own signed-in session. \| \| `live_not_enabled` \| 403 \| Live deployments need the `strategies_live` scope and a broker open for live automated strategies. \| \| `oms_not_enabled` \| 403 \| Order management is not enabled for this user. \| \| `not_shared` \| 403 \| That user has not shared this data with the caller. \| \| `account_not_found` \| 404 \| No linked broker account with this id for the caller. \| \| `instrument_not_found` \| 404 \| No instrument with this token (unknown or expired). \| \| `basket_not_found` \| 404 \| No saved basket with this id for the caller. \| \| `watchlist_not_found` \| 404 \| No watchlist with this id for the caller. \| \| `trigger_not_found` \| 404 \| No trigger with this id for the caller. \| \| `gone` \| 410 \| The route has been retired (after its `Sunset` date); `message` names its replacement. \| \| `api_key_not_found` \| 404 \| No API key with this id for the caller. \| \| `connection_not_found` \| 404 \| No connected partner app with this client id. \| \| `unknown_app` \| 404 \| Unknown or disabled partner app. \| \| `endpoint_not_found` \| 404 \| No signal URL with this id (or this secret) for the caller. \| \| `webhook_not_found` \| 404 \| No webhook with this id for the caller. \| \| `telegram_not_linked` \| 404 \| Telegram is not connected for the caller; start a link first. \| \| `activity_not_found` \| 404 \| No activity record with this id for the caller. \| \| `strategy_not_found` \| 404 \| No automated strategy with this id for the caller. \| \| `version_not_found` \| 404 \| No such version of the strategy. \| \| `shared_not_found` \| 404 \| Nothing is shared with the caller here: the share was stopped or the link revoked, or it never existed. \| \| `listing_not_found` \| 404 \| No such gallery listing for the caller: withdrawn, taken down, not approved yet (and not theirs), or it never existed. \| \| `deployment_not_found` \| 404 \| No deployment with this id for the caller. \| \| `backtest_not_found` \| 404 \| No backtest with this id for the caller (results are kept 30 days). \| \| `schema_not_found` \| 404 \| No such strategy document format on this server. \| \| `order_not_found` \| 404 \| No order with this id (broker order id or `order_tag`) in the caller's order book today. \| \| `request_in_progress` \| 409 \| A request with this `Idempotency-Key` is still being processed. \| \| `trigger_busy` \| 409 \| The trigger changed while it was being updated; retry. \| \| `trigger_fired` \| 409 \| The trigger already fired; its exit is being handled. \| \| `broker_held` \| 409 \| The protection is held by the broker; change the broker order instead. \| \| `watchlist_name_taken` \| 409 \| The caller already has a watchlist with this name. \| \| `webhook_limit_reached` \| 409 \| The webhook limit is reached; delete one first. \| \| `conflict` \| 409 \| A partner app's webhook was changed at the same time as this request; retry. \| \| `already_flat` \| 409 \| There is no open position to exit for this account, instrument and product. \| \| `stale_quote` \| 422 \| This order cannot be sent at market: there is no price for the instrument at all (a price that is not recent is used: the order goes out as a LIMIT protected from it), or the best bid / ask is too far from the last traded price (a thin book); the message says which. Place a LIMIT order instead. Nothing was sent to the broker. \| \| `bracket_position` \| 409 \| The position is held by a bracket / cover order: exit it with `DELETE /v1/orders/bracket/{order_id}`, which squares it off at the broker. Nothing was placed. \| \| `position_protected` \| 409 \| The order is a (partly) filled bracket / cover entry: cancelling it would cancel the live stop-loss / target legs of its position. Send `legs=true` to do it anyway. \| \| `order_not_open` \| 409 \| The order is already finished (filled, cancelled or rejected), so there is nothing to modify or cancel. Nothing was sent to the broker. \| \| `ambiguous_order` \| 409 \| The id names orders in more than one account (or several orders); `message` lists the accounts. Add `account=` to choose one. \| \| `strategy_busy` \| 409 \| The strategy changed since it was read (or is archived); read it again and retry. \| \| `strategy_in_use` \| 409 \| The strategy has a deployment that is not stopped; stop it first. \| \| `deployment_finished` \| 409 \| The deployment is stopped; deploy the strategy again to run it. \| \| `deployment_limit_reached` \| 409 \| The most deployments that may run at once are running; stop one first. \| \| `backtest_limit_reached` \| 409 \| The most backtests that may be queued or running at once, or started today, are reached. \| \| `backtest_finished` \| 409 \| The backtest has already finished (done, failed or cancelled); nothing to cancel. \| \| `no_saved_version` \| 409 \| A strategy is shared as a saved version, never its draft: save a version first. \| \| `not_enough_cycles` \| 409 \| A gallery listing needs at least 20 finished trades of the version in the author's runs with the chosen badge. \| \| `listing_pending` \| 409 \| A listing of this strategy is already waiting for review; withdraw it first. \| \| `purchase_required` \| 409 \| A paid gallery template must be bought before it is copied. \| \| `insufficient_credits` \| 409 \| Not enough platform credits for this purchase; nothing was charged. \| \| `purchase_failed` \| 409 \| A gallery purchase did not complete and nothing was charged; retry with a new Idempotency-Key. \| \| `purchase_in_progress` \| 409 \| A gallery purchase with this Idempotency-Key is still being made; retry with the same key. \| \| `not_flat` \| 409 \| A signal strategy's mode can change only when flat: a position held by the signal strategy, or an open trade on the deployment, must close first. Nothing was changed. \| \| `payload_too_large` \| 413 \| The request body is larger than the server accepts; send less (e.g. fewer orders per request). \| \| `invalid_request` \| 422 \| The request is well-formed but not valid; `field` names the input. \| \| `invalid_document` \| 422 \| The strategy document has problems; `message` lists each (where in the wizard, what is wrong) and `field` names the first. \| \| `not_deployable` \| 422 \| The strategy version cannot be deployed yet (a component or option not released, or an underlying not open); `message` says why. \| \| `acknowledgement_required` \| 422 \| A warning must be acknowledged to deploy (e.g. `unlimited_loss`): add its code to `acknowledge`. \| \| `idempotency_key_reused` \| 422 \| This `Idempotency-Key` was already used with a different request. \| \| `partial_lot_position` \| 422 \| The open position (less exits already working) is not a whole number of lots, so it cannot be fully exited through this route; `message` says how much remains. \| \| `not_supported_by_broker` \| 422 \| The account's broker does not offer this (e.g. margin calculation, GTT listing, or GTT on BFO / intraday at Pocketful). \| \| `gtt_refused` \| 422 \| The broker refused the GTT protecting a position and `rules.fallback` is `reject`: nothing was created. \| \| `archive_day_too_large` \| 422 \| That day's archived activity is too large to serve at once. \| \| `invalid_client` \| 422 \| OAuth: unknown partner app, or wrong client credentials. \| \| `invalid_grant` \| 422 \| OAuth: the authorization code or refresh token is invalid, used or expired. \| \| `invalid_client_metadata` \| 400 \| OAuth client registration: a client metadata value is not accepted (e.g. a `client_name` that is not printable ASCII). \| \| `unsupported_grant_type` \| 400 \| OAuth token endpoint: `grant_type` is missing or not supported. \| \| `slow_down` \| 429 \| OAuth token endpoint: too many failed attempts from this source; retry after `Retry-After`. \| \| `invalid_scope` \| 422 \| OAuth: a requested scope is unknown or not allowed for the app. \| \| `invalid_target` \| 422 \| OAuth (RFC 8707): `resource` is the MCP server, which only clients registered with `POST /v1/oauth/register` may connect through. \| \| `temporarily_unavailable` \| 503 \| OAuth: the authorization server is temporarily unavailable. \| \| `rate_limited` \| 429 \| Too many requests for this credential (or too many failed attempts); retry after the `Retry-After` header (seconds; `error.retry_after_ms` has the exact wait). \| \| `broker_refused` \| 502 \| The broker refused the change. \| \| `broker_outcome_unknown` \| 504 \| The broker did not answer in time: it may or may not have applied the change. Check before retrying. \| \| `service_unavailable` \| 503 \| A dependency is temporarily unavailable, or the feature is off on this server; retry later. \| \| `credits_unavailable` \| 503 \| Platform credits can't be reached right now; nothing was charged. Retry. \| \| `instruments_unavailable` \| 503 \| The instrument master is not loaded yet; retry shortly. \| \| `trigger_worker_down` \| 503 \| No trigger exit worker is running; nothing was created. \| \| `webhooks_disabled` \| 503 \| Webhooks are not enabled on this server. \| \| `notifications_disabled` \| 503 \| Notifications are not enabled on this server. \| \| `unavailable` \| 503 \| Stream only: live updates are unavailable; reconnect. \| |  |
| `ErrorStatus` | `error` | Always `error`. |  |
| `Fallback` | `trigger`, `reject` | What to do when the broker refuses the native protection (bracket / cover order): `trigger` (default) places the order plainly, protected by a server-side trigger; `reject` refuses the order with the broker's reason instead. For a GTT refused once the entry filled, `reject` means no server-side trigger either: the fill is reported unprotected. For a GTT protecting an existing position (`POST /v1/triggers`), `reject` creates nothing (422 `gtt_refused`). | `POST /v1/orders request orders[].protection.fallback`, `POST /v1/margins/orders request orders[].protection.fallback` |
| `kind` | `BO`, `CO` | `BO` or `CO`. | `DELETE /v1/orders/bracket/{order_id} query kind` |
| `kind` | `orders`, `positions`, `holdings`, `trades`, `margins` | Which data: `orders`, `positions`, `holdings`, `trades` or `margins`. | `GET /v1/portfolio/{kind} path kind` |
| `kind` | `kuberhunt`, `tradingview`, `chartink` | The provider the URL was issued for. | `POST /v1/hooks/{kind}/{secret} path kind` |
| `KuberhuntEventName` | `reco.activated`, `reco.scheduled`, `reco.pending`, `webhook.test`, `reco.trail_updated`, `reco.updated`, `reco.t1_reached`, `reco.t2_reached`, `reco.t3_reached`, `reco.t4_reached`, `reco.t5_reached`, `reco.sl_hit`, `reco.target_reached`, `reco.exit`, `reco.invalidated`, `reco.cancelled` | Kuberhunt event name. `reco.activated` opens a position (entry); `reco.sl_hit`, `reco.target_reached` and `reco.exit` close it; `reco.invalidated` / `reco.cancelled` close it too; `reco.updated` / `reco.trail_updated` move its stop-loss / target; `reco.scheduled`, `reco.pending`, `webhook.test` and `reco.t1_reached` ... `reco.t5_reached` are acknowledged without action. Any other name is acknowledged and ignored. | `POST /v1/hooks/endpoints/{endpoint_id}/test request event`, `POST /v1/hooks/{kind}/{secret} request event`, `signal KuberhuntEvent event` |
| `LegInstrument` | `option`, `future`, `stock` | Kind of instrument a leg trades. | `POST /v1/baskets request legs[].instrument`, `GET /v1/baskets/{basket_id} response legs[].instrument`, `PUT /v1/baskets/{basket_id} request legs[].instrument` |
| `LegRole` | `stop_loss`, `target`, `square_off` | For an exit leg of a bracket / cover order: `stop_loss`, `target` or the broker's `square_off` (its entry is `parent_tag`); absent for every other order. | `GET /v1/orders/{order_id} response leg_role`, `GET /v1/connections/{client_id}/left response orders[].leg_role`, `webhook An order changed data.leg_role`, `stream order_update data.leg_role` |
| `MarginSource` | `broker`, `estimate` | Where a margin figure comes from. | `POST /v1/margins/orders response results[].source`, `POST /v1/margins/orders response accounts[].source` |
| `MissingCode` | `no_session`, `not_supported`, `stale` | Why an account has no data, as a stable code. | `GET /v1/portfolio/{kind} response missing[].code`, `GET /v1/shared/consolidated response owners[].accounts[].missing.positions.code`, `GET /v1/shared/consolidated response owners[].accounts[].missing.margins.code`, `GET /v1/shared/consolidated response owners[].accounts[].missing.holdings.code`, `GET /v1/shared/consolidated/{view} response missing[].code`, `GET /v1/shared/{owner}/{view} response missing[].code`, `stream missing code` |
| `NotificationBrokenReason` | `blocked_by_user`, `chat_not_found` | Why a channel stopped working for a user. | `GET /v1/notifications/channels response telegram.broken_reason`, `POST /v1/notifications/telegram/test response broken_reason` |
| `NotificationEvent` | `trigger_fired`, `trigger_refused`, `trigger_ended`, `exit_placed`, `exit_filled`, `exit_refused`, `order_filled`, `order_rejected`, `order_unconfirmed`, `protection_failed`, `entry_failed`, `strategy_entered`, `strategy_exited`, `risk_breached`, `session_expired`, `live_updates`, `account_setup`, `webhook_disabled`, `daily_summary`, `channel_broken`, `strategy_armed`, `strategy_refused`, `strategy_safety_pause`, `strategy_kill_switch`, `strategy_error` | An event type a user can route. Stored in preferences (Mongo) and sent in notices: values are only ever added (readers first). | `GET /v1/notifications response notifications[].event`, `GET /v1/notifications/events response [].event`, `webhook A notification (webhooks chosen in notification preferences only) data.event`, `stream notification data.event` |
| `NotificationEventGroup` | `triggers`, `orders`, `strategies`, `errors`, `account`, `summary` | Filter group shown in the app. | `GET /v1/notifications/events response [].group` |
| `NotificationPromptKind` | `connect_telegram`, `telegram_broken`, `webhook_failing` | Something the app should show the user. | `GET /v1/notifications/channels response prompts[].kind` |
| `NotificationScopeMode` | `all`, `selected` | Which notices a channel gets. | `GET /v1/notifications/preferences response telegram.scope.mode`, `GET /v1/notifications/preferences response app_push.scope.mode`, `GET /v1/notifications/preferences response webhook.scope.mode`, `PUT /v1/notifications/preferences request telegram.scope.mode`, `PUT /v1/notifications/preferences request app_push.scope.mode`, `PUT /v1/notifications/preferences request webhook.scope.mode`, `PUT /v1/notifications/preferences response telegram.scope.mode`, `PUT /v1/notifications/preferences response app_push.scope.mode`, `PUT /v1/notifications/preferences response webhook.scope.mode` |
| `NotificationSeverity` | `info`, `warning`, `critical` | How much a notice needs the user. | `GET /v1/notifications response notifications[].severity`, `GET /v1/notifications/channels response prompts[].severity`, `webhook A notification (webhooks chosen in notification preferences only) data.severity`, `stream notification data.severity` |
| `OAuthErrorCode` | `invalid_request`, `invalid_grant`, `invalid_client`, `temporarily_unavailable`, `unsupported_grant_type`, `slow_down` | Token endpoint error codes (RFC 6749 section 5.2) and their status: `invalid_request` (400), `invalid_grant` (400), `invalid_client` (401), `temporarily_unavailable` (503), `unsupported_grant_type` (400), `slow_down` (429, with `Retry-After`). |  |
| `OAuthGrantType` | `authorization_code`, `refresh_token` | Supported `grant_type` values; any other is refused with `invalid_request`. | `POST /v1/oauth/token request grant_type` |
| `OnGiveUp` | `rest`, `cancel` | What happens to a still-open order when the chase ends unfilled. | `POST /v1/orders request orders[].chase.on_give_up`, `GET /v1/orders/{order_id} response chase.on_give_up`, `POST /v1/margins/orders request orders[].chase.on_give_up`, `GET /v1/connections/{client_id}/left response orders[].chase.on_give_up`, `webhook An order changed data.chase.on_give_up`, `stream order_update data.chase.on_give_up` |
| `OptionKind` | `CE`, `PE` | Option right. | `POST /v1/baskets request legs[].option_type`, `GET /v1/baskets/{basket_id} response legs[].option_type`, `PUT /v1/baskets/{basket_id} request legs[].option_type` |
| `OrderState` | `SUBMITTED`, `OPEN`, `TRIGGER_PENDING`, `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED`, `UNKNOWN` | Order state: `SUBMITTED` (sent, not yet acknowledged), `OPEN`, `TRIGGER_PENDING` (stop-loss waiting for its trigger), `PARTIALLY_FILLED`, `FILLED`, `CANCELLED`, `REJECTED`, `EXPIRED` (day order ended unfilled) or `UNKNOWN` (not confirmed yet; never a guessed terminal state). | `GET /v1/orders/{order_id} response state`, `GET /v1/orders/{order_id} response status_history[].from`, `GET /v1/orders/{order_id} response status_history[].to`, `GET /v1/connections/{client_id}/left response orders[].state`, `GET /v1/connections/{client_id}/left response orders[].status_history[].from`, `GET /v1/connections/{client_id}/left response orders[].status_history[].to`, `webhook An order changed data.state`, `webhook An order changed data.status_history[].from`, `webhook An order changed data.status_history[].to`, `webhook An order filled (fully or partly) data.order_state`, `stream order_update data.state`, `stream order_update data.status_history[].from`, `stream order_update data.status_history[].to` |
| `OrderType` | `MARKET`, `LIMIT`, `SL`, `SL_M` | `MARKET`, `LIMIT`, `SL` (stop-loss limit) or `SL_M` (stop-loss market; `SL-M` and `SLM` are accepted in requests). | `POST /v1/orders request orders[].order_type`, `POST /v1/orders response results[].order_type`, `GET /v1/orders/{order_id} response order_type`, `PATCH /v1/orders/{order_id} request order_type`, `POST /v1/margins/orders request orders[].order_type`, `POST /v1/orders/bracket request order_type`, `POST /v1/positions/exit response results[].order_type`, `POST /v1/hooks/endpoints/{endpoint_id}/test response would_place[].order_type`, `GET /v1/connections/{client_id}/left response orders[].order_type`, `POST /v1/baskets request legs[].order_type`, `GET /v1/baskets/{basket_id} response legs[].order_type`, `PUT /v1/baskets/{basket_id} request legs[].order_type`, `webhook An order changed data.order_type`, `webhook An order filled (fully or partly) data.order_type`, `stream order_update data.order_type` |
| `PartnerWebhookEvent` | `order_update`, `trade`, `account_alert`, `connection_revoked` | Events a partner app's webhook can get (documentation names). | `GET /v1/partner-apps response [].webhook.events`, `PATCH /v1/partner-apps/{client_id} request webhook_events`, `PATCH /v1/partner-apps/{client_id} response webhook.events` |
| `PortfolioKind` | `positions`, `holdings`, `trades`, `margins` | Portfolio data kinds kept as per-account snapshots. | `stream portfolio kind` |
| `Product` | `MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF` | `MIS` (intraday), `CARRYFORWARD` (overnight derivatives, NRML), `DELIVERY` (equity delivery, CNC) or `MTF` (margin trading facility). | `POST /v1/orders request orders[].product`, `GET /v1/orders/{order_id} response product`, `PATCH /v1/orders/{order_id} request product`, `POST /v1/margins/orders request orders[].product`, `POST /v1/orders/bracket request product`, `POST /v1/positions/convert request from`, `POST /v1/positions/convert request to`, `POST /v1/positions/exit request product`, `POST /v1/positions/exit response product`, `GET /v1/gtt response [].gtts[].product`, `POST /v1/gtt request product`, `PATCH /v1/gtt/{trigger_id} request product`, `GET /v2/triggers response [].legs[].product`, `POST /v2/triggers request legs[].product`, `GET /v2/triggers/{trigger_id} response legs[].product`, `POST /v2/triggers/validate request legs[].product`, `GET /v2/triggers/capabilities response products[].products`, `GET /v1/strategies response strategies[].accounts[].rows[].product`, `GET /v1/strategies/{name} response strategy.accounts[].rows[].product`, `POST /v1/hooks/endpoints/{endpoint_id}/test response would_place[].product`, `GET /v1/connections/{client_id}/left response orders[].product`, `GET /v1/connections/{client_id}/left response gtt[].product`, `GET /v1/connections/{client_id}/left response triggers[].legs[].product`, `GET /v1/shared/consolidated response positions[].product`, `webhook An order changed data.product`, `webhook An order filled (fully or partly) data.product`, `webhook An account's positions changed data.positions[].product`, `stream order_update data.product` |
| `ProtectionRoute` | `bracket`, `cover`, `gtt`, `trigger`, `stop_order` | Where an order's protection lives: a broker `bracket` or `cover` order placed with the entry, a broker `gtt` placed once the entry fills, a server-side `trigger` armed once the entry fills, or a broker `stop_order` (a stop-loss order resting at the broker) protecting an existing intraday position. | `POST /v1/orders response results[].protection`, `GET /v1/orders/{order_id}/protection response route`, `POST /v1/positions/exit response results[].protection`, `GET /v2/triggers response [].route`, `POST /v2/triggers response route`, `GET /v2/triggers/{trigger_id} response route`, `PATCH /v2/triggers/{trigger_id} response route`, `DELETE /v2/triggers/{trigger_id} response route`, `POST /v2/triggers/{trigger_id}/pause response route`, `POST /v2/triggers/{trigger_id}/resume response route`, `POST /v2/triggers/validate response route`, `GET /v1/connections/{client_id}/left response triggers[].route` |
| `RequestedRoute` | `auto`, `bracket`, `cover`, `gtt`, `trigger`, `stop_order` | Where the caller wants the protection: `auto` (default) picks the broker's own order type first and a server-side trigger otherwise; `bracket`, `cover`, `gtt`, `stop_order` (an existing intraday position only) or `trigger` asks for that route, and the order is refused before the broker when the account or order cannot use it. If the broker itself then refuses the bracket / cover (or the GTT once the entry fills), `fallback` decides: `trigger` (default) protects with a server-side trigger instead, `reject` does not. A stop order the broker refuses is always replaced by a server-side trigger (the position is never left unprotected). | `POST /v1/orders request orders[].protection.route`, `POST /v1/margins/orders request orders[].protection.route`, `POST /v2/triggers request route`, `PATCH /v2/triggers/{trigger_id} request route`, `POST /v2/triggers/validate request route`, `POST /v2/triggers/{trigger_id}/validate request route` |
| `RuleType` | `percentage`, `points`, `price` | How a rule's `value` is read: `percentage` of the entry price, `points` from it, or an absolute `price`. Case-insensitive in requests. | `POST /v1/orders request orders[].protection.target.type`, `POST /v1/orders request orders[].protection.stop_loss.type`, `POST /v1/orders request orders[].protection.trail.type`, `POST /v1/margins/orders request orders[].protection.target.type`, `POST /v1/margins/orders request orders[].protection.stop_loss.type`, `POST /v1/margins/orders request orders[].protection.trail.type` |
| `Scope` | `read`, `orders`, `triggers`, `strategies`, `strategies_live`, `strategies_signal`, `webhooks`, `notifications`, `watchlists` | What an API key or partner token may do: `read` (portfolio, orders, triggers, GTT and activity reads; the stream), `orders` (place, modify, cancel and convert orders, bracket orders, GTT), `triggers` (create, change and remove server-side triggers), `strategies` (create, change and delete automated strategies and their paper deployments), `strategies_live` (run strategies live, with `strategies`), `strategies_signal` (only post signals to a deployment), `webhooks` (create, change, delete, test webhook receivers and rotate their secrets), `notifications` (change notification preferences) or `watchlists` (create, change and delete watchlists). All but the first three are opt-in; reads need `read`. Partner apps may not hold `strategies_signal`, `webhooks`, `notifications` or `watchlists`, and may hold `strategies` and `strategies_live` only where the tenant enables agent access. App sessions may do everything. | `GET /v1/user/profile response auth.scopes`, `GET /v1/api-keys response [].scopes`, `POST /v1/api-keys request scopes`, `POST /v1/api-keys response scopes`, `GET /v1/partner-apps response [].scopes`, `POST /v1/partner-apps request scopes`, `POST /v1/partner-apps response scopes`, `PATCH /v1/partner-apps/{client_id} response scopes`, `GET /v1/connections response [].scopes` |
| `SharedSkipReason` | `not_shared`, `unavailable` | Why an owner sharing with the caller is left out of a consolidated view. | `GET /v1/shared/consolidated/{view} response skipped_owners[].reason` |
| `ShareScope` | `login`, `funds`, `positions`, `holdings`, `open_orders`, `order_history` | What an owner shared: `login` (account list and login status), `funds`, `positions`, `holdings`, `open_orders`, `order_history`. | `GET /v1/shared response [].scopes`, `GET /v1/shared/consolidated response owners[].scopes` |
| `Side` | `BUY`, `SELL` | Order side. | `POST /v1/orders request orders[].side`, `GET /v1/orders/{order_id} response side`, `PATCH /v1/orders/{order_id} request side`, `POST /v1/margins/orders request orders[].side`, `POST /v1/orders/bracket request side`, `PATCH /v1/orders/bracket/{order_id} request side`, `POST /v1/positions/convert request side`, `POST /v1/positions/exit response side`, `GET /v1/gtt response [].gtts[].side`, `POST /v1/gtt request side`, `PATCH /v1/gtt/{trigger_id} request side`, `GET /v2/triggers response [].legs[].side`, `POST /v2/triggers request legs[].side`, `GET /v2/triggers/{trigger_id} response legs[].side`, `POST /v2/triggers/validate request legs[].side`, `GET /v2/backtests/{backtest_id}/trades response items[].side`, `GET /v1/activity response items[].side`, `GET /v1/activity response items[].depends_on.side`, `GET /v1/activity/{id} response side`, `GET /v1/activity/{id} response depends_on.side`, `POST /v1/hooks/endpoints/{endpoint_id}/test response would_place[].side`, `GET /v1/connections/{client_id}/left response orders[].side`, `GET /v1/connections/{client_id}/left response gtt[].side`, `GET /v1/connections/{client_id}/left response triggers[].legs[].side`, `POST /v1/baskets request legs[].side`, `GET /v1/baskets/{basket_id} response legs[].side`, `PUT /v1/baskets/{basket_id} request legs[].side`, `webhook An order changed data.side`, `webhook An order filled (fully or partly) data.side`, `stream order_update data.side`, `stream activity data.side`, `stream activity data.depends_on.side` |
| `SignalAckMessage` | `Webhook received`, `Duplicate`, `Stale`, `Signal is disabled`, `Strategy paused`, `Strategy not found`, `Invalid Payload`, `Invalid payload`, `Invalid JSON`, `Empty body`, `Invalid signature`, `Timestamp out of window`, `Webhook secret missing`, `Only entry and exit signals are processed`, `Too many stocks in one alert`, `No valid stocks found`, `No accounts configured`, `instrument_token and cirrus_token differ; send one`, `order_tag and cirrus_tag differ; send one`, `exits and cirrus_exits differ; send one`, `Invalid ID` | Every message a signal URL answers with status 200. The delivery was received; nothing more is promised (what it came to is in the URL's delivery log and the activity log). - `Webhook received`: handled (orders placed, closed, updated, or skipped by a strategy rule). - `Duplicate`: the same alert arrived twice; the copy was skipped. - `Stale`: an older Kuberhunt event arrived after a newer one. - `Signal is disabled` / `Strategy paused`: the strategy is paused. - `Strategy not found`: the strategy no longer exists. - `Invalid Payload` / `Invalid payload` / `Invalid JSON` / `Empty body`: the body could not be read. - `Invalid signature`, `Timestamp out of window`, `Webhook secret missing`: Kuberhunt signature checks. - `Only entry and exit signals are processed`: TradingView `type` was neither. - `Too many stocks in one alert`, `No valid stocks found`, `No accounts configured`: Chartink alerts that could not be traded. - `instrument_token ... differ; send one`, `order_tag ... differ; send one`, `exits ... differ; send one`: a field was sent under both its names with different values. - `Invalid ID`: the pre-v1 Kuberhunt URL's token is invalid. | `POST /v1/hooks/{kind}/{secret} response message` |
| `SignalDeliveryOutcome` | `placed`, `partially_placed`, `failed`, `processed`, `ignored`, `refused`, `duplicate`, `rate_limited` | A delivery's result in one word: `placed` (every order it asked for was placed), `partially_placed`, `failed` (orders were attempted, none placed), `processed` (handled without new orders: levels updated, lifecycle event...), `ignored` (valid, but the strategy's rules meant nothing to do), `refused` (wrong secret or signature, unreadable payload), `duplicate` (the same alert again; skipped) or `rate_limited` (over the URL's limit; not processed). | `GET /v1/hooks/endpoints/{endpoint_id}/deliveries response items[].outcome` |
| `SignalErrorStatus` | `error` | Always `error`. |  |
| `SignalForbiddenMessage` | `Invalid secret` | Messages answered with 403 (`Invalid secret`: the TradingView body secret is missing or wrong). |  |
| `SignalMode` | `direct`, `algo`, `unknown` | Where a Signals strategy's alerts go. | `GET /v2/signal-modes response [].mode`, `PUT /v2/signal-modes/{endpoint_id} request mode`, `PUT /v2/signal-modes/{endpoint_id} response mode` |
| `SignalProvider` | `kuberhunt`, `tradingview`, `chartink` | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). | `GET /v2/signal-modes response [].kind`, `PUT /v2/signal-modes/{endpoint_id} response kind`, `GET /v1/hooks/endpoints response [].kind`, `POST /v1/hooks/endpoints request kind`, `POST /v1/hooks/endpoints response kind`, `POST /v1/hooks/endpoints/{endpoint_id}/rotate response kind`, `GET /v1/hooks/endpoints/{endpoint_id}/deliveries response items[].provider` |
| `SignalRetryMessage` | `temporarily unavailable`, `Order handling failed; please redeliver`, `signal strategies unavailable` | Messages answered with 503; the provider should send the delivery again: `temporarily unavailable` (a store is down), `Order handling failed; please redeliver` (a Kuberhunt entry or close failed in a way worth retrying; its de-duplication was undone), `signal strategies unavailable` (strategies cannot be read on this server). |  |
| `SliceReason` | `rate_limited`, `broker_rejected`, `validation`, `no_session`, `dependency_failed`, `protection_refused`, `broker_timeout`, `stale_quote` | Why a slice was not accepted, in machine-readable form. | `POST /v1/orders response results[].reason`, `PATCH /v1/orders/{order_id} response reason`, `PATCH /v1/orders/{order_id} response split_orders[].reason`, `DELETE /v1/orders/{order_id} response reason`, `DELETE /v1/orders/{order_id} response split_orders[].reason`, `POST /v1/orders/bracket response reason`, `PATCH /v1/orders/bracket/{order_id} response reason`, `DELETE /v1/orders/bracket/{order_id} response reason`, `POST /v1/positions/convert response reason`, `POST /v1/positions/exit response results[].reason`, `POST /v1/gtt response reason`, `PATCH /v1/gtt/{trigger_id} response reason`, `DELETE /v1/gtt/{trigger_id} response reason` |
| `SliceStatus` | `accepted`, `rejected`, `unknown` | `accepted` (the broker took it; fills arrive on the stream), `rejected` (refused before or by the broker; nothing was placed) or `unknown` (the broker may or may not have it, e.g. a timeout; reconciliation settles it: never retry blindly). | `POST /v1/orders response results[].status`, `PATCH /v1/orders/{order_id} response status`, `PATCH /v1/orders/{order_id} response split_orders[].status`, `DELETE /v1/orders/{order_id} response status`, `DELETE /v1/orders/{order_id} response split_orders[].status`, `POST /v1/orders/bracket response status`, `PATCH /v1/orders/bracket/{order_id} response status`, `DELETE /v1/orders/bracket/{order_id} response status`, `POST /v1/positions/convert response status`, `POST /v1/positions/exit response results[].status`, `POST /v1/gtt response status`, `PATCH /v1/gtt/{trigger_id} response status`, `DELETE /v1/gtt/{trigger_id} response status` |
| `SnapshotKind` | `orders`, `positions`, `holdings`, `trades`, `margins` | Snapshot kind: `orders` (today's order book) or a portfolio kind. | `stream snapshot kind` |
| `StrategyLedgerMissingCode` | `not_available_yet`, `unreadable` | Why an account has no ledger to show. | `GET /v1/strategies response missing[].code`, `GET /v1/strategies/{name} response missing[].code` |
| `StrategyReconStatus` | `ok`, `pending`, `mismatch` | Whether the strategy's open positions add up to the broker's. | `GET /v1/strategies response strategies[].recon.status`, `GET /v1/strategies/{name} response strategy.recon.status` |
| `StreamErrorCode` | `unavailable` | Why the stream cannot go on: `unavailable` (live updates could not be set up; reconnect). | `stream error code` |
| `SuccessStatus` | `success` | Always `success`. |  |
| `SymbolStatus` | `tradable`, `not_listed`, `not_equity`, `not_allowed` | Whether a member can be part of a stock strategy's universe today. | `GET /v1/watchlists/{watchlist_id} response symbols[].status` |
| `TelegramState` | `not_linked`, `linked`, `broken` | `not_linked`, `linked` (delivering) or `broken` (Telegram refuses our messages: see `broken_reason`). | `GET /v1/notifications/channels response telegram.state` |
| `TradingViewSignalType` | `entry`, `exit` | TradingView alert type: `entry` places the strategy's legs, `exit` closes what the strategy's entries placed. Case and surrounding spaces are ignored (`"Entry "` works); any other type is acknowledged and ignored. | `POST /v1/hooks/endpoints/{endpoint_id}/test request type`, `POST /v1/hooks/{kind}/{secret} request type`, `signal TradingViewAlert type` |
| `TriggerV2CreatedVia` | `app`, `api`, `order_ticket` | How a v2 trigger was made. | `GET /v2/triggers response [].created_via`, `GET /v2/triggers/{trigger_id} response created_via`, `GET /v1/connections/{client_id}/left response triggers[].created_via` |
| `TriggerV2EntryStatus` | `sending`, `accepted`, `filled`, `partially_filled`, `failed`, `outcome_unknown`, `other` | Where the entry stands: `sending` (being placed), `accepted` (at the broker, not filled yet), `filled`, `partially_filled`, `failed` (refused or ended with no fill; the trigger is cancelled), `outcome_unknown` (may or may not be at the broker; never sent again). Other values may appear. | `GET /v2/triggers response [].entry.status`, `GET /v2/triggers/{trigger_id} response entry.status`, `GET /v1/connections/{client_id}/left response triggers[].entry.status` |
| `TriggerV2HeldBy` | `broker`, `server` | Who holds a part. | `GET /v2/triggers response [].protection.*.held_by`, `GET /v2/triggers/{trigger_id} response protection.*.held_by`, `POST /v2/triggers/validate response protection.parts.*.held_by`, `GET /v1/connections/{client_id}/left response triggers[].protection.*.held_by`, `webhook A v2 trigger changed (webhooks that opted in only) data.protection.*.held_by`, `stream trigger_update data.protection.*.held_by` |
| `TriggerV2Kind` | `alert`, `bracket`, `ladder`, `risk_limit` | The machine that runs a trigger. | `GET /v2/triggers response [].kind`, `POST /v2/triggers request kind`, `GET /v2/triggers/{trigger_id} response kind`, `POST /v2/triggers/validate request kind`, `GET /v2/triggers/capabilities response kinds[].kind`, `GET /v1/strategies/{name} response triggers[].kind`, `GET /v1/connections/{client_id}/left response triggers[].kind`, `webhook A v2 trigger changed (webhooks that opted in only) data.kind`, `stream trigger_update data.kind` |
| `TriggerV2ProtectionStatus` | `protected`, `degraded`, `unknown`, `held_by_server`, `ended` | Where a part stands. | `GET /v2/triggers response [].protection.*.status`, `GET /v2/triggers/{trigger_id} response protection.*.status`, `GET /v1/connections/{client_id}/left response triggers[].protection.*.status`, `webhook A v2 trigger changed (webhooks that opted in only) data.protection.*.status`, `stream trigger_update data.protection.*.status` |
| `view` | `positions`, `holdings`, `margins`, `open_orders`, `order_history` | What to read: `positions`, `holdings`, `margins` (needs `funds`), `open_orders`, `order_history`. | `GET /v1/shared/consolidated/{view} path view` |
| `view` | `accounts`, `positions`, `holdings`, `margins`, `open_orders`, `order_history` | What to read: `accounts` (needs `login`), `positions`, `holdings`, `margins` (needs `funds`), `open_orders`, `order_history`. | `GET /v1/shared/{owner}/{view} path view` |
| `WebhookAlertKind` | `session_expired`, `relogin_detected`, `live_updates_unavailable`, `live_updates_restored`, `protection_failed`, `order_rejected`, `account_setup_incomplete`, `webhook_disabled` | Activity kinds sent as `account_alert`: `session_expired` (the broker login ended; log in again), `relogin_detected` (the account was logged in elsewhere), `live_updates_unavailable` / `live_updates_restored` (the broker's live order feed dropped / came back), `protection_failed` (a stop-loss / target could not be placed or kept), `order_rejected`, `account_setup_incomplete` (a field one feature needs is missing) and `webhook_disabled` (one of the user's webhooks was turned off after repeated failures). Partner app webhooks also get kind `account_removed` (the account is no longer linked to the user), which a user's own webhook never gets. | `webhook Something about an account needs the user data.kind` |
| `WebhookEventType` | `order_update`, `trade`, `account_alert`, `positions`, `ping` | Event names. A receiver subscribes to any of `order_update` (any change to an order), `trade` (a fill), `account_alert` (something about an account that needs the user) and `positions` (an account's positions changed, at most once per second per account). `ping` is sent only by the webhook's test endpoint and cannot be subscribed to. | `GET /v1/postbacks response [].events`, `POST /v1/postbacks request events`, `POST /v1/postbacks response events`, `GET /v1/postbacks/{id} response events`, `PATCH /v1/postbacks/{id} request events`, `PATCH /v1/postbacks/{id} response events`, `POST /v1/postbacks/{id}/rotate-secret response events`, `GET /v1/postbacks/{id}/deliveries response deliveries[].type` |
