# Cirrus API Reference

> Cirrus trading API reference: place orders across broker accounts, read your portfolio, stream live updates and receive signed webhooks.

- REST base: `https://api.cirrus.trade/v1`
- Stream: `wss://api.cirrus.trade/v1/stream`
- OpenAPI 3.1 spec: https://app.cirrus.trade/docs/openapi.json (live from the API: https://api.cirrus.trade/v1/openapi.json)
- HTML docs: https://app.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://app.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://app.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://app.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://app.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://app.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://app.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 /v1/triggers, 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 /v1/positions/convert |
| `triggers` | Create, change and remove stop-loss / target triggers | POST / PATCH / DELETE /v1/triggers |

- 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). |

- 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_pyoxfMeCpLKlvksr5Io8XkG7z5ZaKnLz3QEF9m2nuyECcwr2",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_GWiBQeI7nOqJt9PoEovV8T2Nr82NA1jXAJJVHzUG9CFvpK6heN2TmZ76",
  "scope": "read orders"
}
```

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

```json
{
  "access_token": "pa_Mn1gAb1cljCkrXUWf3VEAMaGKxmxFx2NoK33lLZ4Zz1nkhI9",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "pr_qKIa3tl8tPdlC9ukkFwGyRgEJJ4GvZbF4YZpj5mbTMm9rNwCRfG3VBAo",
  "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. |
| 401 | `invalid_client` | No client credentials, an unknown or disabled app, or a wrong secret. |
| 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 /v1/triggers, POST /v1/margins/orders |
| `orders` | POST / PATCH / DELETE /v1/orders and /v1/orders/bracket, POST / PATCH / DELETE /v1/gtt, POST /v1/positions/convert |
| `triggers` | POST / PATCH / DELETE /v1/triggers |
| `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 section of these docs also has a Copy as Markdown button.
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. 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://app.cirrus.trade/llms.txt): Index of the docs as Markdown pages, in the llmstxt.org format.
- [llms-full.txt](https://app.cirrus.trade/llms-full.txt): The whole reference in one Markdown file.
- [openapi.json](https://app.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://app.cirrus.trade/llms-full.txt and the OpenAPI spec at
https://app.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://app.cirrus.trade/docs/ai/claude-md
- **Claude Code**: `.claude/skills/cirrus-api/SKILL.md`, download https://app.cirrus.trade/docs/ai/claude-skill
- **Codex / AGENTS.md**: `AGENTS.md`, download https://app.cirrus.trade/docs/ai/agents-md
- **Cursor**: `.cursor/rules/cirrus-api.mdc`, download https://app.cirrus.trade/docs/ai/cursor
- **GitHub Copilot**: `.github/copilot-instructions.md`, download https://app.cirrus.trade/docs/ai/copilot
- **Windsurf**: `.windsurf/rules/cirrus-api.md`, download https://app.cirrus.trade/docs/ai/windsurf
- **Gemini CLI**: `GEMINI.md`, download https://app.cirrus.trade/docs/ai/gemini
- **Cline / Roo**: `.clinerules/cirrus-api.md`, download https://app.cirrus.trade/docs/ai/cline
- **Aider**: `CONVENTIONS.md`, download https://app.cirrus.trade/docs/ai/aider
- **Aider**: `.aider.conf.yml`, download https://app.cirrus.trade/docs/ai/aider-conf
- **Continue**: `.continue/rules/cirrus-api.md`, download https://app.cirrus.trade/docs/ai/continue
- **Kiro**: `.kiro/steering/cirrus-api.md`, download https://app.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://app.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://app.cirrus.trade/docs/openapi.json (the API also serves it at https://api.cirrus.trade/v1/openapi.json). Full reference as Markdown: https://app.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.

##### 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://app.cirrus.trade/docs/oauth.md.
- Send the user's browser to `https://app.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.

## 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 address, if one 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. |

**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
      },
      {
        "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
      },
      {
        "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
      },
      {
        "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
      },
      {
        "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
      },
      {
        "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-09-28T20:31:11Z",
        "seat_assigned": null,
        "last_update_at": "2026-09-28T19:31:11.879659Z"
      },
      {
        "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
      },
      {
        "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
      }
    ]
  },
  "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 later. |
| 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 address, if one 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. |
| `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 (never its message). |
| `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,
    "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-09-28T20:31:11Z",
    "seat_assigned": null,
    "last_update_at": "2026-09-28T19:31:11.870303Z",
    "health": {
      "stream": "up",
      "polls": false,
      "last_book_read_at": "2026-09-28T19:31:11.870303Z",
      "open_orders": 2,
      "last_error_kind": 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 later. |
| 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, e.g. `NSE`, `NFO`. |
| `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). |

- 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 (`NSE`, `BSE`, `NFO`, `BFO`, `MCX`, ...), upper case. |
| `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`. |

**Example: Filters only: NFO calls at one strike (200)**

```json
{
  "status": "success",
  "data": {
    "items": [],
    "count": 0
  },
  "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
  },
  "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)",
    "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 later. |
| 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 (`NSE`, `BSE`, `NFO`, `BFO`, `MCX`, ...), upper case. |
| `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 later. |
| 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 later. |
| 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`; a multiple of the lot size. |
| `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. |
| `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. |
| `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`. |
| `results[].protection_id` | `string \| null` | no | Broker basket id when the broker holds the legs from placement. |
| `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": "01M3MQZWZ8PW47QZB5A0Y2DWXR"
      },
      {
        "order_index": 0,
        "account": "P1",
        "quantity": 5,
        "status": "accepted",
        "order_id": "PAPER-1",
        "message": null,
        "order_type": "LIMIT",
        "price": 2500,
        "protection": "trigger",
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3MQZWZ84XTJYCDY33KN40EP"
      }
    ],
    "accepted": 2,
    "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": "01M3MQZWZ9F0JEZ3JCBMBCHJTP"
      }
    ],
    "accepted": 1,
    "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": "01M3MQZWZC63GQBA2RNYAXX1M8"
      },
      {
        "order_index": 0,
        "account": "Z1",
        "quantity": 1,
        "status": "rejected",
        "order_id": null,
        "message": "Broker zerodha is not supported yet.",
        "order_type": "LIMIT",
        "price": 2500,
        "instrument_token": "CT:TEST:RELIANCE",
        "order_tag": "01M3MQZWZDP8V1WSK2M7TRSVBE"
      }
    ],
    "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 later. |
| 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 (1-64 letters, digits, `-`, `_` or `.`). |

**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`; a multiple of the lot size. |
| `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. |

**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). |
| 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 later. |
| 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 (1-64 letters, digits, `-`, `_` or `.`). |

**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. |

```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. |

**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"
  },
  "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. |
| 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 later. |
| 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`; a multiple of the lot size. |
| `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). |

- 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 (null on placement). |
| `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. |

**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 later. |
| 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 of the entry (1-64 letters, digits, `-`, `_` or `.`). |

**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 (null on placement). |
| `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. |

**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: 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). |
| 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 later. |
| 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 of the entry (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`. |

```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 (null on placement). |
| `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. |

**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
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `—` | Plain text (not the JSON envelope): a query parameter is missing or not valid, e.g. `Failed to deserialize query string: missing field kind`. |
| 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 later. |
| 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`. |
| `missing` | `object[]` | yes |  |
| `missing[].account` | `string` | yes |  |
| `missing[].reason` | `string` | yes | Why, in plain English (no live data yet: broker not supported, or no active session). |

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

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-28T19:31:12.035116Z",
        "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": 0,
            "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-28T19:31:12.041560Z",
        "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-28T19:31:12.042868Z",
        "data": [
          {
            "broker_tag": "a1b2c3",
            "order_id": "240927000012345",
            "parent_tag": null,
            "username": "user-01M3MQZX047V42W740CP2QY5QN",
            "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-28T19:31:12.042868Z",
            "created_at": "2026-09-28T19:31:12.042868Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "OPEN",
                "at": "2026-09-28T19:31:12.042868Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 0,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3MQZX1AF8Z74E43NAT1YCQ5"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

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

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-28T19:31:12.034488Z",
        "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": 0,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": []
  },
  "error": null
}
```

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

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "P1",
        "updated_at": "2026-09-28T19:31:12.038635Z",
        "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-28T19:31:12.034472Z",
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 0,
            "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-28T19:31:12.041560Z",
        "data": {
          "account": "P1",
          "opening_balance": 100000,
          "available": 90000.8,
          "utilised": 9999.2
        }
      }
    ],
    "missing": [
      {
        "account": "P2",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "Z1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "PF1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "U1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "5P1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "MO1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      },
      {
        "account": "TJ1",
        "reason": "No live data yet for this account (broker not supported yet, or no active session)"
      }
    ]
  },
  "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 | `—` | Unreadable query string (an unknown parameter): a plain-text answer, not the JSON envelope. |
| 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 later. |
| 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 | `—` | Unreadable query string (an unknown parameter): a plain-text answer, not the JSON envelope. |
| 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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Triggers

Cirrus stop-loss / target / trail triggers, evaluated on every tick. Protection held by the broker (bracket, cover, GTT) is listed here but changed at the broker.

### GET /v1/triggers

Scope: `read`

Every trigger of yours, with live levels and status.

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

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].trigger_id` | `string` | yes |  |
| `[].account` | `string` | yes | Account id (see `/v1/accounts`). |
| `[].instrument_token` | `string \| null` | yes | Instrument id. |
| `[].name` | `string` | yes | Enabled rules: `SL`, `TGT`, `TSL`, joined with ` + `. |
| `[].side` | `string` | yes | Side of the protected entry: `BUY` (a long position, exited by a SELL) or `SELL`. |
| `[].product` | `string` | yes | Product of the position (`MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`). |
| `[].quantity` | `integer (int64)` | yes | Quantity the exit covers, in units. |
| `[].price` | `number (double)` | yes | Entry / average price the levels are measured from. |
| `[].order_id` | `string \| null` | yes | The protected entry order, when the trigger came with one. |
| `[].broker_order_id` | `string \| null` | yes | Broker id of the order / GTT holding the legs (broker-held only). |
| `[].kind` | `string \| null` | yes | Where the protection lives: `platform` (a server-side trigger; the server places the exit), `bracket` or `cover` (broker bracket / cover order legs), `gtt_oco` or `gtt_single` (broker GTT with two legs or one). Null for triggers created without a kind; other values may appear for triggers created elsewhere. |
| `[].exits` | `boolean` | yes | The server (not the broker) places the exit when a level is hit. `false`: the broker holds the legs, so change or cancel them at the broker (only delete works here). |
| `[].rules` | `object` | yes | The trigger's stop-loss, target and trailing stop-loss rules. |
| `[].rules.target` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `[].rules.target.enabled` | `boolean` | yes | Off unless `true`. |
| `[].rules.target.type` | `string` | yes | 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`. |
| `[].rules.target.value` | `number (double)` | yes |  |
| `[].rules.stop_loss` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `[].rules.stop_loss.enabled` | `boolean` | yes | Off unless `true`. |
| `[].rules.stop_loss.type` | `string` | yes | 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`. |
| `[].rules.stop_loss.value` | `number (double)` | yes |  |
| `[].rules.trail` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `[].rules.trail.enabled` | `boolean` | yes | Off unless `true`. |
| `[].rules.trail.type` | `string` | yes | 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`. |
| `[].rules.trail.value` | `number (double)` | yes |  |
| `[].stop_loss_price` | `number (double) \| null` | yes | Current stop-loss level (moves when trailing); null when disabled. |
| `[].initial_stop_loss_price` | `number (double) \| null` | yes | Stop-loss level before any trailing; null when disabled. |
| `[].target_price` | `number (double) \| null` | yes | Target level; null when disabled. |
| `[].live` | `boolean` | yes | Protection is armed (the entry filled). |
| `[].status` | `string \| null` | yes | Set once the trigger fired or ended: `sl_hit`, `target_hit`, `target_reached`, `invalidated`, `exit`, `cancelled`, ...; null while it watches. |
| `[].created_at` | `string` | yes | Creation time (India time, `YYYY-MM-DD HH:MM:SS`). |
| `[].updated_at` | `string` | yes | Last change (India time, `YYYY-MM-DD HH:MM:SS`). |

**Example: A server-side trigger and a broker-held GTT (200)**

```json
{
  "status": "success",
  "data": [
    {
      "trigger_id": "efb67bb6-5c8a-44dc-ab8e-dbdec912a0b6",
      "account": "P1",
      "name": "SL + TGT + TSL",
      "side": "BUY",
      "product": "MIS",
      "quantity": 5,
      "price": 2500,
      "order_id": null,
      "broker_order_id": null,
      "kind": "platform",
      "rules": {
        "target": {
          "enabled": true,
          "type": "percentage",
          "value": 2
        },
        "stop_loss": {
          "enabled": true,
          "type": "points",
          "value": 25
        },
        "trail": {
          "enabled": true,
          "type": "points",
          "value": 5
        }
      },
      "stop_loss_price": 2475,
      "initial_stop_loss_price": 2475,
      "target_price": 2550,
      "live": true,
      "status": null,
      "created_at": "2026-09-29 01:01:12",
      "updated_at": "2026-09-29 01:01:12",
      "instrument_token": "CT:TEST:RELIANCE",
      "exits": true
    },
    {
      "trigger_id": "489301aa-4fbb-4cb6-adef-6b7813c6b78f",
      "account": "PF1",
      "name": "SL + TGT",
      "side": "BUY",
      "product": "DELIVERY",
      "quantity": 5,
      "price": 2500,
      "order_id": "250929000002",
      "broker_order_id": "gtt-held",
      "kind": "gtt_oco",
      "rules": {
        "target": {
          "enabled": true,
          "type": "points",
          "value": 100
        },
        "stop_loss": {
          "enabled": true,
          "type": "points",
          "value": 25
        },
        "trail": {
          "enabled": false,
          "type": "percentage",
          "value": 0
        }
      },
      "stop_loss_price": 2475,
      "initial_stop_loss_price": 2475,
      "target_price": 2600,
      "live": true,
      "status": null,
      "created_at": "2026-09-29 01:01:12",
      "updated_at": "2026-09-29 01:01:12",
      "instrument_token": "CT:TEST:RELIANCE",
      "exits": false
    }
  ],
  "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/triggers

Scope: `triggers`

Protect an existing position.

**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 position's entry (BUY = long, protected by a SELL exit). 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 to exit when a level is hit. |
| `price` | `number (double) \| null` | no | Entry / average price the levels are measured from; LTP if absent. |
| `rules` | `object` | yes | Stop-loss, target and trailing stop-loss. Enable a stop-loss, a target or both; a trail needs a stop-loss and is `percentage` or `points`. |
| `rules.target` | `object` | no | One leg of protection. |
| `rules.target.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.target.value` | `number (double)` | no | Greater than 0 when enabled. |
| `rules.stop_loss` | `object` | no | One leg of protection. |
| `rules.stop_loss.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.stop_loss.value` | `number (double)` | no | Greater than 0 when enabled. |
| `rules.trail` | `object` | no | One leg of protection. |
| `rules.trail.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.trail.value` | `number (double)` | no | Greater than 0 when enabled. |

- 503 `trigger_worker_down` when no exit worker is running; nothing is created.

```bash
curl -X POST https://api.cirrus.trade/v1/triggers \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account": "<account_id>", "instrument_token": "<instrument_token>", "side": "BUY",
       "product": "CARRYFORWARD", "quantity": 75,
       "rules": {"stop_loss": {"enabled": true, "type": "percentage", "value": 2},
                 "trail": {"enabled": true, "type": "points", "value": 5}}}'
```

**Response `data` (201)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `instrument_token` | `string \| null` | yes | Instrument id. |
| `name` | `string` | yes | Enabled rules: `SL`, `TGT`, `TSL`, joined with ` + `. |
| `side` | `string` | yes | Side of the protected entry: `BUY` (a long position, exited by a SELL) or `SELL`. |
| `product` | `string` | yes | Product of the position (`MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`). |
| `quantity` | `integer (int64)` | yes | Quantity the exit covers, in units. |
| `price` | `number (double)` | yes | Entry / average price the levels are measured from. |
| `order_id` | `string \| null` | yes | The protected entry order, when the trigger came with one. |
| `broker_order_id` | `string \| null` | yes | Broker id of the order / GTT holding the legs (broker-held only). |
| `kind` | `string \| null` | yes | Where the protection lives: `platform` (a server-side trigger; the server places the exit), `bracket` or `cover` (broker bracket / cover order legs), `gtt_oco` or `gtt_single` (broker GTT with two legs or one). Null for triggers created without a kind; other values may appear for triggers created elsewhere. |
| `exits` | `boolean` | yes | The server (not the broker) places the exit when a level is hit. `false`: the broker holds the legs, so change or cancel them at the broker (only delete works here). |
| `rules` | `object` | yes | The trigger's stop-loss, target and trailing stop-loss rules. |
| `rules.target` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.target.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.target.type` | `string` | yes | 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`. |
| `rules.target.value` | `number (double)` | yes |  |
| `rules.stop_loss` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.stop_loss.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.stop_loss.type` | `string` | yes | 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`. |
| `rules.stop_loss.value` | `number (double)` | yes |  |
| `rules.trail` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.trail.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.trail.type` | `string` | yes | 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`. |
| `rules.trail.value` | `number (double)` | yes |  |
| `stop_loss_price` | `number (double) \| null` | yes | Current stop-loss level (moves when trailing); null when disabled. |
| `initial_stop_loss_price` | `number (double) \| null` | yes | Stop-loss level before any trailing; null when disabled. |
| `target_price` | `number (double) \| null` | yes | Target level; null when disabled. |
| `live` | `boolean` | yes | Protection is armed (the entry filled). |
| `status` | `string \| null` | yes | Set once the trigger fired or ended: `sl_hit`, `target_hit`, `target_reached`, `invalidated`, `exit`, `cancelled`, ...; null while it watches. |
| `created_at` | `string` | yes | Creation time (India time, `YYYY-MM-DD HH:MM:SS`). |
| `updated_at` | `string` | yes | Last change (India time, `YYYY-MM-DD HH:MM:SS`). |

**Example: Stop-loss 25 points below, target 2% above, trailing by 5 points (201)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "aa554bda-c2ad-4981-9f83-c7291c567c40",
    "account": "P1",
    "name": "SL + TGT + TSL",
    "side": "BUY",
    "product": "MIS",
    "quantity": 5,
    "price": 2500,
    "order_id": null,
    "broker_order_id": null,
    "kind": "platform",
    "rules": {
      "target": {
        "enabled": true,
        "type": "percentage",
        "value": 2
      },
      "stop_loss": {
        "enabled": true,
        "type": "points",
        "value": 25
      },
      "trail": {
        "enabled": true,
        "type": "points",
        "value": 5
      }
    },
    "stop_loss_price": 2475,
    "initial_stop_loss_price": 2475,
    "target_price": 2550,
    "live": true,
    "status": null,
    "created_at": "2026-09-29 01:01:12",
    "updated_at": "2026-09-29 01:01:12",
    "instrument_token": "CT:TEST:RELIANCE",
    "exits": true
  },
  "error": null
}
```

**Example: A stop-loss already crossed at LTP is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "the stop-loss is already crossed at LTP 2500; it would exit immediately",
    "field": "rules"
  }
}
```

**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. |
| 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 later. |
| 503 | `trigger_worker_down` | No trigger exit worker is running; nothing was created. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### PATCH /v1/triggers/{id}

Scope: `triggers`

Change rules, quantity or price. Send at least one.

**Path parameters**

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

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `rules` | `object \| null` | no | New rules (all three legs). |
| `rules.target` | `object` | no | One leg of protection. |
| `rules.target.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.target.value` | `number (double)` | no | Greater than 0 when enabled. |
| `rules.stop_loss` | `object` | no | One leg of protection. |
| `rules.stop_loss.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.stop_loss.value` | `number (double)` | no | Greater than 0 when enabled. |
| `rules.trail` | `object` | no | One leg of protection. |
| `rules.trail.enabled` | `boolean` | no | Off unless `true`. |
| `rules.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`. |
| `rules.trail.value` | `number (double)` | no | Greater than 0 when enabled. |
| `quantity` | `integer (int64) \| null` | no | New quantity, in units. |
| `price` | `number (double) \| null` | no | New entry price the levels are measured from. |

- 409 `trigger_busy` (changed while updating; retry), `trigger_fired` (already fired), or `broker_held` (change the broker order instead).

```bash
curl -X PATCH "https://api.cirrus.trade/v1/triggers/<trigger_id>" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "price": 2510,
    "quantity": 3
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `account` | `string` | yes | Account id (see `/v1/accounts`). |
| `instrument_token` | `string \| null` | yes | Instrument id. |
| `name` | `string` | yes | Enabled rules: `SL`, `TGT`, `TSL`, joined with ` + `. |
| `side` | `string` | yes | Side of the protected entry: `BUY` (a long position, exited by a SELL) or `SELL`. |
| `product` | `string` | yes | Product of the position (`MIS`, `CARRYFORWARD`, `DELIVERY`, `MTF`). |
| `quantity` | `integer (int64)` | yes | Quantity the exit covers, in units. |
| `price` | `number (double)` | yes | Entry / average price the levels are measured from. |
| `order_id` | `string \| null` | yes | The protected entry order, when the trigger came with one. |
| `broker_order_id` | `string \| null` | yes | Broker id of the order / GTT holding the legs (broker-held only). |
| `kind` | `string \| null` | yes | Where the protection lives: `platform` (a server-side trigger; the server places the exit), `bracket` or `cover` (broker bracket / cover order legs), `gtt_oco` or `gtt_single` (broker GTT with two legs or one). Null for triggers created without a kind; other values may appear for triggers created elsewhere. |
| `exits` | `boolean` | yes | The server (not the broker) places the exit when a level is hit. `false`: the broker holds the legs, so change or cancel them at the broker (only delete works here). |
| `rules` | `object` | yes | The trigger's stop-loss, target and trailing stop-loss rules. |
| `rules.target` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.target.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.target.type` | `string` | yes | 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`. |
| `rules.target.value` | `number (double)` | yes |  |
| `rules.stop_loss` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.stop_loss.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.stop_loss.type` | `string` | yes | 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`. |
| `rules.stop_loss.value` | `number (double)` | yes |  |
| `rules.trail` | `object` | yes | A rule as served: `type` is snake_case (`percentage`, `points`, `price`). The trigger hash keeps the legacy casing (`Percentage`) that the trigger workers read; requests accept either. |
| `rules.trail.enabled` | `boolean` | yes | Off unless `true`. |
| `rules.trail.type` | `string` | yes | 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`. |
| `rules.trail.value` | `number (double)` | yes |  |
| `stop_loss_price` | `number (double) \| null` | yes | Current stop-loss level (moves when trailing); null when disabled. |
| `initial_stop_loss_price` | `number (double) \| null` | yes | Stop-loss level before any trailing; null when disabled. |
| `target_price` | `number (double) \| null` | yes | Target level; null when disabled. |
| `live` | `boolean` | yes | Protection is armed (the entry filled). |
| `status` | `string \| null` | yes | Set once the trigger fired or ended: `sl_hit`, `target_hit`, `target_reached`, `invalidated`, `exit`, `cancelled`, ...; null while it watches. |
| `created_at` | `string` | yes | Creation time (India time, `YYYY-MM-DD HH:MM:SS`). |
| `updated_at` | `string` | yes | Last change (India time, `YYYY-MM-DD HH:MM:SS`). |

**Example: New entry price and quantity; the levels follow the price (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "710f58d1-9253-4194-b225-12f7a23e5862",
    "account": "P1",
    "name": "SL + TGT + TSL",
    "side": "BUY",
    "product": "MIS",
    "quantity": 3,
    "price": 2510,
    "order_id": null,
    "broker_order_id": null,
    "kind": "platform",
    "rules": {
      "target": {
        "enabled": true,
        "type": "percentage",
        "value": 2
      },
      "stop_loss": {
        "enabled": true,
        "type": "points",
        "value": 25
      },
      "trail": {
        "enabled": true,
        "type": "points",
        "value": 5
      }
    },
    "stop_loss_price": 2485,
    "initial_stop_loss_price": 2485,
    "target_price": 2560.2,
    "live": true,
    "status": null,
    "created_at": "2026-09-29 01:01:12",
    "updated_at": "2026-09-29 01:01:12",
    "instrument_token": "CT:TEST:RELIANCE",
    "exits": true
  },
  "error": null
}
```

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

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

**Example: Broker-held protection is changed at the broker (409)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "broker_held",
    "message": "This protection is held by the broker; change the broker order instead",
    "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. |
| 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. |
| 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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### DELETE /v1/triggers/{id}

Scope: `triggers`

Remove a trigger.

**Path parameters**

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

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

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `trigger_id` | `string` | yes |  |
| `deleted` | `boolean` | yes | Always `true`. |

**Example: Deleted (200)**

```json
{
  "status": "success",
  "data": {
    "trigger_id": "33a9061d-deb5-4c85-8969-01d63f07e0bc",
    "deleted": true
  },
  "error": null
}
```

**Example: No trigger with this id (here: already deleted) (404)**

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

**Example: The broker did not cancel its GTT; the trigger is kept (502)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "broker_refused",
    "message": "GTT already triggered",
    "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. |
| 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 later. |
| 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. |

## 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. |
| `[].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 |  |
| `[].error` | `string \| null` | yes | Why this account's list is missing (others still listed); null when it was read. |

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

```json
{
  "status": "success",
  "data": [
    {
      "account": "P1",
      "broker": "paper",
      "gtts": [],
      "error": "Listing GTTs is not available for paper accounts."
    }
  ],
  "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 | `—` | Plain text (not the JSON envelope): the query string is not valid, e.g. an unknown parameter. |
| 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 later. |
| 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.

**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`; a multiple of the lot size. |
| `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. |

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

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "status": "accepted",
    "message": null
  },
  "error": 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). |
| 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 later. |
| 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 `.`). |

**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`; a multiple of the lot size. |
| `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
    },
    "target": {
      "trigger": 2650,
      "price": 2650
    }
  }'
```

**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. |

**Example: Both legs moved (200)**

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "status": "accepted",
    "message": null
  },
  "error": 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). |
| 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 later. |
| 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. |

```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. |

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

```json
{
  "status": "success",
  "data": {
    "account": "PF1",
    "trigger_id": "0f6c1a52-4d7e-4b8e-9d51-2c7a9e3b1f20",
    "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
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `—` | Plain text (not the JSON envelope): `account` is missing or the query string is not valid. |
| 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 later. |
| 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.

**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. |

**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: `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). |
| 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 later. |
| 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`; a multiple of the lot size. |
| `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. |
| `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 the broker requires; null when there is no figure (`message` says why). |
| `results[].message` | `string \| null` | yes | Why there is no figure (the order would be refused, the broker cannot calculate margins, or it did not answer). |
| `total_required` | `number (double)` | yes | Sum over results that have a figure. |

**Example: One account with a figure, one whose broker cannot calculate margins (200)**

```json
{
  "status": "success",
  "data": {
    "results": [
      {
        "order_index": 0,
        "account": "PF1",
        "quantity": 10,
        "total_required": null,
        "message": "Margin calculation is not available for pocketful",
        "instrument_token": "CT:TEST:RELIANCE"
      },
      {
        "order_index": 0,
        "account": "P2",
        "quantity": 20,
        "total_required": 50000,
        "message": null,
        "instrument_token": "CT:TEST:RELIANCE"
      }
    ],
    "total_required": 50000
  },
  "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 later. |
| 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`. |
| `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`. |
| `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`. |
| `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-01M3MQZX31VE1ERX4EGEWXDXDC",
    "auth": {
      "kind": "api_key",
      "key_id": "ck_tpVIaOozVzi8aEUO9yH6",
      "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-01M3MQZX31VE1ERX4EGEWXDXDC",
    "auth": {
      "kind": "session",
      "impersonated": false,
      "scopes": [
        "read",
        "orders",
        "triggers"
      ]
    }
  },
  "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 later. |
| 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`). |
| `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). |
| `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. |
| `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`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`. |
| `items[].category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account` or `access`. One of: `orders`, `protection`, `signals`, `account`, `access`. |
| `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[].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[].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 |  |
| `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[].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": "01M3MQZWW6BZSTR16YB6359R87",
        "username": "user-01M3MQZWS63GTYZTS4FNMT47RV",
        "at": "2026-09-28T19:31:11.875323958Z",
        "kind": "place",
        "category": "orders",
        "source": {
          "type": "app",
          "name": null
        },
        "request_id": "01M3MQZWW3EWEM5BWG2XZRAMS3",
        "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-EQ at ₹2,500 (Limit) in 1 account",
        "message": null,
        "outcome": "ok",
        "duration_ms": 2,
        "accounts": [
          {
            "account": "P1",
            "broker": "paper",
            "status": "placed",
            "order_ids": [
              "PAPER-1"
            ],
            "message": null,
            "broker_message": null,
            "duration_ms": 1
          }
        ]
      }
    ],
    "next_cursor": null,
    "source": "hot"
  },
  "error": null
}
```

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

```json
{
  "status": "success",
  "data": {
    "items": [
      {
        "id": "01M3MQZWW6BZSTR16YB6359R87",
        "username": "user-01M3MQZWS63GTYZTS4FNMT47RV",
        "at": "2026-09-28T19:31:11.875323958Z",
        "kind": "place",
        "category": "orders",
        "source": {
          "type": "app",
          "name": null
        },
        "request_id": "01M3MQZWW3EWEM5BWG2XZRAMS3",
        "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-EQ at ₹2,500 (Limit) in 1 account",
        "message": null,
        "outcome": "ok",
        "duration_ms": 2,
        "accounts": [
          {
            "account": "P1",
            "broker": "paper",
            "status": "placed",
            "order_ids": [
              "PAPER-1"
            ],
            "message": null,
            "broker_message": null,
            "duration_ms": 1
          }
        ]
      }
    ],
    "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 | `—` | Unreadable query string (an unknown parameter, or `limit` not a whole number): a plain-text answer, not the JSON envelope. |
| 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 later. |
| 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. |
| `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`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`. |
| `category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account` or `access`. One of: `orders`, `protection`, `signals`, `account`, `access`. |
| `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. |
| `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 |  |
| `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 |  |
| `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 |  |
| `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": "01M3MQZWWD5WSM33JA5AJ680C5",
    "username": "user-01M3MQZWS7J3SMP5VGDZ3X17SS",
    "at": "2026-09-28T19:31:11.882796667Z",
    "kind": "place",
    "category": "orders",
    "source": {
      "type": "app",
      "name": null
    },
    "request_id": "01M3MQZWWAJC9KDSNW93HHA938",
    "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-EQ at ₹2,500 (Limit) in 1 account",
    "message": null,
    "outcome": "ok",
    "duration_ms": 2,
    "accounts": [
      {
        "account": "P1",
        "broker": "paper",
        "status": "placed",
        "order_ids": [
          "PAPER-1"
        ],
        "message": null,
        "broker_message": null,
        "duration_ms": 1
      }
    ],
    "timeline": [
      {
        "at": "2026-09-28T19:31:12.992525Z",
        "account": "P1",
        "order_id": "PAPER-1",
        "state": "OPEN",
        "message": null,
        "filled_qty": 0,
        "average_price": null
      },
      {
        "at": "2026-09-28T19:31:13.992525Z",
        "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## 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`. |

**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"
    }
  ]
}
```

#### 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.lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.exchange` | `string \| null` | no | Exchange (`NSE`, `NFO`, `BSE`, `BFO`, `MCX`, ...). |
| `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.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`. |

**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`. |

**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. |
| `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`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled`. |
| `data.category` | `string` | yes | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account` or `access`. One of: `orders`, `protection`, `signals`, `account`, `access`. |
| `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.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.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 |  |
| `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.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-EQ 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
      }
    ]
  }
}
```

#### 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"
}
```

**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"
}
```

## 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.lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.exchange` | `string \| null` | no | Exchange (`NSE`, `NFO`, `BSE`, `BFO`, `MCX`, ...). |
| `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.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`. |

**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 |  |

**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). 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. |
| `data.positions[].lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `data.positions[].exchange` | `string \| null` | no | Exchange (`NSE`, `NFO`, `BSE`, `BFO`, `MCX`, ...). |
| `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[].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`. |

**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."
  }
}
```

### 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. |

- 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 |  |
| `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_01m3mqzx4p5tfpgbyqjgnz52kv",
    "url": "http://127.0.0.1:60716/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-09-28T19:31:12.150Z",
    "updated_at": "2026-09-28T19:31:12.150Z",
    "secret": "whsec_txDdiAC9WzSW48p9DLpz8/Y/pqIXJOzIWhiB7agVaC0=",
    "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`",
    "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 later. |
| 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 |  |

**Example: The user's webhooks (200)**

```json
{
  "status": "success",
  "data": [
    {
      "id": "pb_01m3mqzx4p5tfpgbyqjgnz52kv",
      "url": "http://127.0.0.1:60716/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-09-28T19:31:12.150Z",
      "updated_at": "2026-09-28T19:31:12.150Z"
    }
  ],
  "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 later. |
| 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 |  |

**Example: One webhook (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m3mqzx4p5tfpgbyqjgnz52kv",
    "url": "http://127.0.0.1:60716/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-09-28T19:31:12.150Z",
    "updated_at": "2026-09-28T19:31:12.150Z"
  },
  "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 later. |
| 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). |

```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 |  |

**Example: Every account, one more event, turned off (200)**

```json
{
  "status": "success",
  "data": {
    "id": "pb_01m3mqzx4p5tfpgbyqjgnz52kv",
    "url": "http://127.0.0.1:60716/hooks/orders",
    "events": [
      "order_update",
      "trade",
      "positions"
    ],
    "accounts": null,
    "description": "Order desk",
    "enabled": false,
    "disabled_reason": "disabled_by_user",
    "disabled_at": "2026-09-28T19:31:12.156Z",
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-09-28T19:31:12.150Z",
    "updated_at": "2026-09-28T19:31:12.156Z"
  },
  "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 later. |
| 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_01m3mqzx58t3ezceq1ecb498yv",
    "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 later. |
| 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 |  |
| `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_01m3mqzx58t3ezceq1ecb498yv",
    "url": "http://127.0.0.1:60710/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-29T19:31:12.173Z",
    "secret_rotated_at": "2026-09-28T19:31:12.173Z",
    "created_at": "2026-09-28T19:31:12.168Z",
    "updated_at": "2026-09-28T19:31:12.173Z",
    "secret": "whsec_fqwXMrqAHOadNV3Pw3Lq83azTavNzXwNNP+D8P6jU6Q=",
    "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 later. |
| 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_bbde86f7815e23802cf72965f007b693",
    "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 later. |
| 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": "01M3MQZX5CX39YTHPWEFADXTQN",
        "event_id": "evt_bbde86f7815e23802cf72965f007b693",
        "type": "ping",
        "attempt": 1,
        "outcome": "delivered",
        "status_code": 200,
        "duration_ms": 0,
        "error_kind": null,
        "at": "2026-09-28T19:31:12.172Z"
      }
    ],
    "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 | `—` | The query string does not parse (e.g. `limit` is not a number); plain-text reply. |
| 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 later. |
| 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 992 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_K64Mxu5q5wJHhyDd",
      "kind": "tradingview",
      "strategy_id": "nifty-breakout-01M3MQZX2RZXFZ4BA4K46TY12A",
      "created_at": "2026-09-28T19:31:12.090922+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 later. |
| 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-01M3MQZX2RZXFZ4BA4K46TY12A"
  }'
```

**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_K64Mxu5q5wJHhyDd",
    "kind": "tradingview",
    "strategy_id": "nifty-breakout-01M3MQZX2RZXFZ4BA4K46TY12A",
    "created_at": "2026-09-28T19:31:12.090922+00:00",
    "has_body_secret": true,
    "path": "/v1/hooks/tradingview/wh_j3eXpOAgQQ8ZbCwZLudcszn3KWc4V1sC3rIHcXTV2Le",
    "body_secret": "tvs_7QaFOF9HjMwawCKqUxiSl5nnIMYI4Sue",
    "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 later. |
| 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_K64Mxu5q5wJHhyDd",
    "kind": "tradingview",
    "strategy_id": "nifty-breakout-01M3MQZX2RZXFZ4BA4K46TY12A",
    "created_at": "2026-09-28T19:31:12.102166+00:00",
    "has_body_secret": true,
    "path": "/v1/hooks/tradingview/wh_PcykPFU3b0Mb1P1W7tKaTqbO1VHULzPXd92rqxKZL8l",
    "body_secret": "tvs_VCszf9naN0LnUt2SujREKjNTtsac8dHg",
    "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 later. |
| 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_K64Mxu5q5wJHhyDd",
    "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 later. |
| 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": "01M3MQZX4AT9WSGE09S4CWXHSX",
        "at": "2026-09-28T19:31:12.138Z",
        "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": "01M3MQZX48WJ21J1E118EVG2RD"
      },
      {
        "delivery_id": "01M3MQZX48QJW2DXVP6XD85QRZ",
        "at": "2026-09-28T19:31:12.136Z",
        "provider": "chartink",
        "outcome": "duplicate",
        "reason": "the same alert arrived twice; the copy was skipped",
        "orders_placed": 0,
        "order_ids": [],
        "http_status": 200,
        "request_id": "01M3MQZX46RRRBQ3D201CTZH6P"
      },
      {
        "delivery_id": "01M3MQZX46B5BP5AXHF8YS4HE0",
        "at": "2026-09-28T19:31:12.134Z",
        "provider": "chartink",
        "outcome": "duplicate",
        "reason": "the same alert arrived twice; the copy was skipped",
        "orders_placed": 0,
        "order_ids": [],
        "http_status": 200,
        "request_id": "01M3MQZX45JJPVMR5GRH6Z1T77"
      },
      {
        "delivery_id": "01M3MQZX455MFC6KCR2TWPM699",
        "at": "2026-09-28T19:31:12.133Z",
        "provider": "chartink",
        "outcome": "placed",
        "reason": null,
        "orders_placed": 1,
        "order_ids": [
          "PAPER-1"
        ],
        "http_status": 200,
        "request_id": "01M3MQZX40EW38WXHT3CDF1KAE"
      }
    ],
    "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 | `—` | The query string does not parse (unknown parameter, `limit` not a number); plain-text reply. |
| 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 later. |
| 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 later. |
| 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`. |
| `[].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_MpGKDrd64Jc2XT2CyQGW",
      "name": "Trading bot",
      "scopes": [
        "read",
        "orders"
      ],
      "ip_allowlist": [
        "203.0.113.7"
      ],
      "created_at": "2026-09-28T19:31:11.878729+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 later. |
| 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`. |
| `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`. |
| `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_MpGKDrd64Jc2XT2CyQGW",
    "name": "Trading bot",
    "scopes": [
      "read",
      "orders"
    ],
    "ip_allowlist": [
      "203.0.113.7"
    ],
    "created_at": "2026-09-28T19:31:11.878729+00:00",
    "last_used_at": null,
    "api_secret": "cs_9vnItUrl5NTPwetZAFJRnRRJup9U5KC8lsHvSmu8",
    "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`",
    "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 later. |
| 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_MpGKDrd64Jc2XT2CyQGW",
    "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 later. |
| 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`. |
| `[].created_at` | `string` | yes | RFC 3339 time of registration. |
| `[].disabled` | `boolean` | yes | `true` once the owner disabled the app (its tokens stop working). |

**Example: The user's apps (200)**

```json
{
  "status": "success",
  "data": [
    {
      "client_id": "cp_YtJe2ihlan6rj5LcW7KI",
      "owner": "user-01M3NVXMEG6YJNY2F10FCRF2Q9",
      "name": "Charts Co",
      "verified": false,
      "logo_url": "https://partner.example/logo.png",
      "redirect_uris": [
        "https://partner.example/callback"
      ],
      "scopes": [
        "read",
        "orders"
      ],
      "created_at": "2026-09-29T05:59:06.498541+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 later. |
| 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`. |
| `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`. |
| `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_YtJe2ihlan6rj5LcW7KI",
    "owner": "user-01M3NVXMEG6YJNY2F10FCRF2Q9",
    "name": "Charts Co",
    "verified": false,
    "logo_url": "https://partner.example/logo.png",
    "redirect_uris": [
      "https://partner.example/callback"
    ],
    "scopes": [
      "read",
      "orders"
    ],
    "created_at": "2026-09-29T05:59:06.498541+00:00",
    "disabled": false,
    "client_secret": "cps_hXhW1Bnl4McDJuAMpoGqQVEYPG6b4We9lATUXNKJ7CqigI1y",
    "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`",
    "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 later. |
| 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_ddSuxziFxtMzFwliLeK7",
    "client_secret": "cps_MOIPAboqpyrIceK6zY3QpdbtBbZMqbhlorqoAcLY4ysPBJCb",
    "previous_secret_expires_at": "2026-09-30T05:59:06Z"
  },
  "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 later. |
| 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_YtJe2ihlan6rj5LcW7KI",
    "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/oauth/consent

Access: app session only (API keys and partner tokens get 403)

Consent screen, step 1: check the authorization request (app, redirect URI, scopes) and get what to show the user.

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |
| `redirect_uri` | `string` | yes | One of the app's registered redirect URIs, exactly. |
| `scope` | `string` | yes | Requested scopes, space separated (`read`, `orders`, `triggers`). |

```bash
curl "https://api.cirrus.trade/v1/oauth/consent?client_id=<client_id>&redirect_uri=<redirect_uri>&scope=<scope>" \
  -H "Authorization: token $CIRRUS_API_KEY"
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |
| `name` | `string` | yes | The app's name. |
| `logo_url` | `string \| null` | yes | HTTPS URL of the app's logo. |
| `verified` | `boolean` | yes | `false`: warn the user that the app's name is not verified. |
| `scopes` | `string[]` | yes | The scopes requested (validated, duplicates removed). One of: `read`, `orders`, `triggers`. |

**Example: What the consent screen shows (200)**

```json
{
  "status": "success",
  "data": {
    "client_id": "cp_XwdEn1dFZHLEjiQdFZrN",
    "name": "Charts Co",
    "logo_url": "https://partner.example/logo.png",
    "verified": false,
    "scopes": [
      "read",
      "orders"
    ]
  },
  "error": null
}
```

**Example: Unknown or disabled app (404)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "unknown_app",
    "message": "Unknown or disabled partner app",
    "field": null
  }
}
```

**Example: A scope the app may not request (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_scope",
    "message": "this app may not request `triggers`",
    "field": null
  }
}
```

**Example: The redirect URI is not registered for the app (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "redirect_uri is not registered for this app",
    "field": null
  }
}
```

**Errors**

| HTTP | Code | When |
| --- | --- | --- |
| 400 | `—` | `client_id`, `redirect_uri` or `scope` is missing (plain-text body). |
| 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. |
| 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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/oauth/authorize

Access: app session only (API keys and partner tokens get 403)

Consent screen, step 2: the user approved. Returns the redirect URL carrying a single-use code (60 seconds).

**Body (JSON)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | yes | The partner app's client id. |
| `redirect_uri` | `string` | yes | One of the app's registered redirect URIs, exactly. |
| `scope` | `string` | yes | Requested scopes, space separated (`read`, `orders`, `triggers`); each must be allowed for the app. |
| `state` | `string \| null` | no | The partner's opaque `state`, returned unchanged (URL-encoded) in the redirect. |
| `code_challenge` | `string` | yes | PKCE challenge: base64url (no padding) SHA-256 of the partner's `code_verifier` (43 characters). |
| `code_challenge_method` | `string` | yes | Must be `S256` (plain PKCE is refused). |

```bash
curl -X POST "https://api.cirrus.trade/v1/oauth/authorize" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "cp_XwdEn1dFZHLEjiQdFZrN",
    "redirect_uri": "https://partner.example/callback",
    "scope": "read orders",
    "state": "af0ifjsldkj",
    "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    "code_challenge_method": "S256"
  }'
```

**Response `data` (200)**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `redirect_url` | `string` | yes | The registered `redirect_uri` with `code` (single use, valid 60 seconds) and, when one was sent, `state` (URL-encoded) appended to its query, e.g. `https://partner.example/cb?code=oc_...&state=xyz`. |

**Example: Approved: send the browser to the redirect URL (200)**

```json
{
  "status": "success",
  "data": {
    "redirect_url": "https://partner.example/callback?code=oc_kKvlHo8I44VhkZzyfq28JnXWS2NjrIp05txf7DFj&state=af0ifjsldkj"
  },
  "error": null
}
```

**Example: PKCE other than S256 is refused (422)**

```json
{
  "status": "error",
  "data": null,
  "error": {
    "code": "invalid_request",
    "message": "PKCE with code_challenge_method=S256 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. |
| 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 later. |
| 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`). |
| `[].scopes` | `string[]` | yes | Scopes the user granted. One of: `read`, `orders`, `triggers`. |
| `[].connected_at` | `string` | yes | RFC 3339 time of the first connection. |
| `[].updated_at` | `string` | yes | RFC 3339 time of the latest grant. |

**Example: One connected app (200)**

```json
{
  "status": "success",
  "data": [
    {
      "client_id": "cp_5C5uooATLM4C2m4Vd41e",
      "app_name": "Charts Co",
      "verified": false,
      "scopes": [
        "read",
        "orders"
      ],
      "connected_at": "2026-09-29T05:59:06.502090+00:00",
      "updated_at": "2026-09-29T05:59:06.502090+00:00"
    }
  ],
  "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 later. |
| 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_5C5uooATLM4C2m4Vd41e",
    "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

## Shared portfolios

Portfolios other users shared with you: who shares what, one screen across every shared account, and each view limited to what the share allows.

### GET /v1/shared

Access: app session only (API keys and partner tokens get 403)

The shares granted to you: owner, `scopes` and accounts. Use `owner_username` in the routes below.

```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-01M3MQZX0M1RYSP2CE8YHM2N65",
      "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/shared/consolidated

Access: app session only (API keys and partner tokens get 403)

One card per owner and shared account (balances, P&L, counts), plus every shared position.

```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. |
| `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. |
| `positions[].lot_size` | `integer (int64) \| null` | no | Contract lot size (added when the instrument is known). |
| `positions[].exchange` | `string \| null` | no | Exchange (`NSE`, `NFO`, `BSE`, `BFO`, `MCX`, ...). |
| `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[].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-01M3MQZX0M1RYSP2CE8YHM2N65",
        "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,
            "broker": "paper",
            "account_tag": "main",
            "last_login_at": "1790623872",
            "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,
            "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": 0,
        "owner_username": "owner-01M3MQZX0M1RYSP2CE8YHM2N65",
        "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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/shared/consolidated/refresh

Access: app session only (API keys and partner tokens get 403)

Ask for fresh data for every owner sharing with you (at most once per owner every 10 seconds).

```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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### POST /v1/shared/{owner}/refresh

Access: app session only (API keys and partner tokens get 403)

Ask for fresh data for one owner. Within 10 seconds of the last refresh nothing is asked and `requested` is null.

**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 later. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable, or the feature is off on this server; retry later. |

### GET /v1/shared/{owner}/{view}

Access: app session only (API keys and partner tokens get 403)

One view of the owner’s shared accounts. Each view needs its scope: `accounts` → `login`, `margins` → `funds`, the rest by name.

**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`. |
| `missing` | `object[]` | yes |  |
| `missing[].account` | `string` | yes | The broker account id. |
| `missing[].reason` | `string` | yes | Why there is no data. |

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": "1790623872",
        "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-28T19:31:12.056329Z",
        "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": 0,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "reason": "No live data yet for this account"
      }
    ]
  },
  "error": null
}
```

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

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-28T19:31:12.057977Z",
        "data": {
          "account": "O1",
          "opening_balance": 100000,
          "available": 80000,
          "utilised": 20000
        }
      }
    ],
    "missing": [
      {
        "account": "O2",
        "reason": "No live data yet for this account"
      }
    ]
  },
  "error": null
}
```

**Example: Today's orders not yet final (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-28T19:31:12.059804Z",
        "data": [
          {
            "broker_tag": null,
            "order_id": "PAPER-7",
            "parent_tag": null,
            "username": "owner-01M3MQZX0N9F3JN8RA4N535CZP",
            "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-28T19:31:12.059776Z",
            "created_at": "2026-09-28T19:31:12.059776Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "OPEN",
                "at": "2026-09-28T19:31:12.059776Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 0,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3MQZX1VF18XYYZ816DH4YMM"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "reason": "No live data yet for this account"
      }
    ]
  },
  "error": null
}
```

**Example: Today's final orders (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-28T19:31:12.059804Z",
        "data": [
          {
            "broker_tag": null,
            "order_id": "PAPER-6",
            "parent_tag": null,
            "username": "owner-01M3MQZX0N9F3JN8RA4N535CZP",
            "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-28T19:31:12.059804Z",
            "created_at": "2026-09-28T19:31:12.059804Z",
            "status_history": [
              {
                "from": "SUBMITTED",
                "to": "FILLED",
                "at": "2026-09-28T19:31:12.059804Z",
                "message": null
              }
            ],
            "lot_size": 1,
            "exchange": "NSE",
            "instrument_type": "EQUITY",
            "symbol": "RELIANCE",
            "expiry": "",
            "strike": 0,
            "option_type": "",
            "ltp": 2500,
            "prev_close": 0,
            "instrument_token": "CT:TEST:RELIANCE",
            "order_tag": "01M3MQZX1V0BKXD7R94R3N9WWD"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "reason": "No live data yet for this account"
      }
    ]
  },
  "error": null
}
```

**Example: Positions; the account without data is under missing (200)**

```json
{
  "status": "success",
  "data": {
    "accounts": [
      {
        "account": "O1",
        "updated_at": "2026-09-28T19:31:12.055576Z",
        "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": 0,
            "instrument_token": "CT:TEST:RELIANCE"
          }
        ]
      }
    ],
    "missing": [
      {
        "account": "O2",
        "reason": "No live data yet for this account"
      }
    ]
  },
  "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 later. |
| 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` | Filter group of a record, derived from its `kind`: `orders`, `protection`, `signals`, `account` or `access`. | `GET /v1/activity response items[].category`, `GET /v1/activity/{id} response category`, `stream activity data.category` |
| `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`, `signal_url_issued`, `signal_url_revoked`, `signal_url_rotated`, `webhook_created`, `webhook_updated`, `webhook_deleted`, `webhook_secret_rotated`, `webhook_disabled` | 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` |
| `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` |
| `ErrorCode` | `malformed_json`, `invalid_query`, `idempotency_key_required`, `invalid_idempotency_key`, `unauthorized`, `forbidden`, `own_session_required`, `oms_not_enabled`, `not_shared`, `account_not_found`, `instrument_not_found`, `trigger_not_found`, `api_key_not_found`, `connection_not_found`, `unknown_app`, `endpoint_not_found`, `webhook_not_found`, `activity_not_found`, `request_in_progress`, `trigger_busy`, `trigger_fired`, `broker_held`, `webhook_limit_reached`, `invalid_request`, `idempotency_key_reused`, `archive_day_too_large`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`, `slow_down`, `invalid_scope`, `temporarily_unavailable`, `rate_limited`, `broker_refused`, `service_unavailable`, `instruments_unavailable`, `trigger_worker_down`, `webhooks_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. \| \| `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. \| \| `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). \| \| `trigger_not_found` \| 404 \| No trigger with this id for the caller. \| \| `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. \| \| `activity_not_found` \| 404 \| No activity record with this id for the caller. \| \| `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. \| \| `webhook_limit_reached` \| 409 \| The webhook limit is reached; delete one first. \| \| `invalid_request` \| 422 \| The request is well-formed but not valid; `field` names the input. \| \| `idempotency_key_reused` \| 422 \| This `Idempotency-Key` was already used with a different request. \| \| `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. \| \| `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. \| \| `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 later. \| \| `broker_refused` \| 502 \| The broker refused the change. \| \| `service_unavailable` \| 503 \| A dependency is temporarily unavailable, or the feature is off on this server; retry later. \| \| `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. \| \| `unavailable` \| 503 \| Stream only: live updates are unavailable; reconnect. \| |  |
| `ErrorStatus` | `error` | Always `error`. |  |
| `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`, `POST /kuberhunt/execute-signal/{token} request event`, `signal KuberhuntEvent event` |
| `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` |
| `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). | `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`, `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/hooks/endpoints/{endpoint_id}/test response would_place[].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` |
| `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`, `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`, `GET /v1/gtt response [].gtts[].product`, `POST /v1/gtt request product`, `PATCH /v1/gtt/{trigger_id} request product`, `POST /v1/triggers request product`, `POST /v1/hooks/endpoints/{endpoint_id}/test response would_place[].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` | Where an order's protection lives: a broker `bracket` or `cover` order placed with the entry, a broker `gtt` placed once the entry fills, or a server-side `trigger` armed once the entry fills. | `POST /v1/orders response results[].protection` |
| `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`, `GET /v1/triggers response [].rules.target.type`, `GET /v1/triggers response [].rules.stop_loss.type`, `GET /v1/triggers response [].rules.trail.type`, `POST /v1/triggers request rules.target.type`, `POST /v1/triggers request rules.stop_loss.type`, `POST /v1/triggers request rules.trail.type`, `POST /v1/triggers response rules.target.type`, `POST /v1/triggers response rules.stop_loss.type`, `POST /v1/triggers response rules.trail.type`, `PATCH /v1/triggers/{trigger_id} request rules.target.type`, `PATCH /v1/triggers/{trigger_id} request rules.stop_loss.type`, `PATCH /v1/triggers/{trigger_id} request rules.trail.type`, `PATCH /v1/triggers/{trigger_id} response rules.target.type`, `PATCH /v1/triggers/{trigger_id} response rules.stop_loss.type`, `PATCH /v1/triggers/{trigger_id} response rules.trail.type` |
| `Scope` | `read`, `orders`, `triggers` | 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) or `triggers` (create, change and remove server-side triggers). 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`, `GET /v1/oauth/consent response scopes`, `GET /v1/connections response [].scopes` |
| `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`, `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`, `GET /v1/gtt response [].gtts[].side`, `POST /v1/gtt request side`, `PATCH /v1/gtt/{trigger_id} request side`, `POST /v1/triggers request side`, `GET /v1/activity response items[].side`, `GET /v1/activity/{id} response side`, `POST /v1/hooks/endpoints/{endpoint_id}/test response would_place[].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` |
| `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`, `POST /kuberhunt/execute-signal/{token} 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). |  |
| `SignalProvider` | `kuberhunt`, `tradingview`, `chartink` | The signal provider a URL is for: `tradingview` (TradingView alerts), `chartink` (Chartink scanner alerts) or `kuberhunt` (Kuberhunt recommendation events). | `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). |  |
| `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`, `DELETE /v1/orders/{order_id} response 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/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` |
| `StreamErrorCode` | `unavailable` | Why the stream cannot go on: `unavailable` (live updates could not be set up; reconnect). | `stream error code` |
| `SuccessStatus` | `success` | Always `success`. |  |
| `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` |
| `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). | `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` |
