Active users: {stats.users_active} / {stats.total_user}
Memory: {formatBytes(stats.mem_used)} / {formatBytes(stats.mem_total)}
| Username | Data used | Expires in |
|---|---|---|
| {{ user.username }} | {{ formatBytes(user.used_traffic) }} | {{ user.expire ? humanRemaining(user.expire * 1000) : '∞' }} |
watching node {nodeId} logs...
}
```
# Changelog (/docs/resources/changelog)
## v3.0.0 [#v300]
**Breaking changes:**
* **Webhook verification is now async.** `parseWebhook` and `verifyWebhookSignature` return `Promise` instead of being synchronous. Update all call sites to `await`.
```ts
// Before (v2.x)
const payloads = sdk.webhook.parseWebhook(body, sig)
// After (v3.0)
const payloads = await sdk.webhook.parseWebhook(body, sig)
```
* **Webhook verification is server-side only.** Calling `handleWebhook` or `parseWebhook` with a secret configured in a browser context now throws `WebhookEnvironmentError` (code: `WEBHOOK_ENVIRONMENT_ERROR`).
**New features:**
* **Web Crypto API** — webhook signature verification now uses `crypto.subtle` instead of Node.js `crypto`. This means zero Node-specific imports in browser bundles and native support on Cloudflare Workers, Vercel Edge, Deno, and Bun.
* **Native WebSocket** — the WebSocket log streaming client auto-detects the runtime. Modern runtimes (browser, Node.js 21+, Bun, Deno) use the native `WebSocket` global; older Node versions fall back to the `ws` package.
* `sdk.destroy()` now correctly closes all active WebSocket connections.
***
## v2.0.0 [#v200]
**Breaking changes:**
* SDK configuration is now validated with **Zod** on construction. Invalid configs throw `ConfigurationError` immediately instead of failing silently later.
* Error classes restructured: `AuthError`, `AuthTokenError`, `HttpError`, `ConfigurationError`, and Webhook errors all extend the new `SdkError` base class.
**New features:**
* **Classified error system** — typed error hierarchy with `code` property and type guard helpers (`isAuthError`, `isHttpError`, etc.).
* **Webhook support** — `sdk.webhook` with `on/once/off`, `handleWebhook`, `parseWebhook`, and `dispatch`.
* **Webhook signature verification** — HMAC-SHA256 via Node.js `crypto`.
* **Utility helpers** — `parseSize`, `formatBytes`, `gbToBytes`, `bytesToGb`, `addDays`, `remainingTime`, `humanRemaining`, `toIso`, `Variable`, `varAs`, `varExtract`, `varValidate`, `interpolateTemplateVariables`.
***
## v1.x [#v1x]
Initial release — typed Marzban API client with automatic authentication and JWT refresh, built from the official OpenAPI specification.
***
## Migration guide: v2 → v3 [#migration-guide-v2--v3]
### 1. Await parseWebhook [#1-await-parsewebhook]
```ts
// v2
const events = sdk.webhook.parseWebhook(rawBody, sig)
// v3
const events = await sdk.webhook.parseWebhook(rawBody, sig)
```
### 2. Await handleWebhook [#2-await-handlewebhook]
```ts
// v2
sdk.webhook.handleWebhook(rawBody, sig)
// v3
await sdk.webhook.handleWebhook(rawBody, sig)
```
### 3. Await verifyWebhookSignature (if called directly) [#3-await-verifywebhooksignature-if-called-directly]
```ts
import { verifyWebhookSignature } from 'marzban-sdk'
// v2
const ok = verifyWebhookSignature(sig, secret, bytes)
// v3
const ok = await verifyWebhookSignature(sig, secret, bytes)
```
### 4. Catch WebhookEnvironmentError in browser code [#4-catch-webhookenvironmenterror-in-browser-code]
If you were calling signature verification from the browser (not recommended), you'll now receive `WebhookEnvironmentError`. Move webhook handling to a server route.
# Contributing (/docs/resources/contributing)
Thank you for your interest in contributing to MarzbanSDK! All contributions are welcome — bug fixes, features, documentation improvements, and test coverage.
## Reporting issues [#reporting-issues]
Open an issue on [GitHub](https://github.com/Ilmar7786/marzban-sdk/issues) and include:
* A clear description of the problem.
* Steps to reproduce (code snippet or minimal repo link preferred).
* Expected vs. actual behaviour.
* SDK version (`npm list marzban-sdk`).
## Development setup [#development-setup]
```bash
git clone https://github.com/Ilmar7786/marzban-sdk.git
cd marzban-sdk
npm install
```
### Available scripts [#available-scripts]
| Command | Description |
| ----------------------- | ---------------------------------------------------- |
| `npm run build` | Compile TypeScript → `dist/` (ESM + CJS) |
| `npm test` | Run the full test suite once with Vitest |
| `npm run test:watch` | Run tests in watch mode |
| `npm run test:coverage` | Run tests with a coverage report |
| `npm run lint` | Run ESLint |
| `npm run codegen` | Regenerate the API client from `openapi/` using Kubb |
## Testing [#testing]
The SDK is covered by [Vitest](https://vitest.dev). Tests live next to the code they cover as `src/**/*.test.ts`.
```bash
npm test # run once
npm run test:watch # watch mode while developing
npm run test:coverage # run with a coverage report
```
Hand-written code is held at **100% coverage** — statements, branches, functions, and lines. Generated code (`src/gen/`, produced from the OpenAPI spec) and type-only files are excluded from coverage on purpose.
Every feature and bug fix must come with tests, and `npm run test:coverage` must stay green.
### Build & verify [#build--verify]
```bash
npm run build
npm run test:coverage
```
Both must pass before submitting a PR.
## Submitting a pull request [#submitting-a-pull-request]
1. **Fork** the repository and create a new branch:
```bash
git checkout -b feat/your-feature
```
2. Make your changes.
3. Add or update tests in `src/**/*.test.ts`.
4. Run `npm run test:coverage` and `npm run build`.
5. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (enforced by commitlint):
```
feat: add pagination to getUsers
fix: handle missing token in AuthManager
docs: add NestJS integration example
```
6. Push and open a PR against the `main` branch.
## Commit message format [#commit-message-format]
```
type(scope?): short description
Optional body.
BREAKING CHANGE: description ← for major version bumps
```
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`.
## Code style [#code-style]
* All code is in **TypeScript** with `strict` mode enabled.
* ESLint + Prettier enforce style — run `npm run lint` before committing.
* No unnecessary `any` — prefer proper generics or `unknown`.
* No comments that explain *what* the code does — only *why* when it's non-obvious.
## Regenerating the API client [#regenerating-the-api-client]
The `src/gen/` directory is auto-generated from the OpenAPI spec. Do **not** edit it manually:
```bash
# Update the spec in openapi/ first, then:
npm run codegen
```
If you're fixing a bug in a generated file, fix the template or the spec instead.
## Questions [#questions]
Start a discussion in [GitHub Discussions](https://github.com/Ilmar7786/marzban-sdk/discussions) or open an issue.
# FAQ (/docs/resources/faq)
## General [#general]
### What version of Marzban does this SDK support? [#what-version-of-marzban-does-this-sdk-support]
MarzbanSDK v3.x is generated from the Marzban API as of **v0.8.4** — that's the version pinned in the bundled OpenAPI specification (`packages/sdk/openapi/openapi.json`, `info.version`). Newer Marzban releases that are additive (new optional fields, new endpoints) generally keep working; a release that changes existing request/response shapes may not, until the spec is re-vendored and the client regenerated. Check the [Changelog](/docs/resources/changelog) for what changed between SDK versions.
### Does it work in the browser? [#does-it-work-in-the-browser]
Yes, for most features. The SDK is designed to be cross-runtime:
* **API calls** — work in any environment.
* **WebSocket log streaming** — works in browsers using the native `WebSocket` global.
* **Webhook signature verification** — **server-side only**. Calling `handleWebhook` or `parseWebhook` with a secret in a browser throws `WebhookEnvironmentError` to prevent secret exposure.
### Can I use the SDK without TypeScript? [#can-i-use-the-sdk-without-typescript]
Yes. The package ships ESM and CJS bundles that work in plain JavaScript. You'll lose autocomplete and type checking, but the runtime behaviour is identical.
### My self-hosted panel uses a self-signed certificate — how do I connect? [#my-self-hosted-panel-uses-a-self-signed-certificate--how-do-i-connect]
Pass a Node `https.Agent` configured with your CA via the `httpsAgent` config option, rather than disabling TLS verification:
```ts
import { readFileSync } from 'node:fs'
import https from 'node:https'
const sdk = await createMarzbanSDK({
baseUrl: 'https://panel.example.com',
username: 'admin',
password: 'secret',
httpsAgent: new https.Agent({ ca: readFileSync('ca.pem') }),
})
```
See [Self-signed certificates & custom CA](/docs/configuration/config-options#self-signed-certificates--custom-ca). Using `marzban-mcp` instead? Set `MARZBAN_TLS_CA_FILE` — see [MCP Configuration](/docs/mcp-server/configuration#tls--self-signed-certificates).
***
## Authentication [#authentication]
### Do I need to call authorize() manually? [#do-i-need-to-call-authorize-manually]
No — `createMarzbanSDK` calls it automatically by default. Set `authenticateOnInit: false` only if you want to defer authentication.
### What happens when the JWT expires? [#what-happens-when-the-jwt-expires]
The SDK catches `401 Unauthorized` responses, re-authenticates with your stored `username` and `password`, and retries the original request transparently. You don't need to handle this in your code.
### Can I use a pre-existing token? [#can-i-use-a-pre-existing-token]
Yes. Pass it via the `token` config field. The SDK will use it until it expires, then fall back to credentials for renewal.
***
## Errors [#errors]
### How do I distinguish different error types? [#how-do-i-distinguish-different-error-types]
Use the type guard functions:
```ts
import { isAuthError, isHttpError, isConfigurationError } from 'marzban-sdk'
try {
await sdk.user.getUser('alice')
} catch (err) {
if (isAuthError(err)) { /* auth failed */ }
else if (isHttpError(err)) { /* HTTP 4xx/5xx */ }
else { throw err }
}
```
See [Error Handling](/docs/advanced/error-handling) for the full list.
### What does AUTH\_TOKEN\_FAILED mean? [#what-does-auth_token_failed-mean]
The server returned `200 OK` from the login endpoint, but the response body contained no `access_token`. This usually means the Marzban API returned an unexpected response format. Check your Marzban version compatibility.
***
## Webhooks [#webhooks]
### Do I need to verify webhook signatures? [#do-i-need-to-verify-webhook-signatures]
You don't have to, but you should in production. Without signature verification, any HTTP client can send arbitrary payloads to your webhook endpoint and trigger your event handlers. Set `webhook.secret` in your config to enable verification.
### Why does signature verification throw WebhookEnvironmentError? [#why-does-signature-verification-throw-webhookenvironmenterror]
Webhook signature verification uses `crypto.subtle` (Web Crypto API). While this API is available in browsers, running signature verification in a browser would require exposing your webhook secret to the client. The SDK detects the browser environment and throws `WebhookEnvironmentError` to prevent this mistake.
### Can Marzban send multiple events in one request? [#can-marzban-send-multiple-events-in-one-request]
Yes. The `handleWebhook` method processes arrays of events. Subscribe to the `'batch'` event to receive the full array in one call.
***
## Utilities [#utilities]
### Is parseSize case-sensitive? [#is-parsesize-case-sensitive]
No. It accepts `GB`, `gb`, `Gb`, `GiB`, etc. The value is normalised to uppercase internally.
### What does humanRemaining return for expired dates? [#what-does-humanremaining-return-for-expired-dates]
It returns the string `"expired"` when `totalMs < 0` (i.e. the date is in the past).
***
## Development [#development]
### How do I run the tests? [#how-do-i-run-the-tests]
```bash
npm test
```
The test suite uses **Vitest** and covers config validation, auth flow, error classes, webhook parsing, and utility functions.
### How do I regenerate the API client? [#how-do-i-regenerate-the-api-client]
The generated code in `src/gen/` is produced by Kubb from the OpenAPI spec in `openapi/`:
```bash
npm run codegen
```
After regenerating, rebuild with `npm run build`.
# Data Sizes (/docs/utilities/data-sizes)
The data-size utilities help you work with Marzban's numeric byte values (e.g. `data_limit`, `used_traffic`) in a human-friendly way.
## Import [#import]
```ts
import { parseSize, formatBytes, gbToBytes, bytesToGb } from 'marzban-sdk'
```
## `parseSize(size, opts?)` [#parsesizesize-opts]
Parse a human-readable size string into **bytes** (number).
```ts
parseSize('10GB') // 10737418240 (binary, 1024³)
parseSize('10 gb') // 10737418240
parseSize('1.5 TB') // 1649267441664
parseSize(1073741824) // 1073741824 (number passthrough)
parseSize('500MB', { decimal: true }) // 500000000 (SI units, 1000³)
parseSize('invalid') // 0
```
**Supported units:** `B`, `KB`, `MB`, `GB`, `TB`, `PB` (also accepts `KiB`, `MiB`, `GiB`, `TiB`, `PiB`).
| Option | Type | Default | Description |
| --------- | --------- | ------- | -------------------------------------------------------- |
| `decimal` | `boolean` | `false` | Use 1000-based units (SI) instead of 1024-based (binary) |
***
## `formatBytes(bytes, opts?)` [#formatbytesbytes-opts]
Format a byte number to a human-readable string.
```ts
formatBytes(0) // "0 B"
formatBytes(1024) // "1.00 KB"
formatBytes(10737418240) // "10.00 GB"
formatBytes(10737418240, { decimals: 0 }) // "10 GB"
formatBytes(1500000, { decimal: true }) // "1.50 MB" (SI)
formatBytes(-2097152) // "-2.00 MB"
```
| Option | Type | Default | Description |
| ---------- | --------- | ------- | ------------------------- |
| `decimals` | `number` | `2` | Number of decimal places |
| `decimal` | `boolean` | `false` | Use 1000-based units (SI) |
***
## `gbToBytes(gb, decimal?)` [#gbtobytesgb-decimal]
Convert gigabytes to bytes.
```ts
gbToBytes(10) // 10737418240 (binary)
gbToBytes(10, true) // 10000000000 (decimal/SI)
```
***
## `bytesToGb(bytes, decimal?)` [#bytestogbbytes-decimal]
Convert bytes to gigabytes (float).
```ts
bytesToGb(10737418240) // 10.0
bytesToGb(10737418240, true) // 10.737418240... (SI)
```
## Common usage patterns [#common-usage-patterns]
### Set data limit for a new user [#set-data-limit-for-a-new-user]
```ts
import { parseSize } from 'marzban-sdk'
await sdk.user.addUser({
username: 'alice',
proxies: { vless: {} },
inbounds: { vless: ['VLESS TCP REALITY'] },
data_limit: parseSize('50GB'), // 53687091200 bytes
expire: 0,
})
```
### Display usage stats [#display-usage-stats]
```ts
import { formatBytes } from 'marzban-sdk'
const user = await sdk.user.getUser('alice')
const used = formatBytes(user.used_traffic)
const limit = user.data_limit ? formatBytes(user.data_limit) : 'Unlimited'
const percent = user.data_limit
? ((user.used_traffic / user.data_limit) * 100).toFixed(1)
: '—'
console.log(`${used} / ${limit} (${percent}%)`)
// "3.52 GB / 10.00 GB (35.2%)"
```
# Datetime (/docs/utilities/datetime)
The `datetime` utilities help you work with Marzban's Unix timestamp expiry values — calculating remaining time, formatting durations, and manipulating dates.
## Import [#import]
```ts
import {
addToDate,
addDays,
addHours,
remainingTime,
humanRemaining,
toIso,
} from 'marzban-sdk'
```
## `addToDate(date, opts)` [#addtodatedate-opts]
Add duration components to a date (immutable — returns a new `Date`).
```ts
const now = new Date()
const in30Days = addToDate(now, { days: 30 })
const in2Hours = addToDate(now, { hours: 2 })
const combined = addToDate(now, { days: 7, hours: 6, minutes: 30 })
```
Accepts `Date`, ISO string, or Unix timestamp (ms).
***
## `addDays(date, days)` [#adddaysdate-days]
Shorthand for adding days.
```ts
const expiry = addDays(new Date(), 30)
// Convert to Unix timestamp for the API
const unixExpiry = Math.floor(expiry.getTime() / 1000)
```
***
## `addHours(date, hours)` [#addhoursdate-hours]
Shorthand for adding hours.
```ts
const expiryTimestamp = Math.floor(addHours(new Date(), 24).getTime() / 1000)
```
***
## `remainingTime(to, from?)` [#remainingtimeto-from]
Calculate the remaining time between `from` (default: now) and `to`.
Returns a `Remaining` object:
```ts
interface Remaining {
days: number
hours: number
minutes: number
seconds: number
totalMs: number // negative when expired
}
```
```ts
const user = await sdk.user.getUser('alice')
const expiryMs = (user.expire ?? 0) * 1000
const r = remainingTime(expiryMs)
console.log(`${r.days}d ${r.hours}h ${r.minutes}m remaining`)
// Check if expired
if (r.totalMs < 0) {
console.log('Account has expired')
}
```
***
## `humanRemaining(to, from?)` [#humanremainingto-from]
Get a compact, human-readable remaining time string.
```ts
humanRemaining(Date.now() + 86400 * 1000 * 3) // "3d"
humanRemaining(Date.now() + 3600 * 1000 * 2) // "2h"
humanRemaining(Date.now() + 60 * 1000 * 90) // "1h 30m"
humanRemaining(Date.now() - 1000) // "expired"
humanRemaining(Date.now() + 500) // "< 1s"
```
***
## `toIso(date)` [#toisodate]
Format a date as an ISO 8601 string without milliseconds.
```ts
toIso(new Date()) // "2024-01-15T10:23:05Z"
toIso(1705312800000) // "2024-01-15T10:00:00Z"
toIso('2024-01-15') // "2024-01-15T00:00:00Z"
```
## Common usage patterns [#common-usage-patterns]
### Create a user with a 30-day expiry [#create-a-user-with-a-30-day-expiry]
```ts
import { addDays } from 'marzban-sdk'
const expiry = Math.floor(addDays(new Date(), 30).getTime() / 1000)
await sdk.user.addUser({
username: 'alice',
proxies: { vless: {} },
inbounds: { vless: ['VLESS TCP REALITY'] },
data_limit: 0,
expire: expiry,
})
```
### Display subscription card info [#display-subscription-card-info]
```ts
import { humanRemaining, formatBytes } from 'marzban-sdk'
const user = await sdk.user.getUser('alice')
const timeLeft = user.expire
? humanRemaining(user.expire * 1000)
: 'Never'
const dataUsed = formatBytes(user.used_traffic)
const dataLimit = user.data_limit ? formatBytes(user.data_limit) : 'Unlimited'
console.log(`
User: ${user.username}
Status: ${user.status}
Data: ${dataUsed} / ${dataLimit}
Expires in: ${timeLeft}
`)
```
### Filter users expiring soon [#filter-users-expiring-soon]
```ts
import { remainingTime } from 'marzban-sdk'
const result = await sdk.user.getUsers({ status: 'active', limit: 1000 })
const expiringSoon = result.users.filter(user => {
if (!user.expire) return false
const { days, totalMs } = remainingTime(user.expire * 1000)
return totalMs > 0 && days <= 3
})
console.log(`${expiringSoon.length} users expire within 3 days`)
```
# Pagination (/docs/utilities/pagination)
Endpoints like `sdk.user.getUsers` are paginated with `offset`/`limit`, and stop reporting more once you've paged through everything. `paginateAll` wraps that loop into an async generator, so you write the fetch call once and consume items one at a time.
## Import [#import]
```ts
import { paginateAll } from 'marzban-sdk'
```
## `paginateAll(fetchPage, opts?)` [#paginateallfetchpage-opts]
`fetchPage(offset, limit)` is your own call to a paginated method, adapted to return `{ items, total? }` — `paginateAll` knows nothing about any specific endpoint.
```ts
for await (const user of paginateAll((offset, limit) =>
sdk.user.getUsers({ offset, limit }).then(r => ({ items: r.users, total: r.total }))
)) {
console.log(user.username)
}
```
| Option | Type | Default | Description |
| ---------- | -------- | ------- | ------------------------ |
| `pageSize` | `number` | `100` | Items requested per page |