---
title: JavaScript / TypeScript SDK
description: Official Pipsend SDK for Node.js and TypeScript (@pipsend/sdk). A typed client for the public REST API and the realtime WebSocket stream, with automatic authentication, token refresh and reconnection.
---

# JavaScript / TypeScript SDK

`@pipsend/sdk` is the official Pipsend SDK for Node.js and TypeScript. It is a typed client for the public `/api/v1` REST surface and the realtime WebSocket stream.

## Overview

### Features

- **Typed REST modules:** Accounts, Trading Groups, Positions, Orders, Trades, Market Data and Sessions.
- **Realtime stream** over WebSocket with auto-reconnect, automatic resubscription and a heartbeat.
- **Automatic authentication:** JWT login and transparent token handling, with rate-limit backoff.
- **Business identifiers:** accounts by `number`, trading groups by `path`, symbols by `code`.
- **Dual package** (ESM and CommonJS) with complete type definitions.

### Requirements

- Node.js 16 or later (`engines.node` is `>=16`).
- The SDK performs HTTP calls with the global `fetch` function. For the realtime stream it uses the global `WebSocket` when one exists and otherwise loads the `ws` package, which is installed as a dependency of the SDK.
- A Pipsend account **number** and its **API password**.

### Version 1.x

The current release is **1.2.0**. Version 1.x targets the `/api/v1` contract: business identifiers only (no internal database ids), unwrapped `{ data, meta }` envelopes, and a frame-based realtime protocol. It is **not backward compatible** with 0.x.

## Installation

```bash
npm install @pipsend/sdk
# or: yarn add @pipsend/sdk · pnpm add @pipsend/sdk
```

The package is published on [npm](https://www.npmjs.com/package/@pipsend/sdk).

## Quick start

```typescript
import { createClient } from "@pipsend/sdk";

const client = createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
});

// Accounts list (items + pagination meta)
const { items } = await client.accounts.list({ per_page: 20 });

// Open a market trade
const order = await client.trades.open({
  login: 10001,
  symbol: "EURUSD",
  action: "buy",
  type: "market",
  volume: 0.1,
  stop_loss: 1.085,
  take_profit: 1.095,
});

// Historical candles
const { data: candles } = await client.marketData.getCandles({
  symbol: "BTCUSD",
  timeframe: "1h",
  limit: 100,
});
```

`createClient(config: PipsendConfig): PipsendClient` is the main entry point. The `PipsendClient` class is also exported.

### The client object

| Member | Description |
|--------|-------------|
| `client.accounts` | Accounts API |
| `client.tradingGroups` | Trading groups API (read-only) |
| `client.marketData` | Market data API (historical) |
| `client.positions` | Positions API |
| `client.orders` | Orders API |
| `client.trades` | Trades API (open, close, modify) |
| `client.sessions` | Sessions API |
| `client.auth` | Authentication control |
| `client.stream` | Realtime WebSocket stream |
| `client.ping()` | Health check, returns `Promise<HealthCheckResponse>` |
| `client.getConfig()` | Returns a copy of the effective configuration (`Readonly<PipsendConfig>`) |

```typescript
const health = await client.ping();
// HealthCheckResponse: { status: 'ok' | 'error'; timestamp: string; version?: string }
```

### Response shapes

Responses are **unwrapped** from the server envelope, so you receive the payload directly. Two pagination shapes exist:

```typescript
// Accounts & Trading Groups → ItemsPage<T>
const page = await client.accounts.list({ page: 1, per_page: 20 });
page.items; // T[]
page.meta; // { page, per_page, total, last_page }

// Positions, Orders & Sessions → PaginatedList<T>
const res = await client.positions.list({ login: "10001" });
res.data; // T[]
res.meta.pagination; // { page, per_page, total }
```

Market data lists return `MarketDataPage<T>` (`{ data, meta? }`, see [Market data](#market-data)).

List endpoints accept `page` (default `1`) and `per_page` (default `20`, maximum `100`). Query parameters that are `undefined`, `null` or an empty string are not sent, and array values are sent as repeated parameters.

## Authentication

The SDK authenticates with the account **number** and its **API password**, and manages the token for you. You do not call a login method: create the client and make calls.

> The public `/api/v1/auth/login` validates the account's **API password**, not the master or investor password. Make sure the account has an `api_password` set.

### How it works

1. You create the client with `number` and `password`.
2. The first API call (or `client.stream.connect()`) triggers a login: `POST /api/v1/auth/login`.
3. The SDK caches the access token in memory, together with the account block from the response (including the internal `id` that the WebSocket topics need).
4. Every request is sent with an `Authorization: Bearer <token>` header.
5. When the token is expired, or within `refreshMarginMinutes` of expiring (default 5), the SDK obtains a new one before sending the next request.
6. If a request returns `401`, the SDK refreshes the token through `POST /api/v1/auth/refresh` once and retries the request. If the refresh fails, it falls back to a full login.

Token expiry is read from `access_expires_at` (ISO 8601) in the login response.

### Basic usage

```typescript
import { createClient } from "@pipsend/sdk";

const client = createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
  device: { application: "my-integration" }, // optional session metadata
});

// Authenticates automatically on the first call.
const { items } = await client.accounts.list();
```

### Auth control

```typescript
client.auth.isAuthenticated(); // boolean
client.auth.getToken(); // string | undefined
client.auth.getAccount(); // AuthAccount | undefined (number, id, email, balance, …)
await client.auth.refresh(); // force refresh / login

await client.auth.logout(); // close the current session server-side + clear token
await client.auth.logoutAll(); // close every session → { sessions_closed }
await client.auth.logoutOthers(); // close all but the current → { sessions_closed }
```

| Method | Returns | Description |
|--------|---------|-------------|
| `isAuthenticated()` | `boolean` | `true` while a token is held and it is not expired (counting the refresh margin). |
| `getToken()` | `string \| undefined` | The current access token, or `undefined` when there is no valid token. |
| `getAccount()` | `AuthAccount \| undefined` | The account block of the last successful login. |
| `refresh()` | `Promise<void>` | Forces a refresh. Falls back to a full login when there is no refresh token or the refresh fails. |
| `logout()` | `Promise<void>` | Closes the current session on the server (`POST /api/v1/auth/logout`) and clears the local token. |
| `logoutAll()` | `Promise<LogoutResult>` | Closes every session of the account, including the current one, and clears the local token. |
| `logoutOthers()` | `Promise<LogoutResult>` | Closes all sessions except the current one. The local token is kept. |

`logout()` and `logoutAll()` are asynchronous. After either of them, the next API call logs in again.

### Login response

The `account` block of the login response is available through `client.auth.getAccount()`:

```jsonc
{
  "access_token": "eyJ...",
  "token": "eyJ...",                  // legacy alias of access_token
  "access_expires_at": "2026-06-08T12:00:00Z",
  "refresh_token": "eyJ...",
  "refresh_expires_at": "2026-06-14T12:00:00Z",
  "account": { "id": 5, "number": 10001, "email": "...", "account_type": "real",
               "balance": { "balance": 10000, "credit": 0, "currency": "USD" } },
  "session_id": 99,
  "roles": [],
  "permissions": []
}
```

The `account.id` is the only internal id surfaced. It is exposed because the per-account WebSocket subject is keyed by it server-side.

### Login rate limiting and backoff

The login endpoint is rate-limited (per IP and per account, in 15-minute windows). When a login fails, the SDK records a **cooldown** so that later calls, for example a WebSocket reconnect loop, back off instead of hammering the endpoint. During the cooldown, calls throw the stored error without a network request.

| Failure | Cooldown |
|---------|----------|
| `429 rate_limited` | `Retry-After` seconds (or 60 s when the header is missing) |
| `401` / `403` (`invalid_credentials`) | 30 s |
| Missing `number` or `password` (`missing_credentials`) | 30 s |
| Network error (`network_error`) | 5 s |
| Any other status | 5 s |

Errors are `AuthenticationError` (a `PipsendError`) with `.code` and, on a `429`, `.retryAfter`:

```typescript
import { AuthenticationError } from "@pipsend/sdk";

try {
  await client.auth.refresh();
} catch (error) {
  if (error instanceof AuthenticationError) {
    if (error.code === "rate_limited") {
      console.warn(`rate limited; retry in ${error.retryAfter ?? 60}s`);
    } else if (error.code === "invalid_credentials") {
      console.error("check the account number / API password");
    }
  }
}
```

### Custom refresh margin

```typescript
createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
  refreshMarginMinutes: 10, // refresh 10 minutes before expiry
});
```

### Security notes

- Tokens are held in memory only (never persisted to disk or `localStorage`).
- Use `https://` and `wss://` in production.
- Keep credentials in environment variables, not in source code.

## Configuration options

```typescript
const client = createClient({
  server: "http://localhost:8080", // required
  number: 10001, // account number
  password: "your-api-password",
  device: { application: "my-integration" }, // optional session metadata
  timezone: "UTC", // optional
  autoRefresh: true, // optional (default true)
  refreshMarginMinutes: 5, // refresh this many minutes before expiry
  websocket: {
    enabled: true,
    autoConnect: false,
    autoReconnect: true,
    maxReconnectAttempts: 5,
    reconnectBaseDelayMs: 1000,
    reconnectMaxDelayMs: 30000,
    heartbeatIntervalMs: 15000,
  },
});
```

### `PipsendConfig`

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `server` | `string` | required | Pipsend server URL. Must be a valid URL. |
| `number` | `number` | none | Account commercial number used to log in. |
| `password` | `string` | none | The account's API password. |
| `device` | `DeviceInfo` | none | Optional device metadata attached to the session on login. |
| `timezone` | `string` | `"UTC"` | Timezone for operations. |
| `autoRefresh` | `boolean` | `true` | Enable automatic token refresh. |
| `refreshMarginMinutes` | `number` | `5` | Refresh margin, in minutes before token expiry. |
| `websocket` | `WebSocketConfig` | none | Realtime stream configuration. The stream is disabled unless `websocket.enabled` is `true`. |

`number` and `password` are typed as optional, but without them the first authenticated call throws an `AuthenticationError` with code `missing_credentials`.

### `DeviceInfo`

All fields are optional: `ip`, `mac`, `hostname`, `device_id`, `device_type`, `os`, `browser`, `user_agent`, `application`.

### `WebSocketConfig`

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | `boolean` | `false` | Enable the WebSocket manager. |
| `autoConnect` | `boolean` | `false` | Connect automatically when the client is created. |
| `path` | `string` | `/api/v1/ws` | Endpoint path. |
| `autoReconnect` | `boolean` | `true` | Reconnect after an unexpected disconnect. |
| `maxReconnectAttempts` | `number` | `5` | Maximum reconnection attempts. |
| `reconnectBaseDelayMs` | `number` | `1000` | Base backoff delay in milliseconds. |
| `reconnectMaxDelayMs` | `number` | `30000` | Maximum backoff delay in milliseconds. |
| `heartbeatIntervalMs` | `number` | `15000` | Application-level ping interval in milliseconds. `0` disables the heartbeat and the liveness watchdog. |

## Accounts

Accounts are addressed by their commercial **number** (the login).

```typescript
await client.accounts.list({ search: "john", per_page: 20 }); // ItemsPage<Account>
await client.accounts.get(10001); // Account
await client.accounts.getStatus(10001); // { login, balance, equity, credit, margin, currency }
await client.accounts.getRights(10001); // bitmask + unpacked booleans
await client.accounts.getPermissions(10001); // { account_id, roles, permissions }

await client.accounts.create({
  trading_group: "demo/standard", // path (exact match)
  country: "CO", // ISO 3166 alpha-2
  first_name: "John",
  last_name: "Doe",
  email: "john@example.com",
  master_password: "secret123",
  investor_password: "viewer123", // optional
  api_password: "apikey123", // optional
  leverage: "1:100", // "1:100" or 100
});

await client.accounts.update(10001, { name: "Renamed", phone: "3160000000" });
await client.accounts.setPassword(10001, { kind: "api", password: "newsecret1" });
await client.accounts.updateRights(10001, {
  rights: ["RIGHT_ENABLED", "RIGHT_LOGIN_ENABLED", "RIGHT_TRADE_ENABLED"],
});
await client.accounts.adjust(10001, { type: "balance", amount: 150, comment: "deposit" });
await client.accounts.archive(10001, { reason: "inactive" });
await client.accounts.unarchive(10001);
await client.accounts.delete(10001); // soft-delete
```

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `list(params?: AccountsListParams)` | `Promise<ItemsPage<Account>>` | List accounts with filters and pagination. |
| `get(accountNumber: number)` | `Promise<Account>` | Get one account by number. |
| `create(request: CreateAccountRequest)` | `Promise<Account>` | Create an account. |
| `update(accountNumber: number, request: UpdateAccountRequest)` | `Promise<Account>` | Partial update; only the keys you send change. |
| `delete(accountNumber: number)` | `Promise<void>` | Soft-delete (archive) the account. |
| `getStatus(accountNumber: number)` | `Promise<AccountStatus>` | Live financial snapshot. |
| `getRights(accountNumber: number)` | `Promise<AccountRights>` | Rights bundle: bitmask plus unpacked booleans. |
| `updateRights(accountNumber: number, request: UpdateRightsRequest)` | `Promise<UpdateRightsResponse>` | Replace the whole rights set from `RIGHT_*` names. |
| `getPermissions(accountNumber: number)` | `Promise<AccountPermissions>` | Roles and permissions of the account. |
| `setPassword(accountNumber: number, request: SetPasswordRequest)` | `Promise<void>` | Set the master, investor or API password. |
| `adjust(accountNumber: number, request: AdjustBalanceRequest)` | `Promise<Account>` | Adjust balance or credit with a signed amount. |
| `archive(accountNumber: number, request?: ArchiveAccountRequest)` | `Promise<void>` | Archive the account. |
| `unarchive(accountNumber: number)` | `Promise<void>` | Unarchive the account. |

### `list` parameters (`AccountsListParams`)

| Field | Type | Description |
|-------|------|-------------|
| `page`, `per_page` | `number` | Pagination. |
| `number` | `number` | Filter by commercial number. |
| `email` | `string` | Filter by email. |
| `trading_group_id`, `provider_id`, `country_id`, `ib_account_id` | `number` | Filter by the corresponding id. |
| `archived` | `boolean` | Only archived (`true`) or non-archived (`false`). |
| `closed` | `boolean` | Only closed (`true`) or open (`false`). |
| `search` | `string` | Free-text search. |
| `sort` | `string` | Sort field (default: `created_at`). |
| `order` | `"asc" \| "desc"` | Sort order (default: `desc`). |

### `create` parameters (`CreateAccountRequest`)

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `trading_group` | `string` | Yes | Trading group path, exact match. |
| `first_name`, `last_name` | `string` | Yes | Holder name. |
| `country` | `string` | Yes | ISO 3166 alpha-2 code. |
| `master_password` | `string` | Yes | Minimum 8 characters. |
| `investor_password`, `api_password` | `string` | No | Minimum 8 characters. |
| `currency` | `string` | No | Defaults to the trading group currency. |
| `leverage` | `string \| number` | No | `"1:100"` or `100`; defaults to the group default. A number is converted to a string by the SDK. |
| `number` | `number` | No | Auto-assigned when omitted. |
| `name` | `string` | No | Account label. |
| `email`, `phone`, `middle_name`, `company`, `state`, `city`, `address`, `zip_code`, `comment` | `string` | No | Profile fields. |

`UpdateAccountRequest` accepts the same profile fields (and `trading_group`, `currency`, `leverage`, `name`, `first_name`, `last_name`, `country`), all optional.

### Passwords

`setPassword` takes `{ kind: "master" | "investor" | "api"; password: string }` (`PasswordKind`).

```typescript
await client.accounts.setPassword(10001, { kind: "api", password: "newsecret1" });
```

### Balance and credit adjustments

`AdjustBalanceRequest`:

| Field | Type | Description |
|-------|------|-------------|
| `type` | `"balance" \| "credit"` | What to adjust. |
| `amount` | `number` | Signed: positive credits, negative debits. |
| `comment` | `string` | Optional. |
| `idempotency_key` | `string` | Optional. Generated server-side when omitted. |

### Account rights

`getRights` returns an `AccountRights` object: `rights` (the bitmask as a number) and the booleans `enabled`, `login_enabled`, `trade_enabled`, `ea_enabled`, `read_only`, `api_access_enabled`, `balance_read_only`, `deposit_enabled`, `withdraw_enabled`, `push_enabled`, `force_pwd_reset`.

`updateRights` takes `{ rights: AccountPermission[] | string[] }` (at least one entry) and returns `{ number: number; rights: string[] }`.

| Permission | Value | Description |
|------------|-------|-------------|
| `RIGHT_ENABLED` | 1 | Account is active |
| `RIGHT_LOGIN_ENABLED` | 2 | Can log in |
| `RIGHT_TRADE_ENABLED` | 4 | Can trade |
| `RIGHT_EA_ENABLED` | 8 | Can use Expert Advisors/bots |
| `RIGHT_READONLY` | 16 | Read-only mode |
| `RIGHT_API_ACCESS_ENABLED` | 32 | Can use the API |
| `RIGHT_BALANCE_READONLY` | 64 | Balance is read-only |
| `RIGHT_DEPOSIT_ENABLED` | 128 | Can deposit |
| `RIGHT_WITHDRAW_ENABLED` | 256 | Can withdraw |
| `RIGHT_PUSH_NOTIFICATIONS_ENABLED` | 512 | Can receive push notifications |
| `RIGHT_FORCE_PASSWORD_RESET` | 1024 | Must reset the password on next login |

> `updateRights()` **replaces all existing permissions**; it does not add to them. Always include at least `RIGHT_ENABLED` and `RIGHT_LOGIN_ENABLED` for a functional account.

```typescript
// Read-only mode
await client.accounts.updateRights(10001, {
  rights: ["RIGHT_ENABLED", "RIGHT_LOGIN_ENABLED", "RIGHT_READONLY", "RIGHT_BALANCE_READONLY"],
});
```

### Account permissions (roles)

`getPermissions` returns `{ account_id: number; roles: string[]; permissions: { action: string; object: string }[] }`.

### `Account` fields

`number`, `name`, `currency`, `leverage`, `rights`, `trading_group` (`{ name, path }`), `country` (`{ iso, name } | null`), `first_name`, `last_name`, `middle_name`, `company`, `email`, `phone`, `state`, `city`, `address`, `zip_code`, `comment`, `balance`, `credit`, `closed_at`, `archived_at`, `archive_reason`, `created_at`, `updated_at`.

## Trading groups

Trading groups are read-only and addressed by `name` or hierarchical `path`.

```typescript
await client.tradingGroups.list({ per_page: 100 }); // ItemsPage<TradingGroup>
await client.tradingGroups.getByName("standard"); // TradingGroup
await client.tradingGroups.getByPath("demo/standard"); // TradingGroup
await client.tradingGroups.tree(); // TradingGroupTreeNode[] (forest)
```

| Method | Returns | Description |
|--------|---------|-------------|
| `list(params?: TradingGroupsListParams)` | `Promise<ItemsPage<TradingGroup>>` | Paginated list. Params: `page`, `per_page`, `search`. |
| `getByName(name: string)` | `Promise<TradingGroup>` | Lookup by name. |
| `getByPath(path: string)` | `Promise<TradingGroup>` | Lookup by hierarchical path (exact match). |
| `tree()` | `Promise<TradingGroupTreeNode[]>` | Forest view. Each node is `{ group: TradingGroup; children?: TradingGroupTreeNode[] }`. |

`TradingGroup` includes `name`, `path`, `company`, `type`, `is_demo`, `enable_ea`, `enable_swaps`, `inactivity_period`, `enable_trailing_stop`, `is_hedging`, `margin_call`, `stop_out`, `stop_out_fully_account`, `stop_is_percentage`, `allow_negative_balance`, `allow_credit_negative`, `compensate_negative_balance`, `withdraw_credit_after_negative`, `sort_order`, `commission_type`, `commission_period`, `commission_value`, `default_currency`, `default_leverage`, `created_at` and `updated_at`. Decimal fields (`margin_call`, `stop_out`, `commission_value`) are strings.

## Positions

Positions are addressed by their `id`. Accounts in filters are referenced by `login` (a comma-separated list of account numbers).

```typescript
await client.positions.list({ login: "10001", state: "open" }); // PaginatedList<Position>
await client.positions.history({ login: "10001", from_date: "2026-01-01", to_date: "2026-06-30" });
await client.positions.get(888); // Position

await client.positions.setSLTP(888, { stop_loss: 1.085, take_profit: 1.13 }); // null clears
await client.positions.setTrailingStop(888, { enabled: true, distance: 20, distance_type: "pips" });
await client.positions.close(888, { qty: 0.5 }); // omit qty for full close
await client.positions.closeAll({ login: "10001", pnl: "negative" });
```

`from_date` and `to_date` are `YYYY-MM-DD`.

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `list(params?: PositionsListParams)` | `Promise<PaginatedList<Position>>` | List positions with filters and pagination. |
| `history(params?: PositionsHistoryParams)` | `Promise<PaginatedList<Position>>` | Closed positions. |
| `get(id: number)` | `Promise<Position>` | One position. |
| `setSLTP(id: number, request: SetSLTPRequest)` | `Promise<Position>` | Modify stop-loss and take-profit. `null` clears a level. |
| `setTrailingStop(id: number, request: TrailingStopRequest)` | `Promise<Position>` | Enable or disable the trailing stop. |
| `close(id: number, request?: ClosePositionRequest)` | `Promise<ClosePositionResult>` | Close a position with a reduce-only market counter-order. |
| `closeAll(params?: CloseAllParams)` | `Promise<CloseAllResult>` | Close every position the token can see, optionally scoped or filtered by PnL. |

### Parameters

`PositionsListParams`:

| Field | Type | Description |
|-------|------|-------------|
| `page`, `per_page` | `number` | Pagination. |
| `login` | `string` | Comma-separated account numbers, e.g. `"10001,10002"`. |
| `symbol` | `string` | Symbol code. |
| `side` | `0 \| 1` | `0` BUY, `1` SELL. |
| `mode` | `number` | Position mode. |
| `open_only` | `boolean` | Only open positions. |
| `state` | `"open" \| "closed"` | Position state. |
| `sort` | `string` | Sort field. |
| `order` | `"asc" \| "desc"` | Sort order. |

`PositionsHistoryParams`: `page`, `per_page`, `login`, `symbol`, `from_date`, `to_date` (`YYYY-MM-DD`), `sort_by`, `sort_order` (`"asc" | "desc"`).

`SetSLTPRequest`: `{ stop_loss?: number | null; take_profit?: number | null }`.

`TrailingStopRequest`: `{ enabled: boolean; distance?: number; distance_type?: "pips" | "price" }`. `distance` is required when `enabled` is `true`.

`ClosePositionRequest`: `{ qty?: number }`. The position is fully closed when `qty` is omitted.

`CloseAllParams`: `{ login?: string; pnl?: "positive" | "negative" | "all" }`. When `login` is omitted, the scope is every position the token can see.

### Results

`ClosePositionResult`: `{ order_id: number; client_order_id: string; position_id: number; qty: string; status: string }`.

`CloseAllResult`:

```typescript
{
  closed: Array<{ position_id: number; order_id: number; status: string; pnl: string }>;
  failed: Array<{ position_id: number; error: string }>;
  total: number;
  total_failed: number;
  total_skipped: number;
  pnl_filter: string;
}
```

The close is asynchronous: the result confirms that the counter-order was accepted. Track the fill through the order (`order_id`), through the position's `qty`, or through the [realtime stream](#websocket).

### `Position` fields

`id`, `symbol_code`, `opening_order_id`, `mode`, `mode_name`, `side` (`0` BUY, `1` SELL), `side_name`, `qty`, `initial_qty`, `avg_price`, `realized_pnl`, `unrealized_pnl`, `used_margin`, `cum_commission`, `cum_swap`, `stop_loss`, `take_profit`, `ts_enabled`, `ts_distance`, `ts_price`, `best_price`, `version`, `closed_at`, `exit_avg`, `close_reason`, `close_comment`, `opened_at`, `updated_at`, `contract_size`, `tick_size`, `digits`, `mark_price`, `change`, `change_pct` and `account` (`{ number, currency, type, first_name, last_name, email }`).

## Orders

Enums are numeric, with `*_name` labels alongside:

- `side`: `0` BUY, `1` SELL
- `type`: `0` MARKET, `1` LIMIT, `2` STOP, `3` STOP_LIMIT
- `tif`: `0` GTC, `1` IOC, `2` FOK
- `status`: `2` ACCEPTED, `3` PARTIAL, `4` FILLED, `5` CANCELLED, `6` REJECTED

```typescript
await client.orders.list({ login: "10001", status: ["ACCEPTED", "PARTIAL"] }); // PaginatedList<Order>
await client.orders.history({ login: "10001", from_date: "2026-01-01" });
await client.orders.get(4242); // Order
await client.orders.modify(4242, { limit_price: "1.1060", stop_loss: "1.0950" });
await client.orders.cancel(4242, { reason: "manual" });
```

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `list(params?: OrdersListParams)` | `Promise<PaginatedList<Order>>` | List orders with filters and pagination. |
| `history(params?: OrdersHistoryParams)` | `Promise<PaginatedList<Order>>` | Terminal orders. |
| `get(id: number)` | `Promise<Order>` | One order by id. |
| `modify(id: number, request: ModifyOrderRequest)` | `Promise<Order>` | Modify a working order. |
| `cancel(id: number, request?: CancelOrderRequest)` | `Promise<Order>` | Cancel a working order. |

### Parameters

`OrdersListParams`:

| Field | Type | Description |
|-------|------|-------------|
| `page`, `per_page` | `number` | Pagination. |
| `login` | `string` | Comma-separated account numbers. |
| `symbol` | `string` | Symbol code. |
| `side` | `0 \| 1` | Order side. |
| `type` | `string \| string[]` | Repeatable: `"LIMIT"`, `"STOP"`, … |
| `status` | `string \| string[]` | Repeatable: `"ACCEPTED"`, `"PARTIAL"`, `"FILLED"`, `"CANCELLED"`, `"REJECTED"`. |
| `reduce_only` | `boolean` | Filter reduce-only orders. |

`OrdersHistoryParams`: `page`, `per_page`, `login`, `symbol`, `side`, `type`, `status`, `from_date`, `to_date` (`YYYY-MM-DD`), `sort_by`, `sort_order` (`"asc" | "desc"`).

`ModifyOrderRequest`: `{ limit_price?: string; stop_loss?: string; take_profit?: string }`. Prices are strings.

`CancelOrderRequest`: `{ reason?: string; cancelled_by?: string }`.

Modifying or cancelling an order that is already FILLED or CANCELLED fails with the `order_terminal` error (HTTP 409).

### `Order` fields

`id`, `client_order_id`, `symbol_code`, `side`, `type`, `tif`, `status`, `side_name`, `type_name`, `tif_name`, `status_name`, `qty`, `limit_price`, `stop_price`, `trigger_price`, `stop_loss`, `take_profit`, `filled_qty`, `required_margin`, `margin_currency`, `hold_id`, `lp_order_id`, `router`, `rejected_code`, `reduce_only`, `position_id`, `comment`, `version`, `created_at`, `updated_at`. Quantities and prices are strings.

## Trades

The Trades API opens, closes and modifies trades through a compact, symbol-and-login based surface.

```typescript
await client.trades.open({
  login: 10001,
  symbol: "EURUSD",
  action: "buy", // buy | sell
  type: "market", // market | limit | stop | stop_limit
  volume: 0.1,
  stop_loss: 1.075,
  take_profit: 1.095,
});
await client.trades.close({ login: 10001, position_id: 888, volume: 0.05 });
await client.trades.modify({ login: 10001, position_id: 888, stop_loss: 1.08 });
```

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `open(request: OpenTradeRequest)` | `Promise<OpenTradeResponse>` | Open a market order or create a pending order. |
| `close(request: CloseTradeRequest)` | `Promise<CloseTradeResponse>` | Close a position, fully or partially, by `position_id`. |
| `modify(request: ModifyTradeRequest)` | `Promise<ModifyTradeResponse>` | Modify the stop-loss and take-profit of a position. |

### Open a trade

`OpenTradeRequest`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `login` | `number` | Yes | Account number. |
| `symbol` | `string` | Yes | Symbol code. |
| `action` | `"buy" \| "sell"` | Yes | Trade direction. |
| `type` | `"market" \| "limit" \| "stop" \| "stop_limit"` | Yes | Order type. |
| `volume` | `number` | Yes | Volume in lots. |
| `price` | `number` | For `limit` / `stop` | Order price. |
| `stop_price` | `number` | For `stop_limit` | Trigger price. |
| `stop_loss` | `number \| null` | No | Stop-loss level. |
| `take_profit` | `number \| null` | No | Take-profit level. |
| `client_order_id` | `string` | No | Auto-generated when omitted. |
| `comment` | `string` | No | Free text. |

`OpenTradeResponse`: `{ order_id: number; client_order_id: string; account_number: number; symbol: string; side: string; type: string; qty: string; status: string }`.

```typescript
// Pending buy limit order
await client.trades.open({
  login: 10001,
  symbol: "EURUSD",
  action: "buy",
  type: "limit",
  volume: 0.1,
  price: 1.08,
});
```

### Close a trade

`CloseTradeRequest`: `{ login: number; position_id: number; volume?: number; comment?: string }`. The position is fully closed when `volume` is omitted.

`CloseTradeResponse`: `{ order_id: number; position_id: number; qty: string; status: string }`.

### Modify a trade

`ModifyTradeRequest`: `{ login: number; position_id: number; stop_loss?: number | null; take_profit?: number | null }`. At least one of `stop_loss` or `take_profit` is required; `null` clears the level.

`ModifyTradeResponse`: `{ position_id: number; status: string }`.

Trade endpoints are subject to the Trade rate-limit tier (see [Rate limits](#rate-limits)).

## Market data

Market data is served by the MarketData service through the `/api/v1/md/*` proxy. It is **historical only**: data must be at least about one hour old, so there is no real-time data in this module. For live prices, use the [`tick` and `ohlc` topics](#topics).

```typescript
await client.marketData.getCandles({ symbol: "BTCUSD", timeframe: "1h", limit: 100 }); // MarketDataPage<Candle>
await client.marketData.getSymbols({ group: "CRYPTO_MAJOR", has_data: true }); // MarketDataPage<Symbol>
await client.marketData.getGroups({ root_only: true }); // Group[] (never paginated)
```

Timeframes: `1m, 5m, 15m, 1h, 4h, 1d`.

| Method | Returns | Description |
|--------|---------|-------------|
| `getCandles(params: CandlesParams)` | `Promise<MarketDataPage<Candle>>` | Historical OHLCV candles. |
| `getSymbols(params?: SymbolsParams)` | `Promise<MarketDataPage<Symbol>>` | Available symbols with metadata. |
| `getGroups(params?: GroupsParams)` | `Promise<Group[]>` | Symbol groups (never paginated). |

`MarketDataPage<T>` is `{ data: T[]; meta?: MarketDataMeta }`, where `MarketDataMeta` is `{ total, page, per_page, total_pages, has_next, has_prev }`.

### Candles

`CandlesParams`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `symbol` | `string` | Yes | Symbol codes, comma-separated. |
| `timeframe` | `string` | Yes | Timeframes (`1m,5m,15m,1h,4h,1d`), comma-separated. |
| `from`, `to` | `string` | No | Time range. |
| `min_volume`, `max_volume` | `number` | No | Volume range. |
| `min_price`, `max_price` | `number` | No | Price range. |
| `sort` | `string` | No | Sort field. |
| `order` | `"asc" \| "desc"` | No | Sort order. |
| `limit` | `number` | No | Maximum 10000; ignores pagination. |
| `page`, `per_page` | `number` | No | Pagination. |

`Candle`: `{ symbol, timeframe, timestamp, open, high, low, close, volume }`.

### Symbols

`SymbolsParams`: `name`, `group`, `type`, `search`, `is_active`, `has_data`, `include_data_range`, `sort`, `order`, `page`, `per_page`.

`Symbol`: `name`, `display_name`, `description`, `group_path`, `type`, `is_active`, `has_data`, and the optional `timeframes`, `first_candle`, `last_candle`.

The candle-derived metadata (`timeframes`, `first_candle`, `last_candle`) is only present when you request it with `include_data_range: true`:

```typescript
await client.marketData.getSymbols({ group: "CRYPTO_MAJOR", include_data_range: true });
```

### Groups

`GroupsParams`: `code`, `path`, `search`, `is_active`, `level`, `root_only`, `sort`, `order`.

`Group`: `{ code, name, path, level, is_active, order, symbol_count }`.

## Sessions

```typescript
await client.sessions.list(); // PaginatedList<Session>
await client.sessions.history();
await client.sessions.terminate(99);
```

| Method | Returns | Description |
|--------|---------|-------------|
| `list(params?: SessionsListParams)` | `Promise<PaginatedList<Session>>` | Active sessions for the token. |
| `history(params?: SessionsListParams)` | `Promise<PaginatedList<Session>>` | Session history. |
| `terminate(id: number)` | `Promise<void>` | Terminate one session. |

`SessionsListParams` is the pagination pair `page` and `per_page`.

`Session` fields: `id`, `jti`, `token_type`, `logged_with`, `status`, the optional device fields (`ip`, `mac`, `hostname`, `device_id`, `device_type`, `os`, `browser`, `user_agent`, `application`), `created_at`, `last_seen_at`, `last_ip`, `expires_at`, `terminated_at` and `termination_reason`.

To close sessions in bulk, use `client.auth.logoutAll()` or `client.auth.logoutOthers()` (see [Auth control](#auth-control)).

## WebSocket

The realtime stream uses a frame-based protocol at `/api/v1/ws`. Topics are keyed by the account **number** (login) and the symbol **code**. Build them with the `topics` helper.

Nothing streams until you subscribe: there is no auto-subscribe after connecting.

```typescript
import { createClient, topics } from "@pipsend/sdk";

const client = createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
  websocket: { enabled: true, autoConnect: false },
});

await client.stream.connect();

// Subscribe to specific topics or wildcards
client.stream.subscribe([
  topics.position(10001),
  topics.accountMetrics(10001),
  topics.tick("BTCUSD"),
]);
// Firehose: client.stream.subscribe([topics.position("*"), topics.order("*")]);

// Listen to a topic pattern ("position:*" matches every account's positions)
client.stream.onTopic("position:*", ({ topic, data }) => {
  if (data.event_type === "position.opened") {
    console.log("opened", data.login, data.symbol_code, data.qty);
  }
});

// Or every push frame
client.stream.onPush(({ topic, data }) => console.log(topic, data));

client.stream.onConnected(() => console.log("connected"));
client.stream.onDisconnected(() => console.log("disconnected"));
client.stream.onError((e) => console.error(e));

client.stream.unsubscribe([topics.tick("BTCUSD")]);
client.stream.disconnect();
```

The stream is only available when `websocket.enabled` is `true`. Otherwise, calling a method that needs it throws a `WebSocketError` ("WebSocket not enabled. Set websocket.enabled: true in config").

### Connecting

The connection URL is built from `server` (`http` becomes `ws`, `https` becomes `wss`) plus the `path` option (default `/api/v1/ws`). The access token is passed as the `access_token` query parameter. `connect()` obtains a valid token first; reconnections reuse the cached token, so there is no re-login storm.

| Method | Returns | Description |
|--------|---------|-------------|
| `connect()` | `Promise<void>` | Connect. Resolves when the socket is open; does nothing if already connected. Rejects with `WebSocketError` if the connection fails, or with `AuthenticationError` if login fails. |
| `disconnect()` | `void` | Close the connection. **Also clears all subscriptions and all registered listeners.** |
| `isConnected()` | `boolean` | Whether the socket is currently connected. |
| `getSubscriptions()` | `string[]` | Topics currently subscribed. |

Set `websocket.autoConnect: true` to connect when the client is created. A failure in that background connection is logged with `console.error`.

### Topic helpers

```typescript
import { topics } from "@pipsend/sdk";
```

| Helper | Topic string | Keyed by |
|--------|--------------|----------|
| `topics.position(login)` | `position:<login>` | login |
| `topics.positionsPnl(login)` | `positions:pnl:<login>` | login |
| `topics.accountMetrics(login)` | `account:metrics:<login>` | login |
| `topics.order(login)` | `order:<login>` | login |
| `topics.orderRejected(login)` | `order:rejected:<login>` | login |
| `topics.trade(login)` | `trade:<login>` | login |
| `topics.alert(login)` | `alert:<login>` | login |
| `topics.tick(symbol)` | `tick:<symbol>` | symbol code |
| `topics.ohlc(symbol, timeframe)` | `ohlc:<symbol>:<timeframe>` | symbol code |
| `topics.ohlcFinal(symbol, timeframe)` | `ohlc-final:<symbol>:<timeframe>` | symbol code |

Pass `"*"` as the login to build an account-scoped wildcard (the "firehose") topic. Wildcard subscriptions require the tracking permission on the token; without it, a token can only subscribe to its own account's topics plus the public market topics (`tick:*`, `ohlc:*`).

### Subscribing

| Method | Returns | Description |
|--------|---------|-------------|
| `subscribe(topics: string[], id?: string)` | `void` | Subscribe to topics. `id` is an optional correlation token echoed back on the `ack` event. |
| `unsubscribe(topics: string[])` | `void` | Unsubscribe from topics. |
| `ping(id?: string)` | `void` | Send an application-level `ping`; the server replies with a `pong`. |

You can call `subscribe` before the socket is connected: the topics are remembered and sent as soon as the connection opens. An `ack` only means the frame was processed, not that every topic was authorized: topics you are not permitted to see are skipped silently.

### Topics

| Topic | Payload `data` |
|-------|----------------|
| `position:<login>` | Position lifecycle. `event_type` is `position.opened`, `position.updated` or `position.closed`. SL/TP edits arrive as `position.updated`. |
| `positions:pnl:<login>` | Live account PnL snapshot (`PnlSnapshot`). |
| `account:metrics:<login>` | Account metrics (`AccountMetricsEvent`). |
| `order:<login>` | Order events. `event_type` is `order.accepted`, `order.filled`, `order.partial`, `order.modified`, `order.cancelled` or `order.rejected`. |
| `order:rejected:<login>` | Rejected orders on a dedicated channel (`order.rejected`). |
| `trade:<login>` | Fills only (`order.filled` and `order.partial`). |
| `alert:<login>` | Alert events. |
| `tick:<symbol>` | `{ bid, ask, mid, ts }` (snapshot on subscribe, then live). |
| `ohlc:<symbol>:<tf>` | Partial (forming) candle `{ ts, o, h, l, c, v, ver }`. |
| `ohlc-final:<symbol>:<tf>` | Closed candle. |

`<tf>` is a timeframe such as `1m`, `5m`, `15m`, `1h`, `4h` or `1d`. Every account-scoped push carries `login` and `symbol_code`.

### Listening to events

| Method | Description |
|--------|-------------|
| `onTopic(pattern: string, callback: StreamCallback<PushFrame>)` | Listen to pushes whose topic matches `pattern`. A trailing `*` matches by prefix (e.g. `position:*`, `tick:*`); otherwise the match is exact. The callback receives `{ topic, data }`. |
| `onPush(callback: StreamCallback<PushFrame>)` | Listen to every push frame `{ topic, data }`. |
| `onConnected(callback)` | The connection is open. |
| `onDisconnected(callback)` | The connection closed. |
| `onReconnecting(callback)` | A reconnection attempt is scheduled. |
| `onError(callback)` | A socket or heartbeat error occurred. |
| `onPong(callback)` | Fires on every `pong` from the server. Useful as a liveness signal or a "last seen" timestamp. |
| `on(event: StreamEvent, callback)` | Register a listener for any event: `'connected'`, `'disconnected'`, `'reconnecting'`, `'error'`, `'ack'`, `'pong'` or `'push'`. |
| `off(event: StreamEvent, callback?)` | Remove one listener, or every listener of the event when `callback` is omitted. |

Event payloads:

| Event | Payload |
|-------|---------|
| `connected` | `{ timestamp }` |
| `disconnected` | `{ code, reason, timestamp }` |
| `reconnecting` | `{ attempt, delay }` (delay in milliseconds) |
| `ack` | `{ id }` |
| `pong` | `{ id }` |
| `push` | `{ topic, data }` |
| `error` | The underlying socket error, or a `WebSocketError` ("Heartbeat timeout: no response from server") |

When the server groups several events in a `batch` frame, the SDK unpacks it and calls your listeners once per event. A listener that throws does not interrupt delivery to the others, and malformed frames are ignored.

### Event types

```typescript
type PositionEventType = 'position.opened' | 'position.updated' | 'position.closed';

interface PositionEvent {
  event_type: PositionEventType;
  id: number;
  login: number;
  symbol_code: string;
  side: 0 | 1;
  side_name: string;
  qty: number;
  avg_price: number;
  unrealized_pnl: number;
  [key: string]: any;
}

type OrderEventType =
  | 'order.accepted'
  | 'order.filled'
  | 'order.partial'
  | 'order.modified'
  | 'order.cancelled'
  | 'order.rejected';

interface OrderEvent {
  event_type: OrderEventType;
  login: number;
  symbol_code: string;
  [key: string]: any;
}

interface PnlSnapshot {
  login: number;
  currency: string;
  equity: number;
  used_margin: number;
  free_margin: number;
  margin_level: number;
  unrealized_pnl: number;
  open_count: number;
  positions: any[];
  ts: number;
}

interface AccountMetricsEvent {
  type: 'account_metrics';
  login: number;
  balance: number;
  equity: number;
  margin: number;
  free_margin: number;
  margin_level: number;
  total_pnl: number;
  currency: string;
  timestamp: number | string;
}

interface TickEvent {
  bid: number;
  ask: number;
  mid: number;
  ts: number;
}

interface OhlcEvent {
  ts: number;
  o: number;
  h: number;
  l: number;
  c: number;
  v: number;
  ver: number;
}
```

### Reconnection and heartbeat

When the connection drops unexpectedly and `autoReconnect` is `true` (the default), the SDK reconnects with exponential backoff:

- The delay before attempt *n* (starting at 0) is `min(reconnectBaseDelayMs × 2^n, reconnectMaxDelayMs)`. With the defaults this is 1 s, 2 s, 4 s, 8 s and 16 s.
- A `reconnecting` event with `{ attempt, delay }` is emitted before each attempt.
- On success the SDK emits `connected` again and **resubscribes automatically** to every topic you had subscribed.
- After `maxReconnectAttempts` failed attempts (default 5), the SDK stops retrying.

The heartbeat sends an application-level `ping` every `heartbeatIntervalMs` (default 15000). It also acts as a liveness watchdog: if no `pong` or data arrives within one interval after a ping, the socket is treated as dead (a silent half-open connection), an `error` event is emitted, and the connection is terminated so the normal `disconnected` and reconnect path runs.

```typescript
client.stream.onDisconnected(() => console.log("disconnected, will auto-reconnect"));
client.stream.onReconnecting(({ attempt, delay }) => console.log(`retry #${attempt} in ${delay}ms`));
client.stream.onConnected(() => console.log("connected; subscriptions restored"));
```

Because `disconnect()` removes all listeners and subscriptions, register your listeners again (and subscribe again) after calling it deliberately.

## Error handling

Every error is a `PipsendError` with `code` and `statusCode`. Auth failures are `AuthenticationError` and carry `retryAfter` (seconds) on a `429`.

```typescript
import { PipsendError, AuthenticationError } from "@pipsend/sdk";

try {
  await client.orders.cancel(4242);
} catch (error) {
  if (error instanceof PipsendError) {
    console.error(error.code, error.statusCode, error.message);
    // e.g. "order_terminal" 409 - order already FILLED/CANCELLED
  }
}

try {
  await client.auth.refresh();
} catch (error) {
  if (error instanceof AuthenticationError && error.code === "rate_limited") {
    console.warn(`rate limited; retry in ${error.retryAfter ?? 60}s`);
  }
}
```

### Error classes

| Class | Extends | `code` | When it is thrown |
|-------|---------|--------|-------------------|
| `PipsendError` | `Error` | Server error code, `http_error` or `network_error` | Any non-2xx API response (code and message come from the `{ error: { code, message } }` body; `statusCode` is the HTTP status), and network failures during a request (`network_error`, no `statusCode`). |
| `AuthenticationError` | `PipsendError` | `rate_limited`, `invalid_credentials`, `missing_credentials`, `network_error`, or the code returned by the server | Login failures. Also thrown when a call is made during a login cooldown. Default `statusCode` is `401`; `retryAfter` is set on `429`. |
| `ConfigurationError` | `PipsendError` | `CONFIG_ERROR` | Thrown by `createClient` when `server` is missing or is not a valid URL. |
| `WebSocketError` | `PipsendError` | `WEBSOCKET_ERROR` | The stream is used without `websocket.enabled: true`, the connection cannot be created or fails, the server URL is invalid for WebSocket use, or the `ws` package is not available in Node.js. Also delivered to `error` listeners on a heartbeat timeout. |

`PipsendError` properties and methods:

| Member | Description |
|--------|-------------|
| `message` | Human-readable message. |
| `code?: string` | Error code. |
| `statusCode?: number` | HTTP status code. |
| `errors?: ValidationError[]` | Optional field-level details (`{ field, message }`). |
| `response?: any` | Optional raw response. |
| `getDetailedMessage(): string` | The message followed by one line per validation error. |
| `isValidationError(): boolean` | `true` when `statusCode` is `422` or `code` is `VALIDATION_ERROR` (uppercase). |

The server reports field validation failures with the lowercase code `validation_error` (HTTP 400), so check `error.code === "validation_error"` yourself for those.

### Server error codes

All API errors share the `{ "error": { "code": "...", "message": "..." } }` shape (the `401` and `403` responses carry no body, so their `code` is `http_error`).

| HTTP | Code | Meaning |
|------|------|---------|
| 400 | `bad_request` | Malformed JSON or parameters |
| 400 | `validation_error` | Named field(s) failed validation (see message) |
| 400 | `invalid_account_number` | The account number is not a positive integer |
| 400 | `invalid_leverage` | Leverage is not a positive value |
| 400 | `invalid_right` | Unknown `RIGHT_*` name |
| 401 | none | Missing or invalid token |
| 403 | none | Token type mismatch or access denied |
| 404 | `account_not_found` / `position_not_found` | Unknown resource |
| 409 | `order_terminal` | Cancel or modify on a FILLED or CANCELLED order |
| 422 | `invalid_trading_group` | Trading group path does not resolve |
| 422 | `invalid_country` | Unknown ISO country code |
| 429 | `rate_limited` | Rate-limit bucket exhausted (see `Retry-After`) |
| 501 | `not_implemented` | Endpoint not implemented |
| 5xx | `upstream_unavailable` | A downstream service (for example MarketData) is unavailable |

Market data calls go through a proxy that can answer with `{ status, status_code, message }`. In that case `error.code` holds the `status_code` value.

### Automatic handling

- A `401` triggers one token refresh and one retry of the request (see [How it works](#how-it-works)).
- Failed logins set a cooldown (see [Login rate limiting and backoff](#login-rate-limiting-and-backoff)).
- The SDK does not retry other failed requests, including `429` responses on API calls. Wait for `Retry-After` seconds before retrying.

## TypeScript types

The SDK ships with complete type definitions. Everything below is exported from the package root:

```typescript
import type {
  Account,
  Position,
  Order,
  TradingGroup,
  Candle,
  Symbol,
  Session,
  ItemsPage,
  PaginatedList,
  PositionEvent,
  OrderEvent,
  TickEvent,
} from "@pipsend/sdk";
```

| Area | Exported types |
|------|----------------|
| Config and auth | `PipsendConfig`, `DeviceInfo`, `TokenInfo`, `AuthAccount`, `AuthResponse`, `LogoutResult`, `HealthCheckResponse` |
| Common | `PaginationParams`, `ApiError`, `Envelope`, `PageMeta`, `ItemsPage`, `Pagination`, `ListMeta`, `PaginatedList` |
| Accounts | `Account`, `AccountTradingGroupRef`, `AccountCountryRef`, `AccountsListParams`, `AccountStatus`, `AccountRights`, `PermissionEntry`, `AccountPermissions`, `AccountPermission`, `CreateAccountRequest`, `UpdateAccountRequest`, `PasswordKind`, `SetPasswordRequest`, `UpdateRightsRequest`, `UpdateRightsResponse`, `AdjustBalanceRequest`, `ArchiveAccountRequest` |
| Trading groups | `TradingGroup`, `TradingGroupTreeNode`, `TradingGroupsListParams` |
| Market data | `Candle`, `CandlesParams`, `Symbol`, `SymbolsParams`, `Group`, `GroupsParams`, `MarketDataMeta`, `MarketDataPage` |
| Positions | `Position`, `PositionAccountRef`, `PositionsListParams`, `PositionsHistoryParams`, `SetSLTPRequest`, `TrailingStopRequest`, `ClosePositionRequest`, `ClosePositionResult`, `CloseAllParams`, `CloseAllResult` |
| Orders | `Order`, `OrderSide`, `OrderType`, `OrderTif`, `OrderStatus`, `OrdersListParams`, `OrdersHistoryParams`, `ModifyOrderRequest`, `CancelOrderRequest` |
| Trades | `OpenTradeRequest`, `OpenTradeResponse`, `CloseTradeRequest`, `CloseTradeResponse`, `ModifyTradeRequest`, `ModifyTradeResponse` |
| Sessions | `Session`, `SessionsListParams` |
| Realtime | `WebSocketConfig`, `ClientOp`, `ServerOp`, `Frame`, `PushFrame`, `StreamEvent`, `StreamCallback`, `PositionEventType`, `PositionEvent`, `OrderEventType`, `OrderEvent`, `PnlSnapshot`, `AccountMetricsEvent`, `TickEvent`, `OhlcEvent` |

The classes `PipsendClient`, `PipsendError`, `AuthenticationError`, `ConfigurationError` and `WebSocketError`, the `createClient` function and the `topics` helper are exported as values.

> The `Symbol` type shares its name with the JavaScript global `Symbol`. Import it under an alias if you also use the global: `import type { Symbol as MarketSymbol } from "@pipsend/sdk"`.

The auth and pagination types:

```typescript
interface AuthAccount {
  id: number;
  number: number;
  email: string;
  first_name: string;
  last_name: string;
  account_type: 'real' | 'demo';
  balance: {
    balance: number;
    credit: number;
    currency: string;
  };
}

interface LogoutResult {
  sessions_closed: number;
}

interface ItemsPage<T> {
  items: T[];
  meta: PageMeta; // { page, per_page, total, last_page }
}

interface PaginatedList<T> {
  data: T[];
  meta: ListMeta; // { pagination: { page, per_page, total } }
}
```

## Complete examples

### Client, trade and read-back

This example combines the calls documented above. It authenticates on the first call, opens a trade, lists the open positions and reads the account status.

```typescript
import { createClient } from "@pipsend/sdk";

const client = createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
});

const order = await client.trades.open({
  login: 10001,
  symbol: "EURUSD",
  action: "buy",
  type: "market",
  volume: 0.1,
  stop_loss: 1.085,
  take_profit: 1.095,
});

const open = await client.positions.list({ login: "10001", state: "open" });
const status = await client.accounts.getStatus(10001);
```

### Watching a firehose of positions

```typescript
import { createClient, topics } from "@pipsend/sdk";

const client = createClient({
  server: "http://localhost:8080",
  number: 10001,
  password: "your-api-password",
  websocket: { enabled: true, autoConnect: false },
});

client.stream.onTopic("position:*", ({ topic, data }) => {
  if (data.event_type === "position.opened") {
    console.log("opened", data.login, data.symbol_code, data.qty);
  }
});

await client.stream.connect();
client.stream.subscribe([topics.position("*"), topics.order("*")]);
```

This requires a token with the tracking permission. For a single account, subscribe to `topics.position(10001)` instead.

### Express integration

The `examples/express-app` project shows how to share one client across an Express application. A singleton service creates the client with the stream enabled (adapted here to the 1.x credentials, `number` and `password`):

```typescript
import { createClient, PipsendClient } from '@pipsend/sdk';

let client: PipsendClient | null = null;

export function initializePipsendClient(): PipsendClient {
  if (client) {
    return client;
  }

  const server = process.env.PIPSEND_SERVER;
  const login = process.env.PIPSEND_LOGIN;
  const password = process.env.PIPSEND_PASSWORD;

  if (!server || !login || !password) {
    throw new Error(
      'Missing required environment variables: PIPSEND_SERVER, PIPSEND_LOGIN, PIPSEND_PASSWORD'
    );
  }

  client = createClient({
    server,
    number: Number(login),
    password,
    websocket: {
      enabled: true,
      autoConnect: false,
      autoReconnect: true,
      maxReconnectAttempts: 5
    }
  });

  return client;
}

export function getPipsendClient(): PipsendClient {
  if (!client) {
    throw new Error('Pipsend client not initialized. Call initializePipsendClient() first.');
  }
  return client;
}
```

An error-handling middleware maps SDK errors to HTTP responses:

```typescript
import { Request, Response, NextFunction } from 'express';
import { PipsendError } from '@pipsend/sdk';

export function errorHandler(
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  console.error('Error:', error);

  // Handle Pipsend SDK errors
  if (error instanceof PipsendError) {
    return res.status(error.statusCode || 500).json({
      error: {
        code: error.code,
        message: error.message,
        statusCode: error.statusCode
      }
    });
  }

  // Handle generic errors
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: error.message || 'Internal server error'
    }
  });
}
```

Routes can expose and control the stream:

```typescript
router.get('/status', (req: Request, res: Response) => {
  const client = getPipsendClient();
  const isConnected = client.stream.isConnected();
  const subscriptions = client.stream.getSubscriptions();

  res.json({
    connected: isConnected,
    subscriptions
  });
});

router.post('/connect', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const client = getPipsendClient();

    if (client.stream.isConnected()) {
      return res.json({ message: 'Already connected' });
    }

    await client.stream.connect();
    res.json({ message: 'Connected successfully' });
  } catch (error) {
    next(error);
  }
});
```

On shutdown, disconnect the stream:

```typescript
process.on('SIGTERM', () => {
  const client = getPipsendClient();
  client.stream.disconnect();
  process.exit(0);
});
```

### Account rights recipes

```typescript
import { createClient } from "@pipsend/sdk";
import type { AccountRights, UpdateRightsRequest } from "@pipsend/sdk";

const enableTradingRequest: UpdateRightsRequest = {
  rights: [
    'RIGHT_ENABLED',           // Account is active
    'RIGHT_LOGIN_ENABLED',     // Can login
    'RIGHT_TRADE_ENABLED',     // Can trade
    'RIGHT_EA_ENABLED',        // Can use Expert Advisors
    'RIGHT_API_ACCESS_ENABLED' // Can use API
  ]
};

const updatedRights = await client.accounts.updateRights(10001, enableTradingRequest);

// Read-only mode
const readOnlyRequest: UpdateRightsRequest = {
  rights: [
    'RIGHT_ENABLED',
    'RIGHT_LOGIN_ENABLED',
    'RIGHT_READONLY',          // Read-only mode
    'RIGHT_BALANCE_READONLY'   // Balance is read-only
  ]
};
await client.accounts.updateRights(10001, readOnlyRequest);
```

## Rate limits

Rate limits are applied per token across three tiers:

| Tier | Quota | Applies to |
|------|-------|------------|
| Read | 600 requests/min | `GET` endpoints |
| Write | 100 requests/min | `POST`, `PUT`, `PATCH`, `DELETE` |
| Trade | 30 requests/min | `/api/v1/trades/*` |

When a bucket is exhausted, the API answers `429 Too Many Requests` with the error code `rate_limited` and a `Retry-After` header (in seconds). The SDK raises this as a `PipsendError` with `statusCode` `429`.

The login endpoint has its own limits (per IP and per account, in 15-minute windows). For login failures the SDK applies the cooldowns described in [Login rate limiting and backoff](#login-rate-limiting-and-backoff), and `AuthenticationError.retryAfter` carries the `Retry-After` value.

## Troubleshooting

| Symptom | Cause and fix |
|---------|---------------|
| `ConfigurationError: The "server" parameter is required` / `must be a valid URL` | Pass a full URL such as `http://localhost:8080` or your production `https://` URL as `server`. |
| `AuthenticationError` with code `missing_credentials` | `number` and `password` were not provided. Add them to the config. |
| `AuthenticationError` with code `invalid_credentials` | Check the account number and that you are using the account's **API password** (not the master or investor password). Make sure the account has an `api_password` set. |
| `AuthenticationError` with code `rate_limited` | Too many login attempts. Wait `error.retryAfter` seconds (60 by default). |
| Repeated identical auth errors without network traffic | A login cooldown is active after a failure. See [Login rate limiting and backoff](#login-rate-limiting-and-backoff). |
| `WebSocketError`: "WebSocket not enabled" | Set `websocket: { enabled: true }` in the client config. |
| `WebSocketError`: "WebSocket not available. Install "ws" for Node.js" | The global `WebSocket` is missing and the `ws` package could not be loaded. Reinstall dependencies or run `npm install ws`. |
| `WebSocketError`: "Failed to connect to WebSocket server" | Check `server` (and `websocket.path` if you changed it), and that the server accepts WebSocket connections. Use `wss://` through `https://` servers. |
| Connected but no events | Subscribe explicitly (`client.stream.subscribe([...])`), check `client.stream.getSubscriptions()`, and verify you are allowed to see the topic. Unauthorized topics are skipped silently, and wildcard topics need the tracking permission. |
| Listeners stopped working after `disconnect()` | `disconnect()` clears all listeners and subscriptions. Register them again after reconnecting. |
| Stream stopped reconnecting | Reconnection stops after `maxReconnectAttempts` failures. Raise the option or call `connect()` again. |
| `409 order_terminal` | The order is already FILLED or CANCELLED and can no longer be modified or cancelled. |
| `429 rate_limited` on API calls | Slow down and retry after the `Retry-After` delay. See [Rate limits](#rate-limits). |
| `Symbol` type clashes with the global `Symbol` | Import it under an alias (see [TypeScript types](#typescript-types)). |

## Changelog highlights

### 1.2.0 (2026-06-11)

- Added `SymbolsParams.include_data_range`, an opt-in flag for the symbols endpoint. When `true`, each symbol includes `timeframes`, `first_candle` and `last_candle`. These fields are now optional on `Symbol` and only present when requested.

### 1.1.0 (2026-06-09)

- Added a realtime **liveness watchdog** that detects silently dead (half-open) connections and runs the normal `disconnected` and reconnect path.
- Added `client.stream.onPong(cb)`.
- Lowered the default `heartbeatIntervalMs` from `30000` to `15000`.
- Packaging fix: `main` and `exports.require` now point at the real CommonJS build, so `require('@pipsend/sdk')` resolves for CommonJS consumers.

### 1.0.0 (2026-06-07)

A full rewrite against the `/api/v1` public contract. **Breaking**: no backward compatibility with 0.x.

- Authentication uses `number` instead of `login`, optional `device` metadata, and `access_expires_at` for expiry. `client.auth.logout()` is asynchronous; `logoutAll()`, `logoutOthers()` and the `client.sessions` module were added.
- Responses are unwrapped from `{ data, meta }` and errors are mapped from `{ error: { code, message } }`. Two list shapes: `ItemsPage<T>` and `PaginatedList<T>`.
- Business identifiers only: accounts by number, trading groups by path, symbols by code.
- Accounts, trading groups, positions, orders, trades and market data were reshaped (see the sections above). Market data endpoints moved under the `/md/` proxy prefix.

## License

MIT, copyright (c) 2025 Pipsend.
