# Introduction (/docs) **MarzbanSDK** is a toolkit for building on [Marzban](https://github.com/Gozargah/Marzban): a complete, production-grade TypeScript SDK for your own integrations, and an MCP server built on that same SDK for AI agents. Same auth, same retries, same typed errors underneath either one — pick whichever fits how you're building. ## marzban-sdk [#marzban-sdk] A complete, production-grade TypeScript SDK for building Marzban integrations. Beyond fully typed coverage of the entire API, it bundles the infrastructure a real integration needs — authentication with transparent token refresh, retries, WebSocket log streaming, webhook verification, and runtime validation — and behaves identically in **Node.js**, **Bun**, **Deno**, and the **browser**. ### Features [#features] * **First-class TypeScript** — every request, response, and error is fully typed, [generated directly from the Marzban OpenAPI specification](/docs/get-started/typescript). Matching Zod schemas are exported alongside every model. * **Truly cross-runtime** — the same API in [Node.js, Bun, Deno, and the browser](/docs/integrations/node-bun-deno): native `WebSocket` and the Web Crypto API where available, with a transparent `ws` fallback for older Node.js. * **Modern build** — a dual [ESM + CJS](/docs/get-started/typescript) bundle, side-effect free and fully tree-shakeable, so you ship only what you use. * **Flexible authentication** — [log in automatically on startup, pass an existing JWT, or take full manual control](/docs/authentication/auto-authentication); expired sessions [refresh transparently on `401`](/docs/authentication/manual-auth), so your code never touches a token. * **Built-in resilience** — [exponential-backoff retries](/docs/advanced/http-retry) with a configurable retry count, plus automatic WebSocket reconnects. * **Classified error system** — `AuthError`, `HttpError`, `ConfigurationError`, and webhook errors all extend `SdkError` with a machine-readable `code` and [type-guard helpers](/docs/advanced/error-handling). * **Runtime validation** — config and all API responses are [validated with Zod](/docs/advanced/validation). Bad data is caught immediately with structured error details. * **Real-time log streaming** — [`sdk.logs` streams core and node logs over WebSocket](/docs/realtime/websocket-logs) with automatic token refresh and configurable reconnection. * **Webhooks** — [`sdk.webhook`](/docs/webhooks/event-types) handles incoming Marzban events with [HMAC-SHA256 signature verification](/docs/webhooks/signature-verification), typed event subscriptions, wildcard listeners, and batch processing. * **Batteries-included utilities** — first-class helpers for [data-size formatting/parsing, datetime calculations, and template variables](/docs/utilities/data-sizes). ### Module overview [#module-overview] | Module | Access | Description | | -------------- | ------------------ | ---------------------------------------- | | Users | `sdk.user` | Create, update, query, and manage users | | Admins | `sdk.admin` | Manage admin accounts | | Nodes | `sdk.node` | Add, configure, and monitor nodes | | System | `sdk.system` | Stats, inbounds, and proxy host config | | Core | `sdk.core` | Xray core stats, config, and restart | | Subscriptions | `sdk.subscription` | Public subscription endpoints | | User Templates | `sdk.userTemplate` | Reusable user configuration templates | | Webhooks | `sdk.webhook` | Incoming event handling and verification | | Logs | `sdk.logs` | Real-time WebSocket log streaming | ### Quick example [#quick-example] ```ts import { createMarzbanSDK, formatBytes, humanRemaining } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', }) // List active users const { users, total } = await sdk.user.getUsers({ status: 'active' }) console.log(`${total} active users`) for (const user of users) { const dataLeft = formatBytes((user.data_limit ?? 0) - user.used_traffic) const timeLeft = user.expire ? humanRemaining(user.expire * 1000) : '∞' console.log(`${user.username} — ${dataLeft} left, expires ${timeLeft}`) } ``` ## marzban-mcp [#marzban-mcp] An [MCP](https://modelcontextprotocol.io) server built on `marzban-sdk` that gives an AI agent — Claude, Cursor, or any other MCP client — a set of tools to manage a Marzban panel directly: users, subscriptions, nodes, and the core config. It's a separate, published package (`marzban-mcp`), not a mode of the SDK. * **21 tools, 3 prompts** — the full user lifecycle plus config, hosts, nodes, system stats, and subscriptions, and ready-made prompts that chain several tools into one investigation. * **Profile-gated access** — a tool outside the active profile never appears in `tools/list` at all. * **Confirmation on every destructive action** — a first call only describes the consequences and returns a one-time token; nothing runs until a second, explicitly confirmed call repeats it. * **Credentials only from environment variables**, masked by default in tool output. ```json title="claude_desktop_config.json / .mcp.json" { "mcpServers": { "marzban": { "command": "npx", "args": ["-y", "marzban-mcp"], "env": { "MARZBAN_BASE_URL": "https://panel.example.com", "MARZBAN_USERNAME": "admin", "MARZBAN_PASSWORD": "secret" } } } } ``` # Context7 (/docs/ai-tools/context7) This is different from the [MCP server](/docs/mcp-server) (`marzban-mcp`). That one gives an AI agent operational access to a *running Marzban panel* — creating users, restarting the core. Context7 gives any AI coding assistant read access to *this documentation*, kept current with every release. ## What it is [#what-it-is] [Context7](https://context7.com) is an MCP server that resolves a library name to its current documentation and code examples, then feeds them into an AI assistant's context — instead of the assistant answering from training data that may be a year old. It exposes two tools: `resolve-library-id` (name → library ID) and `get-library-docs` (library ID → current docs). Most clients trigger it with the phrase `use context7` in a prompt. `marzban-sdk` is already indexed: ```text Library ID: /websites/ilmar7786_github_io_marzban-sdk Source: this documentation site ``` ## Why it matters here [#why-it-matters-here] This monorepo ships releases often, and an assistant's training data doesn't move at the same pace — a model can confidently suggest an API shape that changed two versions ago. Context7 closes that gap automatically: your assistant re-fetches current docs on every relevant question, without anyone pasting a page into the chat by hand. For a team evaluating this SDK, it's a concrete signal: the project is set up to be used correctly by AI-assisted developers, not just documented for humans reading top to bottom. ## Two ways an AI can read these docs [#two-ways-an-ai-can-read-these-docs] | Mechanism | How a model gets it | Best for | | ----------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [`llms.txt`](/llms.txt) / [`llms-full.txt`](/llms-full.txt) | A direct URL any assistant can fetch — no MCP required | Clients without MCP support, or pasting a link straight into a chat | | Context7 MCP | Automatic, inside your IDE, for every library you use — not just this one | Day-to-day development in an MCP-aware editor | ## How a request flows [#how-a-request-flows] 1. You ask your assistant a question and mention `use context7` (or your client's equivalent). 2. The assistant calls Context7's `resolve-library-id` for "marzban-sdk" — or you supply the ID above directly. 3. Context7 calls `get-library-docs`, pulling from this site's current content. 4. The docs come back into the assistant's context. 5. The answer reflects the version you actually have installed, not whatever the model last saw in training. ## Setting it up [#setting-it-up] Context7 runs either as a remote HTTP server (`https://mcp.context7.com/mcp`, no install) or locally via `npx -y @upstash/context7-mcp`. An API key from [context7.com](https://context7.com) is optional but raises your rate limit. Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (every project): ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp" } } } ``` ```bash claude mcp add --transport http context7 https://mcp.context7.com/mcp ``` Add `-s user` to make it available in every project instead of just this one. Edit the config file directly (see [Client Setup](/docs/mcp-server/client-setup) for the exact path per OS), then fully quit and reopen the app: ```json { "mcpServers": { "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] } } } ``` Click the MCP icon in the Cascade panel → **Configure** → **View raw config**, and add: ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp" } } } ``` VS Code uses `servers`, not `mcpServers`, as the top-level key. Create or edit `.vscode/mcp.json`: ```json { "servers": { "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] } } } ``` For any other MCP client, see [Context7's own client list](https://context7.com/docs/resources/all-clients) — the shape is almost always one of the two above. Then just ask, mentioning the trigger phrase: ```text Add pagination to sdk.user.getUsers using paginateAll — use context7 for marzban-sdk's current API ``` Or skip the resolve step and reference the library directly: ```text use library /websites/ilmar7786_github_io_marzban-sdk for marzban-sdk and marzban-mcp ``` ## In practice [#in-practice] * **Feature work** — ask for a change against a specific module ("add X to `sdk.node`") and get an answer against the API you actually have installed, not a remembered one. * **Onboarding** — a new hire's assistant is correct about this SDK from day one, without anyone walking them through what changed since the model's training cutoff. * **Agentic workflows** — an autonomous coding agent resolves documentation mid-task on its own, the same way it would for any other dependency it needs current facts about instead of guessing. # Error Handling (/docs/advanced/error-handling) MarzbanSDK uses a typed error hierarchy. Every error extends `SdkError`, which extends `Error` — so standard `try/catch` works as expected. On top of that, each error class has a `code` property and a matching type guard for safe narrowing. ## Error hierarchy [#error-hierarchy] ``` Error └── SdkError ├── AuthError (code: AUTH_FAILED) │ └── AuthTokenError (code: AUTH_TOKEN_FAILED) ├── ConfigurationError (code: CONFIG_INVALID) ├── HttpError (code: NETWORK_HTTP_ERROR) └── WebhookError ├── WebhookSignatureError (code: WEBHOOK_SIGNATURE_ERROR) ├── WebhookValidationError (code: WEBHOOK_VALIDATION_ERROR) └── WebhookEnvironmentError (code: WEBHOOK_ENVIRONMENT_ERROR) ``` ## SdkError base class [#sdkerror-base-class] All SDK errors have these properties: ```ts class SdkError extends Error { code: string // machine-readable error code details?: unknown // extra context (Zod issues, original error, etc.) toJSON(): { name: string code: string message: string details: unknown } } ``` ## Error codes reference [#error-codes-reference] | Code | Class | When thrown | | --------------------------- | ------------------------- | ------------------------------------------------------- | | `AUTH_FAILED` | `AuthError` | Login request failed (wrong credentials, network error) | | `AUTH_TOKEN_FAILED` | `AuthTokenError` | Server responded but returned no `access_token` | | `CONFIG_INVALID` | `ConfigurationError` | Config fails Zod schema validation | | `NETWORK_HTTP_ERROR` | `HttpError` | HTTP request failed (4xx, 5xx, network timeout) | | `WEBHOOK_SIGNATURE_ERROR` | `WebhookSignatureError` | Missing signature or HMAC mismatch | | `WEBHOOK_VALIDATION_ERROR` | `WebhookValidationError` | Webhook payload doesn't match expected schema | | `WEBHOOK_ENVIRONMENT_ERROR` | `WebhookEnvironmentError` | Signature verification called in a browser context | ## Secret redaction [#secret-redaction] `details` is safe to log or send to an external service as-is — passwords, tokens, `Authorization` headers, cookies and similar fields are stripped to `[REDACTED]` before the error is even constructed, wherever they appear in the object graph (including inside an already-`JSON.stringify`'d request body, e.g. a failed login's payload). The same redaction applies to whatever a caught error's `trace` prints through the built-in logger, so a raw secret never reaches your terminal or log files either. ```ts try { await createMarzbanSDK({ baseUrl: '...', username: 'admin', password: 'hunter2' }) } catch (err) { if (isAuthError(err)) { console.error(err.details) // password/token fields inside are already "[REDACTED]" — safe to log } } ``` This happens automatically — there's nothing to opt into, and no redaction helper to import. ## Type guards [#type-guards] Import the guards to narrow errors without `instanceof`: ```ts import { isAuthError, isAuthTokenError, isConfigurationError, isHttpError, isWebhookSignatureError, isWebhookValidationError, isWebhookEnvironmentError, } from 'marzban-sdk' ``` ## Usage examples [#usage-examples] ### Handling auth errors [#handling-auth-errors] ```ts import { createMarzbanSDK, isAuthError, isAuthTokenError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'wrong-password', }) } catch (err) { if (isAuthTokenError(err)) { // Server returned 200 but body had no token console.error('Token not found in response') } else if (isAuthError(err)) { // Covers AuthTokenError too — check specific first console.error('Authentication failed:', err.message) } } ``` ### Handling HTTP errors [#handling-http-errors] `HttpError` exposes the underlying HTTP response through typed getters, instead of reaching into `.details` (which carries the raw, redacted Axios error) yourself: ```ts import { isHttpError } from 'marzban-sdk' try { const user = await sdk.user.getUser('non-existent') } catch (err) { if (isHttpError(err)) { console.error('HTTP status:', err.status) // 404, or undefined if the request never got a response console.error('Status text:', err.statusText) // "Not Found" console.error('Response body:', err.data) // whatever the server returned console.error('Request:', err.method, err.url) // "GET", "/api/user/non-existent" } } ``` `status`/`statusText`/`data`/`method`/`url` are all `undefined` when there was no response to read them from — a network failure, a timeout, or a DNS error never reaches the server, so there's no status code to report. Check for `undefined` rather than assuming a response always exists. ### Handling config errors [#handling-config-errors] ```ts import { createMarzbanSDK, isConfigurationError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ baseUrl: 'not-a-valid-url', username: '', password: 'secret', }) } catch (err) { if (isConfigurationError(err)) { console.error('Bad config:', err.message) console.error('Zod issues:', err.details) } } ``` ### Catch-all with toJSON [#catch-all-with-tojson] ```ts import { SdkError } from 'marzban-sdk' try { await sdk.user.getUsers() } catch (err) { if (err instanceof SdkError) { console.error(JSON.stringify(err.toJSON(), null, 2)) // { // "name": "HttpError", // "code": "NETWORK_HTTP_ERROR", // "message": "HTTP request failed", // "details": { ... } // } } } ``` ## Best practices [#best-practices] * **Check specific subtypes before the parent** — `isAuthTokenError` before `isAuthError`, since `AuthTokenError` extends `AuthError`. * **Always re-throw unknown errors** — only catch what you can handle; let the rest propagate. * **Use `toJSON()`** when logging errors to structured log systems — it serializes the full error context. * **Webhook errors are server-side only** — `WebhookEnvironmentError` is thrown if signature verification is called in a browser. Move webhook handling to a server route. # HTTP & Retry (/docs/advanced/http-retry) MarzbanSDK is built on top of Axios and uses `axios-retry` to handle transient network failures automatically, without any extra code in your application. ## Timeout [#timeout] The default timeout is **30 000 ms (30 seconds)** per request. Override it in the config: ```ts const sdk = await createMarzbanSDK({ // ... timeout: 10_000, // 10 seconds }) ``` Pass `timeout: 0` to disable the timeout entirely (wait forever). This is not recommended for production. ## Automatic retries [#automatic-retries] When a request fails, the SDK retries it automatically using **exponential backoff**: | Attempt | Delay | | --------- | ------------------ | | 1st retry | 1 s | | 2nd retry | 2 s | | 3rd retry | 4 s | | 4th retry | 8 s | | … | … (capped at 30 s) | The default is **3 retries** (4 total attempts). Change it: ```ts const sdk = await createMarzbanSDK({ // ... retries: 5, // 5 retries, 6 total attempts }) ``` Set `retries: 0` to disable retries: ```ts const sdk = await createMarzbanSDK({ // ... retries: 0, }) ``` ## What gets retried [#what-gets-retried] `axios-retry` retries requests that fail due to: * **Network errors** (connection refused, DNS failure, etc.) * **Idempotent 5xx responses** (500, 502, 503, 504) for safe HTTP methods (GET, HEAD, OPTIONS) It does **not** retry: * `4xx` client errors (except 429 if configured separately) * Non-idempotent methods (POST, PUT, PATCH, DELETE) on 5xx — to avoid duplicate writes ## 401 re-auth retry [#401-re-auth-retry] The SDK has a separate, built-in mechanism for 401 responses: 1. A `401 Unauthorized` response triggers `sdk.authorize()`. 2. After a fresh token is obtained, the **original request is retried once** with the new token. 3. If re-auth fails, the error is propagated to the caller. This happens transparently — your code never sees the 401. ## WebSocket retries [#websocket-retries] WebSocket connections use the same `retries` value for **403 Forbidden** reconnections: ```ts const closeStream = await sdk.logs.connectByCore({ onMessage: (data) => console.log(data), onError: (event) => console.error('Max retries reached', event), }) ``` If the server returns `403` on a WebSocket connection, the SDK re-authenticates and retries up to `retries` times. After exhausting retries, the `onError` callback is invoked. ## Multiple SDK instances [#multiple-sdk-instances] Each `MarzbanSDK` instance has its own **isolated** Axios instance. Retry counters, tokens, and connections from one instance never affect another. ```ts const sdkA = await createMarzbanSDK({ baseUrl: 'https://server-a.com', ... }) const sdkB = await createMarzbanSDK({ baseUrl: 'https://server-b.com', ... }) // Independent connections, tokens, and retry state ``` ## Cleanup [#cleanup] Call `sdk.destroy()` to close all active WebSocket connections and release resources: ```ts await sdk.destroy() ``` This is particularly important in long-running processes or tests where you create multiple SDK instances. # Data Validation (/docs/advanced/validation) MarzbanSDK uses **Zod 4** for runtime validation at every boundary: SDK configuration, API responses, and webhook payloads. This means you get structured errors instead of silent corruption or confusing `undefined` values at runtime. ## Where validation happens [#where-validation-happens] | Location | What's validated | Schema | | --------------------------------- | ---------------------------------- | -------------------------------------- | | `createMarzbanSDK(config)` | Config object fields and types | `configSchema` | | Every API response | Response payload structure | Auto-generated per-endpoint schemas | | `sdk.webhook.parseWebhook(body)` | Webhook payload structure | `WebhookSchema` / `WebhookArraySchema` | | `sdk.webhook.handleWebhook(body)` | Same as `parseWebhook` + signature | — | ## Config validation [#config-validation] Validation runs synchronously when the SDK is constructed. Invalid config throws `ConfigurationError` before any network call: ```ts import { createMarzbanSDK, isConfigurationError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ baseUrl: 'not-a-url', // invalid URL username: '', // must be non-empty password: 'secret', }) } catch (err) { if (isConfigurationError(err)) { console.error(err.message) // "Invalid SDK configuration" console.error(err.details) // Zod's ZodError with issue paths } } ``` ## API response validation [#api-response-validation] Every API method validates the server response through its Zod schema before returning. This protects you from API contract drift — if Marzban's response deviates from the schema, you get a structured error instead of broken data: ```ts // Under the hood, every method does something like: const raw = await httpClient.get('/api/user/alice') return getUserQueryResponseSchema.parse(raw.data) // throws ZodError if shape is wrong ``` ## Use schemas in your own code [#use-schemas-in-your-own-code] All generated schemas are exported from the package: ```ts import { userResponseSchema, adminSchema, nodeResponseSchema, userCreateSchema, } from 'marzban-sdk' // Safe-parse external data const result = userResponseSchema.safeParse(unknownData) if (result.success) { console.log(result.data.username) } else { console.error(result.error.issues) } ``` ## Webhook payload validation [#webhook-payload-validation] Webhook bodies are validated against a **discriminated union** schema keyed on the `action` field: ```ts import { sdk } from './sdk' // parseWebhook validates structure and returns typed WebhookType[] const payloads = await sdk.webhook.parseWebhook(rawBody) for (const payload of payloads) { if (payload.action === 'user_created') { // payload is narrowed to UserCreatedSchema type console.log(payload.user.username) console.log(payload.by.username) // admin who created the user } } ``` Invalid payloads throw `WebhookValidationError`: ```ts import { isWebhookValidationError } from 'marzban-sdk' try { await sdk.webhook.parseWebhook('{"action":"unknown_event"}') } catch (err) { if (isWebhookValidationError(err)) { console.error('Invalid webhook payload:', err.details) } } ``` ## Types [#types] The SDK exports TypeScript types for all models directly — you don't need to derive them from schemas: ```ts import type { UserResponse, UserCreate, NodeResponse, AdminCreate } from 'marzban-sdk' ``` Use `z.infer` from schemas only when you need a type that doesn't exist as a named export, or when extending a schema for custom validation. ## Custom validation [#custom-validation] Combine SDK schemas with your own validators: ```ts import { z } from 'zod/v4' import { userCreateSchema } from 'marzban-sdk' // Extend the generated schema with your business rules const myUserCreateSchema = userCreateSchema.extend({ note: z.string().max(500).optional(), }) const payload = myUserCreateSchema.parse(formData) await sdk.user.addUser(payload) ``` # Auto Authentication (/docs/authentication/auto-authentication) By default, MarzbanSDK handles authentication for you. You provide credentials once in the config, and the SDK takes care of obtaining and refreshing the JWT token automatically. ## How it works [#how-it-works] 1. **On init** — `createMarzbanSDK` posts your credentials to `POST /api/admin/token`. The returned JWT is stored in memory. 2. **On every request** — the `Authorization: Bearer ` header is injected by an Axios request interceptor. 3. **On 401 response** — the response interceptor catches the error, re-authenticates once, replaces the token, and retries the original request transparently. ```ts const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', // authenticateOnInit: true ← this is the default }) // Token is already available — no extra call needed const users = await sdk.user.getUsers() ``` ## Concurrent-call deduplication [#concurrent-call-deduplication] If multiple requests trigger a 401 at exactly the same time, the SDK does **not** fire multiple login requests. The `AuthManager` stores the in-flight `Promise` and returns it to all concurrent callers, so only one `/api/admin/token` POST is made. ## Retrieve the current token [#retrieve-the-current-token] ```ts const token = await sdk.getAuthToken() console.log(token) // "eyJhbGci..." ``` `getAuthToken()` waits for any in-progress authentication before returning, so it is safe to call at any time. ## Authentication lifecycle [#authentication-lifecycle] ``` createMarzbanSDK() └─► validateConfig() └─► new MarzbanSDK() ← interceptors wired up └─► sdk.authorize() └─► POST /api/admin/token └─► token stored in memory └─► returns sdk any sdk.user / sdk.node / ... call └─► request interceptor attaches Bearer token └─► response interceptor └─► 200 → return data └─► 401 → re-authenticate → retry once → return data ``` ## Token storage [#token-storage] Tokens are kept **in memory only** — never written to disk, `localStorage`, or cookies. Each `MarzbanSDK` instance has its own isolated `AuthManager`, so multiple SDK instances (e.g. connecting to different Marzban servers) never share tokens. ## Configuration [#configuration] | Field | Default | Effect | | -------------------- | ------- | ---------------------------------------------------- | | `authenticateOnInit` | `true` | Call `authorize()` before `createMarzbanSDK` returns | | `retries` | `3` | Max re-auth attempts on repeated 401 responses | ## What if credentials are wrong? [#what-if-credentials-are-wrong] If the login endpoint returns an error, `createMarzbanSDK` (or `sdk.authorize()`) throws an `AuthError`: ```ts import { createMarzbanSDK, isAuthError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'wrong', }) } catch (err) { if (isAuthError(err)) { console.error('Login failed:', err.message) // "Authentication failed" console.error('Code:', err.code) // "AUTH_FAILED" } } ``` See [Error Handling](/docs/advanced/error-handling) for the full error hierarchy. # Manual Auth & Tokens (/docs/authentication/manual-auth) Sometimes you need to control exactly when authentication happens — for example in test environments, serverless cold-starts, or when you already have a valid token from another source. ## Disable auto-auth [#disable-auto-auth] Set `authenticateOnInit: false` to skip the login call during SDK construction: ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', authenticateOnInit: false, }) // SDK is ready but NOT authenticated yet. // API calls will fail until you authorize(). ``` ## Call authorize() manually [#call-authorize-manually] ```ts await sdk.authorize() // Now safe to make API calls const users = await sdk.user.getUsers() ``` `authorize()` is idempotent with respect to concurrency — if a login is already in progress, it returns the same `Promise` instead of issuing a second request. ## Supply an existing token [#supply-an-existing-token] If you already have a JWT (from a previous session, a shared auth service, etc.), pass it via `token`: ```ts const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', // still required for automatic re-auth on token expiry token: 'eyJhbGciOi...', authenticateOnInit: false, }) ``` The SDK will use the provided token immediately. If the token expires and a `401` is received, it falls back to `username` + `password` to get a fresh one. ## Read the current token [#read-the-current-token] ```ts const token = await sdk.getAuthToken() ``` This method waits for any in-flight authentication before returning, making it safe to call from multiple places concurrently. ## Patterns [#patterns] ### Lazy authentication (serverless) [#lazy-authentication-serverless] ```ts // Module-level singleton — not authenticated yet const sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, authenticateOnInit: false, }) export async function handler() { // First request triggers auth; subsequent requests reuse the token. return sdk.user.getUsers() } ``` ### Testing with a pre-set token [#testing-with-a-pre-set-token] ```ts const sdk = await createMarzbanSDK({ baseUrl: 'http://localhost:7777', username: 'test', password: 'test', token: 'test-jwt-token', authenticateOnInit: false, }) ``` ### Re-authenticate explicitly [#re-authenticate-explicitly] ```ts // Force a fresh token (e.g. after a password change) await sdk.authorize() const freshToken = await sdk.getAuthToken() ``` ## Error handling [#error-handling] ```ts import { isAuthError, isAuthTokenError } from 'marzban-sdk' try { await sdk.authorize() } catch (err) { if (isAuthTokenError(err)) { // Server returned 200 but no access_token in the body console.error('Token retrieval failed:', err.code) // "AUTH_TOKEN_FAILED" } else if (isAuthError(err)) { // Login request failed (wrong credentials, network error, etc.) console.error('Auth failed:', err.message) } } ``` # Config Options (/docs/configuration/config-options) The `Config` object is passed to `createMarzbanSDK` or the `MarzbanSDK` constructor. All fields are validated by Zod before the SDK boots — invalid config throws a `ConfigurationError` immediately. ## Reference [#reference] | Field | Type | Required | Default | Description | | -------------------- | ---------------------------------- | -------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `baseUrl` | `string` | Yes | — | Base URL of the Marzban instance. Must be a valid URL with protocol. Example: `https://vpn.example.com` | | `username` | `string` | Yes | — | Admin username for authentication (non-empty). | | `password` | `string` | Yes | — | Admin password for authentication (non-empty). | | `token` | `string` | No | — | Existing JWT token. If provided, the SDK uses it directly instead of calling the login endpoint. | | `authenticateOnInit` | `boolean` | No | `true` | When `true`, `createMarzbanSDK` calls `authorize()` before returning. Set to `false` for deferred or manual auth. | | `timeout` | `number` | No | `30000` | HTTP request timeout in milliseconds. Pass `0` to disable (wait forever). | | `retries` | `number` | No | `3` | Number of automatic retries for failed HTTP requests and WebSocket reconnections. Uses exponential backoff. | | `logger` | `false \| LoggerOptions \| Logger` | No | env-aware | Logging configuration. See [Logging](/docs/configuration/logging). | | `webhook` | `{ secret?: string }` | No | — | Webhook configuration. Set `secret` to enable HMAC-SHA256 signature verification. | | `httpAgent` | `HttpAgentLike` | No | — | Node.js `http.Agent` (or compatible) used for `http:` requests. Ignored in browsers. | | `httpsAgent` | `HttpAgentLike` | No | — | Node.js `https.Agent` (or compatible) used for `https:` requests and the WebSocket log stream. Ignored in browsers. See [Self-signed certificates](#self-signed-certificates--custom-ca) below. | ## Minimal config [#minimal-config] ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', }) ``` ## Full config example [#full-config-example] ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', timeout: 15_000, // 15 seconds retries: 5, authenticateOnInit: true, logger: { level: 'debug', timestamp: true, }, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) ``` ## Supply an existing token [#supply-an-existing-token] If you manage JWT tokens yourself, skip the login step by providing the `token` field: ```ts const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', token: 'eyJhbGciOi...', // already-obtained JWT authenticateOnInit: false, }) ``` If the supplied `token` expires, the SDK will re-authenticate using `username` and `password` automatically. Both fields are still required. ## Self-signed certificates & custom CA [#self-signed-certificates--custom-ca] Self-hosted Marzban panels are frequently served behind a self-signed or internal-CA certificate. Rather than disabling TLS verification, pass a Node `https.Agent` configured with the CA to trust: ```ts import { readFileSync } from 'node:fs' import https from 'node:https' import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://panel.example.com', username: 'admin', password: 'secret', httpsAgent: new https.Agent({ ca: readFileSync('ca.pem') }), }) ``` The same `httpsAgent` also covers `sdk.logs` — see [WebSocket Logs](/docs/realtime/websocket-logs#custom-agent--self-signed-certificates). `httpAgent`/`httpsAgent` are Node-only: they're ignored (with a warning) in the browser, where there's no way to hand a custom CA to the native `fetch`/`WebSocket` implementations. Don't set `rejectUnauthorized: false` on the agent to work around a certificate error — that disables verification entirely, for every connection the agent makes, not just the one you're debugging. Trust the specific CA instead, as shown above. ## Retry behaviour [#retry-behaviour] When `retries > 0`, failed HTTP requests are retried with **exponential backoff**: | Attempt | Delay | | ------- | ------------------ | | 1 | 1 s | | 2 | 2 s | | 3 | 4 s | | … | … (capped at 30 s) | WebSocket reconnections after `403 Forbidden` also use the same `retries` limit. Set `retries: 0` to disable automatic retries. ## Validation errors [#validation-errors] If you pass an invalid config (e.g. missing `baseUrl`, non-integer `timeout`), the SDK throws a `ConfigurationError` before any network call is made: ```ts import { isConfigurationError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ baseUrl: '', username: '', password: '' }) } catch (err) { if (isConfigurationError(err)) { console.error(err.message) // "Invalid SDK configuration" console.error(err.details) // Zod validation issues } } ``` # Logging (/docs/configuration/logging) MarzbanSDK ships with a built-in colored logger and a flexible interface that lets you swap it out for any logger you prefer — Winston, Pino, NestJS Logger, or anything else. ## Default behaviour [#default-behaviour] When you don't set `logger` at all, the SDK picks the log level based on the environment: | `NODE_ENV` | Default level | | -------------------------- | ------------- | | `development` | `info` | | anything else (production) | `error` | ## Built-in logger options [#built-in-logger-options] To tune the built-in logger, pass an options object to `logger`: ```ts const sdk = await createMarzbanSDK({ // ... logger: { level: 'debug', // 'debug' | 'info' | 'warn' | 'error' timestamp: true, // prepend ISO timestamp to each line (default: true) stream: 'stdout', // 'stdout' | 'stderr' (default: 'stdout') }, }) ``` ### `LoggerOptions` [#loggeroptions] The shape of the options object. All fields are optional — omit one to keep its default. ```ts import type { LoggerOptions } from 'marzban-sdk' ``` | Field | Type | Required | Description | | ----------- | ---------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `level` | `'debug' \| 'info' \| 'warn' \| 'error'` | No | Minimum level to emit. Defaults to the environment-based level (`info` in development, `error` in production). | | `timestamp` | `boolean` | No | Prepend an ISO timestamp to each line. Default: `true`. | | `stream` | `'stdout' \| 'stderr'` | No | Where the built-in logger writes. Default: `'stdout'`. | ### Output stream [#output-stream] `stream: 'stderr'` writes straight to `process.stderr`, bypassing `console` entirely (with its own TTY-based color detection). Use it whenever stdout is reserved for something else that a stray log line would corrupt — the canonical case is a stdio-based server, like an MCP server, where stdout carries the JSON-RPC wire protocol: ```ts const sdk = await createMarzbanSDK({ // ... logger: { stream: 'stderr' }, }) ``` With a custom logger object (see below) instead of the built-in one, you're responsible for choosing the right stream yourself — `stream` only affects the built-in logger. ### Log levels [#log-levels] | Level | When to use | | ------- | --------------------------------------------------------------- | | `debug` | Verbose internal traces (HTTP calls, retry attempts, WS events) | | `info` | Major lifecycle events (auth success, connections opened) | | `warn` | Non-fatal issues (auth retry, token refresh) | | `error` | Failures and exceptions | Each level also emits every more severe level. For example, `warn` also logs `error` messages. ## Log output format [#log-output-format] The built-in logger outputs lines like: ``` [MarzbanSDK] 2024-01-15T10:23:05.123Z INFO [AuthManager] Authentication successful, token stored [MarzbanSDK] 2024-01-15T10:23:05.200Z DEBUG [HttpClient] Configuring HTTP client: baseURL=https://..., timeout=30000ms, retries=3 ``` Fields: `[MarzbanSDK]` prefix · ISO timestamp · level (padded) · `[context]` · message. ## Disable logging [#disable-logging] Pass `logger: false` to suppress all SDK output: ```ts const sdk = await createMarzbanSDK({ // ... logger: false, }) ``` ## Custom logger [#custom-logger] Provide any object that implements the `Logger` interface: ```ts interface Logger { debug(message: string, context?: string): void info(message: string, context?: string): void warn(message: string, context?: string): void error(message: string, trace?: unknown, context?: string): void } ``` ### Example: Winston [#example-winston] ```ts import winston from 'winston' import { createMarzbanSDK } from 'marzban-sdk' const winstonLogger = winston.createLogger({ level: 'info', transports: [new winston.transports.Console()], }) const sdk = await createMarzbanSDK({ // ... logger: { debug: (msg, ctx) => winstonLogger.debug(msg, { context: ctx }), info: (msg, ctx) => winstonLogger.info(msg, { context: ctx }), warn: (msg, ctx) => winstonLogger.warn(msg, { context: ctx }), error: (msg, trace, ctx) => winstonLogger.error(msg, { context: ctx, trace }), }, }) ``` ### Example: Pino [#example-pino] ```ts import pino from 'pino' import { createMarzbanSDK } from 'marzban-sdk' const logger = pino({ level: 'debug' }) const sdk = await createMarzbanSDK({ // ... logger: { debug: (msg, ctx) => logger.debug({ ctx }, msg), info: (msg, ctx) => logger.info({ ctx }, msg), warn: (msg, ctx) => logger.warn({ ctx }, msg), error: (msg, trace, ctx) => logger.error({ ctx, trace }, msg), }, }) ``` ### Example: NestJS Logger [#example-nestjs-logger] ```ts import { Logger as NestLogger } from '@nestjs/common' import { createMarzbanSDK } from 'marzban-sdk' const nestLogger = new NestLogger('MarzbanSDK') const sdk = await createMarzbanSDK({ // ... logger: { debug: (msg, ctx) => nestLogger.debug(msg, ctx), info: (msg, ctx) => nestLogger.log(msg, ctx), warn: (msg, ctx) => nestLogger.warn(msg, ctx), error: (msg, trace, ctx) => nestLogger.error(msg, trace, ctx), }, }) ``` # Installation (/docs/get-started/installation) MarzbanSDK is published to npm and works in **Node.js**, **Bun**, **Deno**, **browser**, and **edge runtimes** (Cloudflare Workers, Vercel Edge, etc.). ## Add to your project [#add-to-your-project] ```bash npm install marzban-sdk ``` ```bash yarn add marzban-sdk ``` ```bash pnpm add marzban-sdk ``` ```bash bun add marzban-sdk ``` ```bash deno add npm:marzban-sdk ``` ```ts import { createMarzbanSDK } from 'https://esm.sh/marzban-sdk' ``` No build step — import straight from esm.sh in the browser or Deno. ## Requirements [#requirements] | Runtime | Minimum version | | ------- | -------------------------------------------------------- | | Node.js | 18+ | | Bun | 1.0+ | | Deno | 1.38+ | | Browser | Any modern browser (Chrome 90+, Firefox 90+, Safari 15+) | Node.js 18+ is required because the SDK uses the **Web Crypto API** (`crypto.subtle`) for webhook signature verification. Native `WebSocket` is available from Node.js 21+; older Node versions automatically fall back to the `ws` package (bundled as an optional peer dependency). ## What's included [#whats-included] The package ships with: * **ESM** and **CJS** dual-format bundles — no configuration required. * Full **TypeScript** declarations (`.d.ts`) generated from the OpenAPI spec. * **Tree-shakeable** helpers and utilities in separate export paths. ## Next steps [#next-steps] * [Quick Start](/docs/get-started/quick-start) — create your first client and make a request. * [Configuration](/docs/configuration/config-options) — all options and defaults. # Quick Start (/docs/get-started/quick-start) ## Create the SDK instance [#create-the-sdk-instance] The recommended way to create the SDK is via `createMarzbanSDK`. It validates your config, constructs the client, and authenticates in one step. ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://your-marzban-instance.com', username: 'admin', password: 'your-password', }) ``` `createMarzbanSDK` is an **async factory** — it calls `sdk.authorize()` internally before returning. If authentication fails, it throws an `AuthError`. ## Make your first request [#make-your-first-request] ```ts // Get all users (paginated) const users = await sdk.user.getUsers() console.log(users) // Get a single user by username const user = await sdk.user.getUser('alice') console.log(user.status, user.data_limit) // Get system statistics const stats = await sdk.system.getSystemStats() console.log(`Memory: ${stats.mem_used} / ${stats.mem_total}`) ``` Every method returns a **Promise** that resolves to a fully typed response object. ## Create a user [#create-a-user] ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ /* ... */ }) const newUser = await sdk.user.addUser({ username: 'alice', proxies: { vless: {} }, inbounds: { vless: ['VLESS TCP REALITY'] }, data_limit: 10 * 1024 ** 3, // 10 GB in bytes expire: Math.floor(Date.now() / 1000) + 30 * 24 * 3600, // 30 days from now }) console.log(newUser.subscription_url) ``` ## Handle errors [#handle-errors] ```ts import { createMarzbanSDK, isAuthError, isHttpError } from 'marzban-sdk' try { const sdk = await createMarzbanSDK({ /* ... */ }) const user = await sdk.user.getUser('unknown') } catch (err) { if (isAuthError(err)) { console.error('Authentication failed:', err.message) } else if (isHttpError(err)) { console.error('HTTP error:', err.message, err.details) } else { throw err } } ``` See [Error Handling](/docs/advanced/error-handling) for the full error reference. ## What happens under the hood [#what-happens-under-the-hood] 1. `createMarzbanSDK` validates your config with Zod — bad config throws immediately with a clear message. 2. An Axios-based HTTP client is configured with your `baseUrl`, `timeout`, and `retries`. 3. `sdk.authorize()` is called — credentials are posted to `/api/admin/token`, and the returned JWT is stored. 4. All subsequent API calls include `Authorization: Bearer ` automatically. 5. If the server returns `401`, the SDK re-authenticates once and retries the request transparently. ## Next steps [#next-steps] * [Configuration options](/docs/configuration/config-options) — `timeout`, `retries`, `logger`, and more. * [Authentication](/docs/authentication/auto-authentication) — auto-auth vs. manual mode. * [Modules](/docs/modules/users) — full API reference for Users, Admins, Nodes, and more. # TypeScript & Modules (/docs/get-started/typescript) ## TypeScript support [#typescript-support] MarzbanSDK is written in TypeScript and ships with **complete type declarations** generated directly from the Marzban OpenAPI specification. Every request parameter, response object, error type, and configuration field is fully typed. No `@types/marzban-sdk` package is needed — types are included in the main package. ## Import types [#import-types] All public types are re-exported from the root entry point: ```ts import type { Config, UserResponse, UserCreate, UserModify, NodeResponse, AdminCreate, WebhookType, } from 'marzban-sdk' ``` ### Common types [#common-types] | Type | Description | | -------------- | ------------------------------------------------------------ | | `Config` | SDK configuration object (input type with defaults optional) | | `UserResponse` | Marzban user as returned by the API | | `UserCreate` | Payload for creating a new user | | `UserModify` | Payload for updating a user | | `NodeResponse` | A Marzban node object | | `AdminCreate` | Payload for creating an admin | | `WebhookType` | Discriminated union of all 12 webhook event types | | `LogOptions` | Options for WebSocket log stream connections | ## ESM and CJS [#esm-and-cjs] The package ships a **dual bundle**: | Format | Entry point | When used | | ------ | ---------------- | ------------------------------------------------------- | | ESM | `dist/index.mjs` | `import` statements, bundlers (Vite, Webpack 5, Rollup) | | CJS | `dist/index.cjs` | `require()`, older Node.js toolchains | Node.js and all modern bundlers resolve the correct format automatically via the `exports` field in `package.json`. ```ts // ESM (recommended) import { createMarzbanSDK } from 'marzban-sdk' // CJS const { createMarzbanSDK } = require('marzban-sdk') ``` ## Zod schema exports [#zod-schema-exports] Every model's Zod schema is exported alongside the TypeScript types. Use them when you need to validate external data: ```ts import { userResponseSchema } from 'marzban-sdk' const result = userResponseSchema.safeParse(unknownData) if (result.success) { console.log(result.data.username) } ``` ## Infer types from schemas [#infer-types-from-schemas] ```ts import { z } from 'zod/v4' import { userResponseSchema } from 'marzban-sdk' type User = z.infer ``` ## tsconfig recommendations [#tsconfig-recommendations] ```json { "compilerOptions": { "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "target": "ES2022" } } ``` The SDK uses `zod/v4` (Zod 4 scoped import). If your project also uses Zod, make sure you're on **Zod 4** or later to avoid version conflicts. # NestJS (/docs/integrations/nestjs) The recommended pattern is to wrap the SDK in a **NestJS module** with a provider that initializes it during `onModuleInit` and exposes it as an injectable service. ## Setup [#setup] ```bash npm install @nestjs/core @nestjs/common marzban-sdk ``` ## MarzbanModule [#marzbanmodule] ```ts // marzban/marzban.module.ts import { Global, Module } from '@nestjs/common' import { MarzbanService } from './marzban.service' @Global() @Module({ providers: [MarzbanService], exports: [MarzbanService], }) export class MarzbanModule {} ``` ## MarzbanService [#marzbanservice] ```ts // marzban/marzban.service.ts import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common' import { createMarzbanSDK, MarzbanSDK } from 'marzban-sdk' import { ConfigService } from '@nestjs/config' @Injectable() export class MarzbanService implements OnModuleInit, OnModuleDestroy { private _sdk!: MarzbanSDK constructor(private config: ConfigService) {} async onModuleInit() { this._sdk = await createMarzbanSDK({ baseUrl: this.config.getOrThrow('MARZBAN_URL'), username: this.config.getOrThrow('MARZBAN_USER'), password: this.config.getOrThrow('MARZBAN_PASS'), logger: { level: this.config.get('NODE_ENV') === 'production' ? 'error' : 'info', }, webhook: { secret: this.config.get('MARZBAN_WEBHOOK_SECRET'), }, }) } async onModuleDestroy() { await this._sdk?.destroy() } get sdk(): MarzbanSDK { return this._sdk } } ``` ## Using in a service [#using-in-a-service] ```ts // users/users.service.ts import { Injectable } from '@nestjs/common' import { MarzbanService } from '../marzban/marzban.service' import { parseSize, formatBytes } from 'marzban-sdk' @Injectable() export class UsersService { constructor(private marzban: MarzbanService) {} async getAllUsers() { const result = await this.marzban.sdk.user.getUsers({ limit: 1000 }) return result.users.map(u => ({ username: u.username, status: u.status, dataUsed: formatBytes(u.used_traffic), dataLimit: u.data_limit ? formatBytes(u.data_limit) : 'Unlimited', })) } async createUser(username: string, limitGb: number, days: number) { const expire = Math.floor(Date.now() / 1000) + days * 86400 return this.marzban.sdk.user.addUser({ username, proxies: { vless: {} }, inbounds: { vless: ['VLESS TCP REALITY'] }, data_limit: parseSize(`${limitGb}GB`), expire, }) } } ``` ## Using in a controller [#using-in-a-controller] ```ts // users/users.controller.ts import { Controller, Get, Post, Body, Param } from '@nestjs/common' import { UsersService } from './users.service' @Controller('users') export class UsersController { constructor(private users: UsersService) {} @Get() getAll() { return this.users.getAllUsers() } @Post() create(@Body() body: { username: string; limitGb: number; days: number }) { return this.users.createUser(body.username, body.limitGb, body.days) } } ``` ## AppModule [#appmodule] ```ts // app.module.ts import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' import { MarzbanModule } from './marzban/marzban.module' import { UsersModule } from './users/users.module' @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), MarzbanModule, UsersModule, ], }) export class AppModule {} ``` For webhook handling in NestJS, see the [Webhooks → NestJS](/docs/webhooks/nestjs) guide. # Next.js (/docs/integrations/nextjs) MarzbanSDK is **server-side only** in Next.js. The SDK calls your Marzban API using credentials that must never be exposed to the browser. ## Singleton on the server [#singleton-on-the-server] Create a shared singleton in a server-only module: ```ts // lib/marzban.ts import 'server-only' import { createMarzbanSDK, MarzbanSDK } from 'marzban-sdk' let sdkPromise: Promise | null = null export function getSDK(): Promise { if (!sdkPromise) { sdkPromise = createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, }) } return sdkPromise } ``` Add `MARZBAN_URL`, `MARZBAN_USER`, and `MARZBAN_PASS` to your `.env.local`. Do **not** prefix them with `NEXT_PUBLIC_` — that would expose them to the browser. ## Server Components [#server-components] ```tsx // app/dashboard/page.tsx import { getSDK } from '@/lib/marzban' import { formatBytes } from 'marzban-sdk' export default async function DashboardPage() { const sdk = await getSDK() const stats = await sdk.system.getSystemStats() return (

Marzban Dashboard

Active users: {stats.users_active} / {stats.total_user}

Memory: {formatBytes(stats.mem_used)} / {formatBytes(stats.mem_total)}

) } ``` ## Route Handlers [#route-handlers] ```ts // app/api/users/route.ts import { NextResponse } from 'next/server' import { getSDK } from '@/lib/marzban' export async function GET() { const sdk = await getSDK() const result = await sdk.user.getUsers({ limit: 50 }) return NextResponse.json(result) } export async function POST(req: Request) { const sdk = await getSDK() const body = await req.json() const user = await sdk.user.addUser(body) return NextResponse.json(user, { status: 201 }) } ``` ## Server Actions [#server-actions] ```ts // app/actions/users.ts 'use server' import { getSDK } from '@/lib/marzban' import { parseSize } from 'marzban-sdk' import { revalidatePath } from 'next/cache' export async function createUser(formData: FormData) { const sdk = await getSDK() await sdk.user.addUser({ username: formData.get('username') as string, proxies: { vless: {} }, inbounds: { vless: ['VLESS TCP REALITY'] }, data_limit: parseSize(formData.get('limit') as string), expire: 0, }) revalidatePath('/dashboard/users') } ``` ## Webhook Route Handler [#webhook-route-handler] See [Webhooks → Next.js](/docs/webhooks/nextjs) for the full webhook integration with raw body and signature verification. ## Environment variables [#environment-variables] ```bash MARZBAN_URL=https://vpn.example.com MARZBAN_USER=admin MARZBAN_PASS=your-password MARZBAN_WEBHOOK_SECRET=your-webhook-secret ``` # Node.js / Bun / Deno (/docs/integrations/node-bun-deno) MarzbanSDK works out of the box in Node.js 18+, Bun 1.0+, and Deno 1.38+ with no additional configuration. ## Node.js (TypeScript) [#nodejs-typescript] ```ts // main.ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, }) const stats = await sdk.system.getSystemStats() console.log('Users active:', stats.users_active) await sdk.destroy() ``` Run with `tsx`: ```bash npx tsx main.ts ``` Or compile first: ```bash npx tsc && node dist/main.js ``` ## Bun [#bun] ```ts // main.ts — identical code, run directly import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: Bun.env.MARZBAN_URL!, username: Bun.env.MARZBAN_USER!, password: Bun.env.MARZBAN_PASS!, }) const users = await sdk.user.getUsers() console.log(`Total users: ${users.total}`) await sdk.destroy() ``` ```bash bun run main.ts ``` ## Deno [#deno] ```ts // main.ts import { createMarzbanSDK } from 'npm:marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: Deno.env.get('MARZBAN_URL')!, username: Deno.env.get('MARZBAN_USER')!, password: Deno.env.get('MARZBAN_PASS')!, }) const nodes = await sdk.node.getNodes() console.log(`Nodes: ${nodes.length}`) await sdk.destroy() ``` ```bash deno run --allow-net --allow-env main.ts ``` ## Singleton pattern for long-running services [#singleton-pattern-for-long-running-services] For daemons, cron jobs, or API servers, create the SDK once and reuse it: ```ts // sdk.ts import { createMarzbanSDK, MarzbanSDK } from 'marzban-sdk' let _sdk: MarzbanSDK | null = null export async function getSDK(): Promise { if (!_sdk) { _sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, }) } return _sdk } ``` ```ts // service.ts import { getSDK } from './sdk' export async function syncUsers() { const sdk = await getSDK() const { users } = await sdk.user.getUsers({ status: 'active', limit: 1000 }) // ... } ``` ## Graceful shutdown [#graceful-shutdown] ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ /* ... */ }) // Start log streaming const closeLog = await sdk.logs.connectByCore({ onMessage: (data) => process.stdout.write(data), }) // Graceful shutdown on SIGTERM / SIGINT async function shutdown() { console.log('Shutting down...') await sdk.destroy() // closes WebSocket streams process.exit(0) } process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) ``` # React (/docs/integrations/react) MarzbanSDK must **not** be used directly in browser React apps when your Marzban credentials need to stay secret. Use a backend API (Next.js Route Handler, Express, etc.) as a proxy and call that from React. This page covers cases where you control the network environment (admin dashboards on private networks, Electron apps, etc.). ## SDK context [#sdk-context] Create a React context that holds the SDK instance: ```tsx // context/MarzbanContext.tsx import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' import { createMarzbanSDK, type MarzbanSDK } from 'marzban-sdk' const MarzbanContext = createContext(null) export function MarzbanProvider({ children }: { children: ReactNode }) { const [sdk, setSdk] = useState(null) useEffect(() => { let instance: MarzbanSDK | null = null createMarzbanSDK({ baseUrl: import.meta.env.VITE_MARZBAN_URL, username: import.meta.env.VITE_MARZBAN_USER, password: import.meta.env.VITE_MARZBAN_PASS, }).then(s => { instance = s setSdk(s) }) return () => { instance?.destroy() } }, []) return ( {children} ) } export function useMarzban(): MarzbanSDK { const sdk = useContext(MarzbanContext) if (!sdk) throw new Error('useMarzban must be used inside MarzbanProvider') return sdk } ``` ```tsx // main.tsx import { MarzbanProvider } from './context/MarzbanContext' ReactDOM.createRoot(document.getElementById('root')!).render( ) ``` ## Using the hook [#using-the-hook] ```tsx // components/UserList.tsx import { useEffect, useState } from 'react' import { useMarzban } from '../context/MarzbanContext' import type { UserResponse } from 'marzban-sdk' export function UserList() { const sdk = useMarzban() const [users, setUsers] = useState([]) useEffect(() => { sdk.user.getUsers({ limit: 50 }).then(r => setUsers(r.users)) }, [sdk]) return (
    {users.map(u => (
  • {u.username} — {u.status}
  • ))}
) } ``` ## With TanStack Query [#with-tanstack-query] ```tsx // hooks/useUsers.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMarzban } from '../context/MarzbanContext' import { parseSize } from 'marzban-sdk' export function useUsers() { const sdk = useMarzban() return useQuery({ queryKey: ['users'], queryFn: () => sdk.user.getUsers({ limit: 1000 }), }) } export function useCreateUser() { const sdk = useMarzban() const qc = useQueryClient() return useMutation({ mutationFn: (payload: { username: string; limitGb: number }) => sdk.user.addUser({ username: payload.username, proxies: { vless: {} }, inbounds: { vless: ['VLESS TCP REALITY'] }, data_limit: parseSize(`${payload.limitGb}GB`), expire: 0, }), onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }), }) } ``` ## WebSocket logs in a component [#websocket-logs-in-a-component] ```tsx import { useEffect, useRef } from 'react' import { useMarzban } from '../context/MarzbanContext' export function CoreLogViewer() { const sdk = useMarzban() const ref = useRef(null) useEffect(() => { let close: (() => void) | undefined sdk.logs.connectByCore({ onMessage: (data) => { if (ref.current) ref.current.textContent += data + '\n' }, }).then(fn => { close = fn }) return () => { close?.() } }, [sdk]) return
}
```


# Vue (/docs/integrations/vue)




  Use MarzbanSDK in browser Vue apps only in controlled network environments (admin dashboards on private networks, Electron apps). In public-facing apps, proxy API calls through a backend.


## Plugin / composable pattern [#plugin--composable-pattern]

```ts
// plugins/marzban.ts
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue'
import { createMarzbanSDK, type MarzbanSDK } from 'marzban-sdk'

const MARZBAN_KEY: InjectionKey> = Symbol('marzban')

export function provideMarzban() {
  const sdk = ref(null)

  createMarzbanSDK({
    baseUrl: import.meta.env.VITE_MARZBAN_URL,
    username: import.meta.env.VITE_MARZBAN_USER,
    password: import.meta.env.VITE_MARZBAN_PASS,
  }).then(s => { sdk.value = s })

  provide(MARZBAN_KEY, sdk)

  // Cleanup on app unmount
  return () => sdk.value?.destroy()
}

export function useMarzban(): MarzbanSDK {
  const sdk = inject(MARZBAN_KEY)
  if (!sdk?.value) throw new Error('Marzban SDK not initialized')
  return sdk.value
}
```

```ts
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { provideMarzban } from './plugins/marzban'

const app = createApp(App)

// Provide at root level
app.mount('#app')

const cleanup = provideMarzban()
// cleanup() on app unmount if needed
```

## Using in components [#using-in-components]

```vue




```

## Composable with data fetching [#composable-with-data-fetching]

```ts
// composables/useUsers.ts
import { ref, onMounted } from 'vue'
import { useMarzban } from '../plugins/marzban'
import type { UserResponse } from 'marzban-sdk'

export function useUsers() {
  const sdk = useMarzban()
  const users = ref([])
  const total = ref(0)
  const loading = ref(false)
  const error = ref(null)

  async function fetchUsers(page = 0, limit = 50) {
    loading.value = true
    error.value = null
    try {
      const result = await sdk.user.getUsers({ offset: page * limit, limit })
      users.value = result.users
      total.value = result.total
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  onMounted(() => fetchUsers())

  return { users, total, loading, error, fetchUsers }
}
```

## WebSocket logs composable [#websocket-logs-composable]

```ts
// composables/useCoreLogs.ts
import { ref, onUnmounted } from 'vue'
import { useMarzban } from '../plugins/marzban'

export function useCoreLogs() {
  const sdk = useMarzban()
  const logs = ref([])
  let close: (() => void) | undefined

  async function start() {
    close = await sdk.logs.connectByCore({
      onMessage: (data) => { logs.value.push(String(data)) },
    })
  }

  function stop() {
    close?.()
  }

  onUnmounted(stop)

  return { logs, start, stop }
}
```


# Client Setup (/docs/mcp-server/client-setup)



Almost every MCP client reads the same JSON shape — a `command`/`args`/`env` block, keyed by a server name you choose, under an `mcpServers` object:

```json
{
  "mcpServers": {
    "marzban": {
      "command": "npx",
      "args": ["-y", "marzban-mcp"],
      "env": {
        "MARZBAN_BASE_URL": "https://panel.example.com",
        "MARZBAN_USERNAME": "admin",
        "MARZBAN_PASSWORD": "secret"
      }
    }
  }
}
```

What differs per client is *where* that block goes, and occasionally the key it's nested under. Pick your client below.


  Every example below uses `npx`. Prefer a container instead? See [Running via Docker](#running-via-docker) — swap the `command`/`args` shown there into any of the client configs below, keeping that client's own `env` block.



  
    Edit the config file directly, then fully quit and reopen the app (not just close the window):

    | OS      | Path                                                              |
    | ------- | ----------------------------------------------------------------- |
    | macOS   | `~/Library/Application Support/Claude/claude_desktop_config.json` |
    | Windows | `%APPDATA%\Claude\claude_desktop_config.json`                     |

    Use the JSON block above as-is. If the file already has other servers, add `marzban` alongside them inside the existing `mcpServers` object rather than replacing it.
  

  
    Two options — pick whichever fits how you work:

    **CLI** (writes the config for you):

    ```bash
    claude mcp add \
      --env MARZBAN_BASE_URL=https://panel.example.com \
      --env MARZBAN_USERNAME=admin \
      --env MARZBAN_PASSWORD=secret \
      marzban -- npx -y marzban-mcp
    ```

    By default this registers the server at `local` scope — private to you, active only in the current project. Add `--scope project` to write it into `.mcp.json` and share it with teammates via version control, or `--scope user` to make it available across every project.

    **Hand-edit `.mcp.json`** at your project root with the same JSON block shown above — this is what `--scope project` writes for you, so editing it directly is equivalent.
  

  
    Create (or edit) one of these with the JSON block above:

    | Scope             | Path                 |
    | ----------------- | -------------------- |
    | This project only | `.cursor/mcp.json`   |
    | Every project     | `~/.cursor/mcp.json` |
  

  
    Cline keeps its own settings file, separate from VS Code's or any other extension's MCP config:

    | OS      | Path                                                                                                            |
    | ------- | --------------------------------------------------------------------------------------------------------------- |
    | macOS   | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` |
    | Windows | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json`                     |
    | Linux   | `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`                     |

    Easier than navigating there by hand: open the Cline panel → **MCP Servers** icon → **Configure** tab → **Configure MCP Servers**, which opens this same file for editing. Add `marzban` under its `mcpServers` key using the JSON block above.
  

  
    | OS      | Path                                              |
    | ------- | ------------------------------------------------- |
    | macOS   | `~/.codeium/windsurf/mcp_config.json`             |
    | Windows | `%USERPROFILE%\.codeium\windsurf\mcp_config.json` |

    Or from the UI: click the MCP icon in the Cascade panel → **Configure** → **View raw config**. Add the JSON block above, save, and restart Windsurf.
  

  
    Continue's own config is YAML, but it also auto-loads any JSON file dropped into `.continue/mcpServers/` — the simplest route is to save the JSON block above as `.continue/mcpServers/marzban.json` in your project.

    To configure it in native YAML instead (e.g. to add it to `~/.continue/config.yaml`'s top-level `mcpServers` block):

    ```yaml
    mcpServers:
      - name: marzban
        command: npx
        args: ["-y", "marzban-mcp"]
        env:
          MARZBAN_BASE_URL: https://panel.example.com
          MARZBAN_USERNAME: admin
          MARZBAN_PASSWORD: secret
    ```
  

  
    
      VS Code uses `servers`, not `mcpServers`, as the top-level key — copying the JSON block above as-is won't work here.
    

    Create `.vscode/mcp.json` in your project:

    ```json
    {
      "servers": {
        "marzban": {
          "command": "npx",
          "args": ["-y", "marzban-mcp"],
          "env": {
            "MARZBAN_BASE_URL": "https://panel.example.com",
            "MARZBAN_USERNAME": "admin",
            "MARZBAN_PASSWORD": "secret"
          }
        }
      }
    }
    ```

    Restart VS Code, then the tools are available to Copilot Chat (and any other MCP-aware extension). To avoid committing your password in plain text, VS Code supports an `inputs` section that prompts for a value and injects it via `${input:...}` — see the [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) for the syntax.
  


## Running via Docker [#running-via-docker]

Every release is also published as a multi-arch (`linux/amd64` + `linux/arm64`) image at [`ilmar7786/marzban-mcp`](https://hub.docker.com/r/ilmar7786/marzban-mcp). Use this instead of `npx` when you'd rather not have Node.js on the host, or want a pinned, reproducible runtime.

The server talks MCP over stdio, so the container needs `-i` (keep stdin open) and `--rm` (don't leave stopped containers around) — no port to publish, nothing to health-check from outside:

```json
{
  "mcpServers": {
    "marzban": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "MARZBAN_BASE_URL",
        "-e", "MARZBAN_USERNAME",
        "-e", "MARZBAN_PASSWORD",
        "ilmar7786/marzban-mcp"
      ],
      "env": {
        "MARZBAN_BASE_URL": "https://panel.example.com",
        "MARZBAN_USERNAME": "admin",
        "MARZBAN_PASSWORD": "secret"
      }
    }
  }
}
```

`-e MARZBAN_BASE_URL` with no `=value` tells Docker to pass that variable through from the process's own environment — which is exactly what the client's `env` block sets before launching `docker run`. This is the same pattern for any of the [optional configuration variables](/docs/mcp-server/configuration): add another `-e MARZBAN_MCP_...` arg and the matching key under `env`.

Pin a specific version instead of always pulling `latest` with `ilmar7786/marzban-mcp:X.Y.Z` — see the [tags list](https://hub.docker.com/r/ilmar7786/marzban-mcp/tags) or the [GitHub Releases](https://github.com/Ilmar7786/marzban-sdk/releases) for available versions.

## Common issues [#common-issues]

* **First call hangs or times out** — `npx -y marzban-mcp` downloads the package on first run if it isn't cached yet. Give it a minute, especially on a slow connection.
* **Nothing shows up after editing the config** — every client here needs a restart (or, for VS Code, at least a config reload) to pick up changes. Editing the file alone isn't enough.
* **"command not found: npx"** — the client launches the server with whatever `PATH` it sees, which isn't always your shell's `PATH` (especially for GUI apps on macOS). Make sure Node.js is installed where the client can find it, or use an absolute path to `npx` in `command`.


# Configuration (/docs/mcp-server/configuration)



Configuration is entirely env-based — set these in your MCP client's `env` block (see [Client Setup](/docs/mcp-server/client-setup)). Nothing here is ever accepted as a tool argument; see [Security](/docs/mcp-server/security) for why that's a hard rule, not just a convention.

## Credentials [#credentials]

| Variable           | Required | Default | Description                                                  |
| ------------------ | -------- | ------- | ------------------------------------------------------------ |
| `MARZBAN_BASE_URL` | Yes      | —       | Your panel's URL, e.g. `https://panel.example.com`           |
| `MARZBAN_USERNAME` | Yes      | —       | Admin username                                               |
| `MARZBAN_PASSWORD` | Yes      | —       | Admin password                                               |
| `MARZBAN_TOKEN`    | No       | —       | An already-valid session token, to skip the first login call |


  `MARZBAN_USERNAME`/`MARZBAN_PASSWORD` are required even when `MARZBAN_TOKEN` is set — see [Security](/docs/mcp-server/security#why-both-username-and-password-are-required) for why a token alone isn't enough.


## Access control [#access-control]

| Variable                  | Default    | Values                                        | Description                                                                                                                      |
| ------------------------- | ---------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `MARZBAN_MCP_PROFILE`     | `standard` | `readonly` \| `standard` \| `full`            | Which tools get registered at all — see [Security](/docs/mcp-server/security#profiles)                                           |
| `MARZBAN_MCP_CONFIRM`     | `auto`     | `off` \| `auto` \| `always`                   | How often a destructive tool re-asks for confirmation — see [Security](/docs/mcp-server/security#confirming-destructive-actions) |
| `MARZBAN_MCP_SHOW_LINKS`  | `false`    | `true` \| `false`                             | Whether `proxies`, `subscription_url`, and `links` are shown in full instead of masked                                           |
| `MARZBAN_MCP_TOOLS_ALLOW` | —          | Comma-separated globs, e.g. `marzban_users_*` | If set, only matching tools are registered                                                                                       |
| `MARZBAN_MCP_TOOLS_DENY`  | —          | Comma-separated globs                         | Matching tools are never registered — wins over `_ALLOW` when both match the same tool                                           |

## Output shaping [#output-shaping]

| Variable                | Default   | Values                      | Description                                           |
| ----------------------- | --------- | --------------------------- | ----------------------------------------------------- |
| `MARZBAN_MCP_FORMAT`    | `text`    | `text` \| `table` \| `json` | How a tool's result is rendered for the model to read |
| `MARZBAN_MCP_VERBOSITY` | `compact` | `compact` \| `full`         | How many fields each response includes                |
| `MARZBAN_MCP_MAX_CHARS` | `8000`    | Positive integer            | Character budget per response before truncation       |

See [Response Format & Token Economy](/docs/mcp-server/response-format) for what each of these actually changes.

## Logging [#logging]

| Variable                | Default | Values                                 | Description                     |
| ----------------------- | ------- | -------------------------------------- | ------------------------------- |
| `MARZBAN_MCP_LOG_LEVEL` | `warn`  | `debug` \| `info` \| `warn` \| `error` | Minimum level written to stderr |

Logs never go to stdout — stdout is reserved for the JSON-RPC protocol itself, and a single stray byte there would break the connection.

## TLS / self-signed certificates [#tls--self-signed-certificates]

| Variable                          | Default | Values                  | Description                                                                                                                                                                                                     |
| --------------------------------- | ------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MARZBAN_TLS_CA_FILE`             | —       | Path to a CA cert (PEM) | Trust this CA in addition to the system store — for a panel behind a self-signed or internal-CA certificate. Relative paths resolve against the process's working directory.                                    |
| `MARZBAN_TLS_REJECT_UNAUTHORIZED` | —       | `true` \| `false`       | Escape hatch for a panel you can't get a trusted/known CA for. Setting `false` disables certificate validation entirely and logs a startup warning — prefer `MARZBAN_TLS_CA_FILE` whenever the CA is available. |

An unreadable `MARZBAN_TLS_CA_FILE` fails startup with a `ConfigError` naming the resolved path, rather than a bare `ENOENT`.


# Overview (/docs/mcp-server/overview)



[`marzban-mcp`](https://www.npmjs.com/package/marzban-mcp) is a [Model Context Protocol](https://modelcontextprotocol.io) server built on `marzban-sdk`. It gives an AI agent — Claude, Cursor, or any other MCP client — a set of tools to manage a Marzban panel: users, subscriptions, nodes, and the core config.

It's a separate package from the SDK itself. If you're building your own integration in code, you want [`marzban-sdk`](/docs) instead — this section covers the ready-made server for AI clients.


  Looking for how your AI assistant can read up-to-date *documentation* about `marzban-sdk`/`marzban-mcp`, rather than operate a panel? See [AI Tools → Context7](/docs/ai-tools/context7).


## At a glance [#at-a-glance]

* **21 tools**, namespaced `marzban__`, covering the full user lifecycle plus config, hosts, nodes, system stats, and subscriptions.
* **3 prompts** that chain those tools into ready-made investigations (expiring subscriptions, node health, traffic reports).
* **Profile-gated access** — a tool outside the active profile never appears in `tools/list` at all, not just hidden behind a hint.
* **Every destructive action needs confirmation** — a first call only describes the consequences and returns a one-time token; nothing runs until a second, explicitly confirmed call repeats it.
* **Credentials only from environment variables** — never accepted as a tool argument, so a model can't redirect the server or leak them through a call.
* **Credential-bearing fields masked by default** — `proxies`, `subscription_url`, and `links` stay hidden unless you opt in.

## Quick example [#quick-example]

```json title="claude_desktop_config.json / .mcp.json"
{
  "mcpServers": {
    "marzban": {
      "command": "npx",
      "args": ["-y", "marzban-mcp"],
      "env": {
        "MARZBAN_BASE_URL": "https://panel.example.com",
        "MARZBAN_USERNAME": "admin",
        "MARZBAN_PASSWORD": "secret"
      }
    }
  }
}
```

Nearly every MCP client reads this same shape — see [Client Setup](/docs/mcp-server/client-setup) for exact instructions per client. Prefer a container over Node.js on the host? A multi-arch image is published at [`ilmar7786/marzban-mcp`](https://hub.docker.com/r/ilmar7786/marzban-mcp) — see [Client Setup → Running via Docker](/docs/mcp-server/client-setup#running-via-docker).


  

  

  

  

  

  



# Prompts (/docs/mcp-server/prompts)



A prompt isn't a tool — it doesn't touch the panel itself. It's a canned instruction that tells the model which tools to call, in what order, and what to look for in the results. Every tool that ends up called is still gated by the active [profile](/docs/mcp-server/security#profiles) and [confirmation rules](/docs/mcp-server/security#confirming-destructive-actions) exactly as if it had been called directly.

Your MCP client surfaces these as selectable prompts — check its docs for exactly how (a slash command, a prompt picker, etc.).

## `expiring_users_audit` [#expiring_users_audit]

Finds users whose subscription is expiring soon, or is already expired/limited, and suggests a next step for each.

**Argument:** `withinDays` (optional, default `7`) — how many days ahead counts as "expiring soon".

What it does:

1. Lists users (paging through all of them if there are many, via `marzban_users_list`).
2. Compares each active/on\_hold user's days-left figure against the window.
3. Also includes anyone already `expired` or `limited`.
4. Reports them grouped by urgency — already expired first, then soonest-to-expire — with status, days left, and usage.
5. Suggests `marzban_users_extend` or `marzban_users_deactivate` per user, but doesn't call either without the user's go-ahead.

## `node_diagnostics` [#node_diagnostics]

Investigates why a node might be unhealthy.

**Argument:** `nodeName` (optional) — focus on one node; omit to check all of them.

What it does:

1. Calls `marzban_nodes_list` for status, Xray version, and any error message per node.
2. Calls `marzban_system_stats` for panel-wide context — whether the core itself is running.
3. Flags any node not in `connected` status, quoting its message verbatim.
4. Compares Xray versions across nodes — a stale or missing version on one node often points to a failed update rather than a network issue.
5. Summarizes per node, without attempting a fix (a real fix would mean `marzban_core_restart`, which is destructive and affects every node, not just the unhealthy one).

## `traffic_report` [#traffic_report]

Summarizes bandwidth usage across the panel, nodes, and top users for a period.

**Arguments:** `start`, `end` (both optional, ISO datetimes) — omit either for no bound on that side.

What it does:

1. Calls `marzban_system_stats` for panel-wide totals and current speed.
2. Calls `marzban_nodes_list` for per-node uplink/downlink over the period.
3. Calls `marzban_users_list`, then `marzban_users_usage` for the heaviest-looking users, to break their traffic down by node.
4. Summarizes total bandwidth, the top 5 users by usage, and any node carrying disproportionate load.


# Response Format & Token Economy (/docs/mcp-server/response-format)



An AI agent's context is a shared, finite budget. Every tool here is designed around that constraint deliberately, not as an afterthought — this page covers how.

## `format`: text, table, or json [#format-text-table-or-json]

`MARZBAN_MCP_FORMAT` controls how a result is rendered as text for the model to read. The same `marzban_users_get` call looks like this in each mode:


  
    ```
    username: alice | status: active | usage: 2.1 GB / 10 GB | expire: 2026-09-01 (18d)
    ```

    The default — compact `key: value` lines, cheaper in tokens than a markdown table for a single row.
  

  
    ```
    | username | status | usage | expire |
    | --- | --- | --- | --- |
    | alice | active | 2.1 GB / 10 GB | 2026-09-01 (18d) |
    ```

    Reads better once there are several rows — a user list, a node list.
  

  
    ```json
    { "username": "alice", "status": "active", "usage": "2.1 GB / 10 GB", "expire": "2026-09-01 (18d)" }
    ```

    Same projected fields as the other two, just JSON-shaped instead of a separately-rendered `structuredContent` (see below).
  


## `verbosity`: compact vs full [#verbosity-compact-vs-full]

Every tool response is a *projection* of the underlying data, not the raw object. `MARZBAN_MCP_VERBOSITY=compact` (the default) keeps only what's relevant most of the time — for a user, that's `username`, `status`, usage, and expiry. `full` adds the rest: `proxies` (masked unless `MARZBAN_MCP_SHOW_LINKS` is set), `inbounds`, `note`, and similar detail fields that are rarely needed but sometimes essential.

Switch to `full` when you're actually inspecting configuration details, not for routine lookups — a `marzban_users_list` call over 50 users in `full` mode costs meaningfully more tokens than the same call in `compact`.

## `content` vs `structuredContent` [#content-vs-structuredcontent]

Every response actually carries two representations: `content` is the compact, format/verbosity-shaped text described above, meant for the model to read cheaply. `structuredContent` is always the complete, unprojected data, meant for a client to consume programmatically. Switching `verbosity`/`format` only changes `content` — nothing is ever actually hidden from a client capable of reading `structuredContent` directly.

## Pagination [#pagination]

List tools (`marzban_users_list`, and similarly-shaped tools elsewhere) default to a page size of 25 and cap at 100 per call, and always report the true `total` plus a note like *"showing 25 of 340 — increase offset to see more"*. The intent is to nudge toward `search` for a known user rather than paging through everyone — `marzban_users_get` is a better tool than iterating `marzban_users_list` when you already know the username.

## Truncation [#truncation]

`MARZBAN_MCP_MAX_CHARS` (default `8000`) caps every response's `content` at a character budget. If a response would exceed it, it's cut with an explicit marker — never silently. A model reading a silently-truncated response has no way to know it's looking at partial data; a marked one does.

## `marzban_config_get` — a special case [#marzban_config_get--a-special-case]

The Xray core config can run into tens of kilobytes — large enough that returning it raw by default would burn a disproportionate chunk of context on a single call. `marzban_config_get` instead defaults to a structural summary: inbound/outbound tags, ports, protocols, and a routing-rule count. Pass `section` (e.g. `"inbounds"`) for one key's raw JSON, or `section: "raw"` for the entire config — but only when you actually need it.

## Human-readable values [#human-readable-values]

Byte counts and dates render as `formatBytes`/human-relative strings (`"2.1 GB"`, `"18d"`) in `content`, so the model doesn't have to do that arithmetic itself. `structuredContent` keeps the raw numbers (bytes, Unix timestamps) for anything that needs to compute with them.

## Tool descriptions steer, too [#tool-descriptions-steer-too]

Several tool descriptions explicitly say when *not* to use them — `marzban_users_list`'s description says to prefer `search` over paging through everyone; `marzban_users_update`'s says to prefer the dedicated `marzban_users_activate`/`deactivate`/`hold`/`extend` tools for status changes and renewals. This is part of the same economy: steering the model toward the cheaper, more specific tool before it reaches for the expensive general one.

## `tools/list` caching [#toolslist-caching]

The tool list itself is fully determined by `MARZBAN_MCP_PROFILE`/`_TOOLS_ALLOW`/`_TOOLS_DENY`, fixed at process startup — it cannot change for the life of a connection. The server sets a cache hint on `tools/list` accordingly, so a client that respects it (per the MCP spec's caching support) doesn't need to re-fetch and re-pay for the same tool definitions on every turn.


# Security (/docs/mcp-server/security)



This page covers every safety mechanism the server has, and why each one is shaped the way it is. If you're deciding which profile to run in, or reviewing this before pointing it at a production panel, start here.

## Credentials [#credentials]

Credentials come from environment variables only, read once at startup, and are fixed for the process's entire lifetime. No tool's `inputSchema` accepts a field named `token`, `password`, `credentials`, `base_url`, `url`, or `host` — that's an architectural rule enforced across every tool, not a per-tool judgment call. A tool that accepted a URL or token as an argument would turn the server into an open proxy: text injected into ordinary data (a user's `note` field, say) could make it call out to an attacker-controlled host with attacker-controlled credentials.

### Why both username and password are required [#why-both-username-and-password-are-required]

Marzban's session tokens are short-lived by default, and the panel has no separate mechanism for long-lived API keys for external systems — a token is purely a per-login session artifact. `MARZBAN_USERNAME`/`MARZBAN_PASSWORD` let the server silently re-authenticate whenever a token expires, which it will over a server process's lifetime of hours or days. `MARZBAN_TOKEN` is accepted too, but purely as a startup optimization — if it's still fresh, the server skips the first login call. It is never a substitute for the password: without one, every tool would start failing the moment the initial token expired, with no way to recover short of a restart.

## Profiles [#profiles]

A profile is an access-control boundary, not a display hint. A tool outside the active profile is **never registered** — it doesn't appear in `tools/list` at all, so a model can't call what it can't see, and can't be talked into calling it either.

| Profile              | Exposes                                                                      |
| -------------------- | ---------------------------------------------------------------------------- |
| `readonly`           | Read-only tools only: lists, lookups, stats                                  |
| `standard` (default) | Read + write: full user CRUD, renewals, status changes, subscription lookups |
| `full`               | Everything in `standard`, plus every destructive tool                        |

Set with `MARZBAN_MCP_PROFILE` — see [Configuration](/docs/mcp-server/configuration#access-control).

## Confirming destructive actions [#confirming-destructive-actions]

A destructive tool's first call never runs anything. It describes exactly what would happen — with real context pulled from the panel, like the target user's current status or a diff against the current config — and returns a one-time token instead of a result. Only a second call, with that token attached, actually executes:

```text
> marzban_users_delete({ username: "alice" })

< This will permanently delete user "alice" (status: active, used 12.4 GB,
  expires 2026-09-01) and their subscription link. This cannot be undone.
  Do not call this tool again until the user has explicitly said yes. Once
  they have, repeat the exact same call with confirmToken: "v1.eyJwIjp7..."
  added. The token is only valid for this tool and these exact arguments,
  and expires in 5 minutes.

> marzban_users_delete({ username: "alice", confirmToken: "v1.eyJwIjp7..." })

< { username: "alice", deleted: true }
```


  A token being available is not the same as the user's own consent. The text above is meant to be shown to the human and acted on only after they actually say yes — not treated as a formality the model can clear on its own judgment. Nothing about possessing a valid token authenticates who's asking; on stdio, that boundary is the process itself, not this token.


### The confirm\_token mechanics [#the-confirm_token-mechanics]

The token is signed (HMAC), not just opaque — a model can't forge one, and re-using or repurposing one fails outright. On every verification, all of the following must hold, or the request is treated as unconfirmed and a fresh token is minted:

1. **Signature is valid** — rules out a token the model invented or altered.
2. **Not expired** — a 5-minute TTL, so a confirmation from an old, abandoned turn can't fire later.
3. **Bound to this exact tool** — a token minted for `marzban_core_restart` is rejected if replayed against `marzban_users_delete`.
4. **Bound to these exact arguments** — a token minted for `{ username: "alice" }` is rejected if replayed with `{ username: "bob" }`, or with `all: false` silently swapped to `all: true`.
5. **Single-use** — once verified successfully, the same token can't be verified again.

The signing key lives only in the server process's memory — a restart invalidates every outstanding token, by design.

### Confirmation frequency: off / auto / always [#confirmation-frequency-off--auto--always]

`MARZBAN_MCP_CONFIRM` controls how often the flow above happens:

| Mode             | Behavior                                                                                                  | Best for                                                                                             |
| ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `auto` (default) | Confirm once per tool name per connection; after that, calls to the *same tool* proceed without re-asking | Interactive use — balances safety with not re-confirming "delete user" fifty times in a cleanup loop |
| `always`         | Confirm every single call, with no memory of what was already confirmed                                   | Shared or interactive environments where even one accumulated trust is undesirable                   |
| `off`            | Skip confirmation entirely, from the very first call                                                      | Fully automated, unattended environments only — there is no safety net once this is set              |

Trust in `auto` mode is granted per tool *name*, not per call's arguments — confirming a delete for `alice` also trusts future deletes for `bob` without re-asking, for the rest of that connection. This is a deliberate, logged trade-off, not an oversight: it's no looser than what a host's own "always allow this tool" consent dialog already permits, just made explicit and auditable on this side. Every action taken under accumulated trust is still written to the audit log described below.

## Credential masking [#credential-masking]

`proxies`, `subscription_url`, and `links` are functionally access credentials — a `vless://` link embeds a UUID that grants connectivity — so they're masked by default in every tool response:

* `proxies` shows only the configured protocol names, not their settings.
* `subscription_url` shows only its origin (`https://panel.example.com/*** (hidden...)`).
* `links` shows only a count (`3 link(s) (hidden...)`).

Set `MARZBAN_MCP_SHOW_LINKS=true` to reveal them in full. This is a startup-time, all-or-nothing switch — there's no per-call override, so a model can't opt itself into revealing credentials mid-conversation.

## No token or URL passthrough [#no-token-or-url-passthrough]

Related to the credentials rule above but worth calling out on its own: nothing in this server accepts a token, panel URL, or hostname from a model and uses it to make a request. The one narrow, intentional exception is `marzban_subscription_info`, which takes a *subscription* token (the low-privilege, per-user token embedded in a subscription link — not an admin credential) and queries the same public, unauthenticated endpoint a user's own client apps already hit, against the one fixed `MARZBAN_BASE_URL` configured at startup. It never accepts an arbitrary URL.

## Config-write safety net [#config-write-safety-net]

`marzban_config_update` and `marzban_hosts_update` are the two tools that overwrite panel-wide configuration wholesale, so they carry extra guardrails beyond confirmation:

* **Structural validation** — `marzban_config_update` refuses a payload that doesn't have array `inbounds`/`outbounds` fields, before it's even offered for confirmation.
* **Automatic backup** — both tools fetch and return the pre-write state as `backup` in their response, so a mistaken write can be manually reverted.
* **`dryRun` preview** — `marzban_config_update` accepts `dryRun: true`, which returns the exact diff against the current config with no write, no core restart, and — since it provably changes nothing — no confirmation step either.

## Transport hygiene [#transport-hygiene]

All server logs go to stderr, never stdout — stdout is reserved exclusively for the JSON-RPC protocol, and a single stray log line there would corrupt the connection. Errors surfaced back to the model reuse `marzban-sdk`'s own secret redaction, so a failure that happens to embed a header or request body never leaks a raw credential into the model's context either.

## What's never exposed [#whats-never-exposed]

`adminToken` (issuing new admin JWTs) and admin account management (`createAdmin`/`modifyAdmin`/`removeAdmin`) are not registered as tools, in any profile. The server can manage what an admin manages — it can't mint new admin credentials for itself or anyone else.


# Tools (/docs/mcp-server/tools)



Every tool is namespaced `marzban__`. The **Scope** column determines which [profile](/docs/mcp-server/security#profiles) exposes it: `read`/`write` tools are in `standard` (the default), `destructive` tools need `full`.

## Users [#users]

| Tool                          | Scope       | Description                                                                                             |
| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- |
| `marzban_users_list`          | read        | List users, with search/status filters and pagination                                                   |
| `marzban_users_get`           | read        | Get one user by username, with a computed summary (data left, days left, usage %)                       |
| `marzban_users_create`        | write       | Create a user — optionally from a template, with any explicit field overriding the template's value     |
| `marzban_users_update`        | write       | Partially update a user; only the fields you provide change                                             |
| `marzban_users_activate`      | write       | Set status to `active`                                                                                  |
| `marzban_users_deactivate`    | write       | Set status to `disabled`, blocking access without deleting the account                                  |
| `marzban_users_hold`          | write       | Set status to `on_hold` — inactive until first connection, then the hold timer starts                   |
| `marzban_users_extend`        | write       | Renew: extend expiry and/or add data, relative to the user's current values (not an absolute overwrite) |
| `marzban_users_usage`         | read        | Traffic usage, with a per-node breakdown                                                                |
| `marzban_users_delete`        | destructive | Permanently delete a user and their subscription link                                                   |
| `marzban_users_reset_traffic` | destructive | Reset used traffic to zero, for one user or every user at once (`all: true`)                            |

`marzban_users_activate`/`deactivate`/`hold` exist as separate tools rather than one generic "set status" tool deliberately — they're the most common operations, and a model reaches for them correctly on the first try without needing to know the underlying status enum.

## Config [#config]

| Tool                    | Scope       | Description                                                                                                                   |
| ----------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `marzban_config_get`    | read        | Read the core (Xray) config — a structural summary by default; one section or the full JSON on request                        |
| `marzban_config_update` | destructive | Replace the entire core config (restarts the core). `dryRun: true` previews the diff with no write and no confirmation needed |
| `marzban_core_restart`  | destructive | Restart the Xray core without changing its config                                                                             |
| `marzban_hosts_get`     | read        | List proxy hosts, flagging any field that references an unknown `{VARIABLE}` template token                                   |
| `marzban_hosts_update`  | destructive | Replace the entire proxy host configuration                                                                                   |

See [Security](/docs/mcp-server/security#config-write-safety-net) for the backup/dry-run guarantees on the two destructive config tools.

## System & nodes [#system--nodes]

| Tool                      | Scope | Description                                                                           |
| ------------------------- | ----- | ------------------------------------------------------------------------------------- |
| `marzban_system_stats`    | read  | Panel-wide CPU/memory/user-count/bandwidth stats, plus core version and running state |
| `marzban_system_inbounds` | read  | List configured inbound proxies, grouped by protocol                                  |
| `marzban_nodes_list`      | read  | List nodes with status, Xray version, and bandwidth usage over a period               |

## Subscription [#subscription]

| Tool                                | Scope       | Description                                                                                 |
| ----------------------------------- | ----------- | ------------------------------------------------------------------------------------------- |
| `marzban_subscription_info`         | read        | Look up subscription status/usage by the token from a subscription URL — no username needed |
| `marzban_users_revoke_subscription` | destructive | Issue a new subscription link for a user, invalidating the old one                          |

## What's not here [#whats-not-here]

`adminToken` and admin account management (`createAdmin`/`modifyAdmin`/`removeAdmin`) are never registered, in any profile — see [Security](/docs/mcp-server/security#whats-never-exposed). User-template management (create/list/delete templates) isn't included either; `marzban_users_create`'s `templateId` argument references a template created through Marzban's own web panel.


# Admins (/docs/modules/admins)



`sdk.admin` provides endpoints for creating, updating, and querying Marzban administrators.

## Types [#types]

### `Admin` [#admin]

Returned by `getCurrentAdmin`, `createAdmin`, `modifyAdmin`, and `getAdmins`.

```ts
import type { Admin } from 'marzban-sdk'
```

| Field             | Type             | Description                                                  |
| ----------------- | ---------------- | ------------------------------------------------------------ |
| `username`        | `string`         | Admin username                                               |
| `is_sudo`         | `boolean`        | Superadmin flag — grants full access to all admins and users |
| `telegram_id`     | `number \| null` | Linked Telegram account ID                                   |
| `discord_webhook` | `string \| null` | Discord webhook URL for notifications                        |
| `users_usage`     | `number \| null` | Total traffic used by this admin's users (bytes)             |

### `AdminCreate` [#admincreate]

Payload for `createAdmin`.

```ts
import type { AdminCreate } from 'marzban-sdk'
```

| Field             | Type             | Required | Description                  |
| ----------------- | ---------------- | -------- | ---------------------------- |
| `username`        | `string`         | Yes      | Admin username               |
| `password`        | `string`         | Yes      | Initial password             |
| `is_sudo`         | `boolean`        | Yes      | Grant superadmin privileges  |
| `telegram_id`     | `number \| null` | No       | Linked Telegram account      |
| `discord_webhook` | `string \| null` | No       | Discord notification webhook |

### `AdminModify` [#adminmodify]

Payload for `modifyAdmin`. Note that `username` cannot be changed and `is_sudo` must always be sent (it is not optional, unlike the other fields).

```ts
import type { AdminModify } from 'marzban-sdk'
```

| Field             | Type             | Required | Description                                          |
| ----------------- | ---------------- | -------- | ---------------------------------------------------- |
| `is_sudo`         | `boolean`        | Yes      | Superadmin flag — must be provided on every update   |
| `password`        | `string \| null` | No       | New password; omit or `null` to keep the current one |
| `telegram_id`     | `number \| null` | No       | Linked Telegram account                              |
| `discord_webhook` | `string \| null` | No       | Discord notification webhook                         |

***

## Methods [#methods]

### `getCurrentAdmin()` [#getcurrentadmin]

Get the profile of the currently authenticated admin.

**Returns** `Admin`

```ts
const me = await sdk.admin.getCurrentAdmin()
console.log(me.username)
console.log(me.is_sudo)       // true for superadmin
console.log(me.telegram_id)
```

***

### `getAdmins(params?)` [#getadminsparams]

List all admins. Requires superadmin privileges.

**Returns** `Admin[]`

```ts
const admins = await sdk.admin.getAdmins({
  offset: 0,
  limit: 25,
  username: 'op',
})

for (const admin of admins) {
  console.log(admin.username, admin.is_sudo)
}
```

***

### `createAdmin(data)` [#createadmindata]

Create a new admin account from an `AdminCreate` payload. Requires superadmin.

**Returns** `Admin`

```ts
const admin = await sdk.admin.createAdmin({
  username: 'operator',
  password: 'strong-password',
  is_sudo: false,
  telegram_id: 123456789,
  discord_webhook: 'https://discord.com/api/webhooks/...',
})

console.log(admin.username)
```

***

### `modifyAdmin(username, data)` [#modifyadminusername-data]

Update an admin's profile or permissions from an `AdminModify` payload.

**Returns** `Admin`

```ts
await sdk.admin.modifyAdmin('operator', {
  password: 'new-password',
  is_sudo: true,
})
```

***

### `removeAdmin(username)` [#removeadminusername]

Delete an admin account. Requires superadmin.

**Returns** `any`

```ts
await sdk.admin.removeAdmin('operator')
```

***

### `getAdminUsage(username, params?)` [#getadminusageusername-params]

Get traffic usage attributed to a specific admin's users.

**Returns** `number` — total bytes used

```ts
const usage = await sdk.admin.getAdminUsage('operator', {
  start: '2024-01-01T00:00:00',
  end:   '2024-01-31T23:59:59',
})
```

***

### `resetAdminUsage(username)` [#resetadminusageusername]

Reset the traffic usage counter for an admin.

**Returns** `Admin`

```ts
await sdk.admin.resetAdminUsage('operator')
```

***

### `disableAllActiveUsers(username)` [#disableallactiveusersusername]

Disable all active users owned by a specific admin.

**Returns** `any`

```ts
await sdk.admin.disableAllActiveUsers('operator')
```

***

### `activateAllDisabledUsers(username)` [#activatealldisabledusersusername]

Re-activate all disabled users owned by a specific admin.

**Returns** `any`

```ts
await sdk.admin.activateAllDisabledUsers('operator')
```

## Common patterns [#common-patterns]

### Bootstrap a new operator admin [#bootstrap-a-new-operator-admin]

```ts
const admin = await sdk.admin.createAdmin({
  username: 'ops-team',
  password: process.env.OPS_PASSWORD!,
  is_sudo: false,
})

console.log('Admin created:', admin.username)
```

### Temporarily suspend all users of an admin [#temporarily-suspend-all-users-of-an-admin]

```ts
// Suspend
await sdk.admin.disableAllActiveUsers('operator')

// Later, restore
await sdk.admin.activateAllDisabledUsers('operator')
```


# Core (/docs/modules/core)



`sdk.core` provides control over the underlying Xray core — read stats, inspect or update the JSON config, and restart the engine.

## Types [#types]

### `CoreStats` [#corestats]

Returned by `getCoreStats`.

```ts
import type { CoreStats } from 'marzban-sdk'
```

| Field            | Type      | Description                                    |
| ---------------- | --------- | ---------------------------------------------- |
| `version`        | `string`  | Xray core version, e.g. `"1.8.4"`              |
| `started`        | `boolean` | `true` when the Xray process is running        |
| `logs_websocket` | `string`  | WebSocket endpoint path for core log streaming |

***

## Methods [#methods]

### `getCoreStats()` [#getcorestats]

Get runtime statistics for the Xray core process.

**Returns** `CoreStats`

```ts
const stats = await sdk.core.getCoreStats()

console.log(stats.version)         // "1.8.4"
console.log(stats.started)         // true
console.log(stats.logs_websocket)  // "/api/core/logs"
```

***

### `getCoreConfig()` [#getcoreconfig]

Retrieve the full current Xray JSON configuration — the raw Xray config tree, passed through untyped.

**Returns** `object`

```ts
const config = await sdk.core.getCoreConfig()
// Returns a raw JSON object — the full Xray config tree
console.log(JSON.stringify(config, null, 2))
```

***

### `modifyCoreConfig(data)` [#modifycoreconfigdata]

Replace the Xray configuration with a new `object` (the full Xray config tree). Triggers a core restart.

**Returns** `object` — the applied config

```ts
const config = await sdk.core.getCoreConfig()

await sdk.core.modifyCoreConfig({
  ...config,
  log: {
    ...config.log,
    loglevel: 'warning',
  },
})
```


  Modifying the core config restarts the Xray process and briefly disconnects all users. Test changes in a staging environment first.


***

### `restartCore()` [#restartcore]

Restart the Xray core without changing its configuration.

**Returns** `any`

```ts
await sdk.core.restartCore()
```

## Common patterns [#common-patterns]

### Check if the core is running [#check-if-the-core-is-running]

```ts
const stats = await sdk.core.getCoreStats()

if (!stats.started) {
  console.error('Xray core is not running — restarting...')
  await sdk.core.restartCore()
}
```

### Update log level only [#update-log-level-only]

```ts
const config = await sdk.core.getCoreConfig()

await sdk.core.modifyCoreConfig({
  ...config,
  log: { ...config.log, loglevel: 'debug' },
})

console.log('Core restarted with debug logging')
```


# Nodes (/docs/modules/nodes)



`sdk.node` provides endpoints for adding, querying, and managing Marzban worker nodes.

## Types [#types]

### `NodeResponse` [#noderesponse]

Returned by `getNode`, `addNode`, `modifyNode`, and `getNodes`.

```ts
import type { NodeResponse } from 'marzban-sdk'
```

| Field               | Type                                                   | Description                              |
| ------------------- | ------------------------------------------------------ | ---------------------------------------- |
| `id`                | `number`                                               | Unique node ID                           |
| `name`              | `string`                                               | Display name                             |
| `address`           | `string`                                               | IP address or hostname                   |
| `port`              | `number`                                               | Xray API port (default: `62050`)         |
| `api_port`          | `number`                                               | Node gRPC API port (default: `62051`)    |
| `usage_coefficient` | `number`                                               | Traffic multiplier (default: `1`)        |
| `status`            | `'connected' \| 'connecting' \| 'error' \| 'disabled'` | Current connection state                 |
| `xray_version`      | `string \| null`                                       | Xray version running on the node         |
| `message`           | `string \| null`                                       | Error message when `status` is `'error'` |

### `NodeCreate` [#nodecreate]

Payload for `addNode`.

```ts
import type { NodeCreate } from 'marzban-sdk'
```

| Field               | Type      | Required | Description                                             |
| ------------------- | --------- | -------- | ------------------------------------------------------- |
| `name`              | `string`  | Yes      | Display name                                            |
| `address`           | `string`  | Yes      | IP address or hostname                                  |
| `port`              | `number`  | No       | Default: `62050`                                        |
| `api_port`          | `number`  | No       | Default: `62051`                                        |
| `usage_coefficient` | `number`  | No       | Traffic multiplier, default: `1`                        |
| `add_as_new_host`   | `boolean` | No       | Auto-create a host entry for this node, default: `true` |

### `NodeModify` [#nodemodify]

Payload for `modifyNode` — all fields optional. Omitted fields are left unchanged. Unlike `NodeCreate`, there is no `add_as_new_host`, and `status` can be set directly.

```ts
import type { NodeModify } from 'marzban-sdk'
```

| Field               | Type                                                           | Required | Description                                 |
| ------------------- | -------------------------------------------------------------- | -------- | ------------------------------------------- |
| `name`              | `string \| null`                                               | No       | Display name                                |
| `address`           | `string \| null`                                               | No       | IP address or hostname                      |
| `port`              | `number \| null`                                               | No       | Xray API port                               |
| `api_port`          | `number \| null`                                               | No       | Node gRPC API port                          |
| `usage_coefficient` | `number \| null`                                               | No       | Traffic multiplier                          |
| `status`            | `'connected' \| 'connecting' \| 'error' \| 'disabled' \| null` | No       | Force a connection state, e.g. `'disabled'` |

### `NodeSettings` [#nodesettings]

Returned by `getNodeSettings` — the global TLS settings used for node-to-core communication.

```ts
import type { NodeSettings } from 'marzban-sdk'
```

| Field              | Type     | Description                                                  |
| ------------------ | -------- | ------------------------------------------------------------ |
| `certificate`      | `string` | TLS certificate that nodes use to authenticate with the core |
| `min_node_version` | `string` | Minimum supported node version. Default: `"v0.2.0"`          |

### `NodesUsageResponse` [#nodesusageresponse]

Returned by `getUsage` — traffic totals per node over the requested range.

```ts
import type { NodesUsageResponse } from 'marzban-sdk'
```

| Field    | Type                  | Description              |
| -------- | --------------------- | ------------------------ |
| `usages` | `NodeUsageResponse[]` | Per-node traffic entries |

Each `NodeUsageResponse` entry:

| Field       | Type             | Description                                          |
| ----------- | ---------------- | ---------------------------------------------------- |
| `node_id`   | `number \| null` | Node ID; `null` for aggregated or unassigned traffic |
| `node_name` | `string`         | Node display name                                    |
| `uplink`    | `number`         | Bytes sent (upload)                                  |
| `downlink`  | `number`         | Bytes received (download)                            |

***

## Methods [#methods]

### `getNodes()` [#getnodes]

List all configured nodes.

**Returns** `NodeResponse[]`

```ts
const nodes = await sdk.node.getNodes()

for (const node of nodes) {
  console.log(node.id, node.name, node.status)
}
```

***

### `getNode(node_id)` [#getnodenode_id]

Get a single node by ID.

**Returns** `NodeResponse`

```ts
const node = await sdk.node.getNode(1)
console.log(node.name)
console.log(node.status)   // 'connected' | 'connecting' | 'error' | 'disabled'
console.log(node.message)  // error details when status === 'error'
```

***

### `addNode(data)` [#addnodedata]

Register a new node from a `NodeCreate` payload.

**Returns** `NodeResponse`

```ts
const node = await sdk.node.addNode({
  name: 'Frankfurt-01',
  address: '203.0.113.10',
  port: 62050,
  api_port: 62051,
  add_as_new_host: true,
})

console.log(node.id)
console.log(node.status) // 'connecting' initially
```

***

### `modifyNode(node_id, data)` [#modifynodenode_id-data]

Update a node's configuration from a `NodeModify` payload.

**Returns** `NodeResponse`

```ts
await sdk.node.modifyNode(1, {
  name: 'Frankfurt-01 (updated)',
  address: '203.0.113.11',
  status: 'disabled',
})
```

***

### `removeNode(node_id)` [#removenodenode_id]

Remove a node from Marzban.

**Returns** `any`

```ts
await sdk.node.removeNode(1)
```

***

### `reconnectNode(node_id)` [#reconnectnodenode_id]

Force a node to reconnect.

**Returns** `any`

```ts
await sdk.node.reconnectNode(1)
```

***

### `getUsage(params?)` [#getusageparams]

Get traffic usage per node, optionally filtered by time range.

**Returns** `NodesUsageResponse`

```ts
const usage = await sdk.node.getUsage({
  start: '2024-01-01T00:00:00',
  end:   '2024-01-31T23:59:59',
})

for (const entry of usage.usages) {
  console.log(entry.node_name, entry.uplink, entry.downlink)
}
```

***

### `getNodeSettings()` [#getnodesettings]

Retrieve the global node TLS certificate used for node-to-core communication.

**Returns** `NodeSettings`

```ts
const settings = await sdk.node.getNodeSettings()
console.log(settings.certificate)
```

## Common patterns [#common-patterns]

### Monitor node health [#monitor-node-health]

```ts
const nodes = await sdk.node.getNodes()

const unhealthy = nodes.filter(n => n.status === 'error')
for (const node of unhealthy) {
  console.warn(`Node ${node.name} error: ${node.message}`)
  await sdk.node.reconnectNode(node.id)
}
```

### Add a node and wait for it to connect [#add-a-node-and-wait-for-it-to-connect]

```ts
const node = await sdk.node.addNode({
  name: 'Singapore-01',
  address: '10.0.0.5',
  add_as_new_host: true,
})

let current = node
while (current.status === 'connecting') {
  await new Promise(r => setTimeout(r, 2000))
  current = await sdk.node.getNode(node.id)
}

console.log('Node status:', current.status)
```


# Subscriptions (/docs/modules/subscriptions)



`sdk.subscription` exposes the public subscription endpoints — the same URLs that end-users open in their VPN client apps.


  These endpoints are **public** (no admin token required). They are identified by the user's subscription `token`, not by username.


## Types [#types]

### `SubscriptionUserResponse` [#subscriptionuserresponse]

Returned by `userSubscriptionInfo`.

```ts
import type { SubscriptionUserResponse } from 'marzban-sdk'
```

| Field                   | Type                                                            | Description                                    |
| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------- |
| `username`              | `string`                                                        | Username                                       |
| `status`                | `'active' \| 'disabled' \| 'limited' \| 'expired' \| 'on_hold'` | Current account state                          |
| `data_limit`            | `number \| null`                                                | Max data in bytes; `null` or `0` = unlimited   |
| `used_traffic`          | `number`                                                        | Bytes consumed in the current period           |
| `lifetime_used_traffic` | `number`                                                        | Total bytes consumed since account creation    |
| `expire`                | `number \| null`                                                | Unix timestamp expiry; `null` = no expiry      |
| `created_at`            | `string`                                                        | ISO 8601 creation timestamp                    |
| `links`                 | `string[]`                                                      | Proxy connection strings                       |
| `subscription_url`      | `string`                                                        | Full subscription URL                          |
| `sub_updated_at`        | `string \| null`                                                | Last subscription update timestamp             |
| `sub_last_user_agent`   | `string \| null`                                                | Client user-agent from last subscription fetch |
| `online_at`             | `string \| null`                                                | Last seen timestamp                            |

***

## Methods [#methods]

### `userSubscription(token, headers?)` [#usersubscriptiontoken-headers]

Fetch the user's subscription config in the format for their VPN client — the raw subscription payload (base64-encoded links or a client-specific config). `headers` is an optional `{ 'user-agent'?: string }` used to pick the client format server-side — there's no query-params argument.

**Returns** `any`

```ts
const config = await sdk.subscription.userSubscription('user-subscription-token')
// Returns subscription data — base64-encoded links or raw config depending on client
```

The `token` is the path segment from `subscription_url`. For `https://vpn.example.com/sub/abc123`, the token is `abc123`.

***

### `userSubscriptionInfo(token)` [#usersubscriptioninfotoken]

Get structured subscription metadata.

**Returns** `SubscriptionUserResponse`

```ts
const info = await sdk.subscription.userSubscriptionInfo('user-subscription-token')

console.log(info.username)
console.log(info.status)
console.log(info.used_traffic)     // bytes consumed
console.log(info.data_limit)       // byte limit, null = unlimited
console.log(info.expire)           // Unix timestamp or null
console.log(info.subscription_url)
console.log(info.links)            // proxy connection strings
```

***

### `userGetUsage(token, params?)` [#usergetusagetoken-params]

Get per-node usage stats for a subscription, optionally filtered by date range.

**Returns** `any`

```ts
const usage = await sdk.subscription.userGetUsage('user-subscription-token', {
  start: '2024-01-01T00:00:00',
  end:   '2024-01-31T23:59:59',
})
```

***

### `userSubscriptionWithClientType(clientType, token)` [#usersubscriptionwithclienttypeclienttype-token]

Fetch the subscription formatted for a specific VPN client. Note the argument order — `clientType` comes first, then `token`.

**Returns** `any`

```ts
const clashConfig = await sdk.subscription.userSubscriptionWithClientType(
  'clash',
  'user-subscription-token'
)
```

Accepted client types: `clash`, `clash-meta`, `sing-box`, `outline`, `v2ray`, `v2ray-json`.

## Common patterns [#common-patterns]

### Build a subscription info card [#build-a-subscription-info-card]

```ts
import { formatBytes, humanRemaining } from 'marzban-sdk'

const token = req.params.token

const info = await sdk.subscription.userSubscriptionInfo(token)

return {
  username: info.username,
  status: info.status,
  dataUsed: formatBytes(info.used_traffic),
  dataLimit: info.data_limit ? formatBytes(info.data_limit) : 'Unlimited',
  expiry: info.expire
    ? humanRemaining(info.expire * 1000)
    : 'Never',
}
```

### Proxy subscription requests to Marzban [#proxy-subscription-requests-to-marzban]

```ts
app.get('/sub/:token', async (req, res) => {
  const data = await sdk.subscription.userSubscription(req.params.token)
  res.send(data)
})
```


# System (/docs/modules/system)



`sdk.system` provides access to server-level information: resource usage, protocol inbounds, and proxy host configuration.

## Types [#types]

### `SystemStats` [#systemstats]

Returned by `getSystemStats`.

```ts
import type { SystemStats } from 'marzban-sdk'
```

| Field                      | Type     | Description                       |
| -------------------------- | -------- | --------------------------------- |
| `version`                  | `string` | Marzban version string            |
| `mem_total`                | `number` | Total RAM in bytes                |
| `mem_used`                 | `number` | Used RAM in bytes                 |
| `cpu_cores`                | `number` | Number of CPU cores               |
| `cpu_usage`                | `number` | CPU usage percentage (0–100)      |
| `total_user`               | `number` | Total number of users             |
| `users_active`             | `number` | Users with `active` status        |
| `users_disabled`           | `number` | Users with `disabled` status      |
| `users_limited`            | `number` | Users who hit their data limit    |
| `users_expired`            | `number` | Users whose subscription expired  |
| `users_on_hold`            | `number` | Users with `on_hold` status       |
| `online_users`             | `number` | Currently connected users         |
| `incoming_bandwidth`       | `number` | Total inbound traffic in bytes    |
| `outgoing_bandwidth`       | `number` | Total outbound traffic in bytes   |
| `incoming_bandwidth_speed` | `number` | Current inbound speed in bytes/s  |
| `outgoing_bandwidth_speed` | `number` | Current outbound speed in bytes/s |

***

### `ProxyInbound` [#proxyinbound]

A single inbound configured in the Xray core. `getInbounds` returns these grouped by protocol.

```ts
import type { ProxyInbound } from 'marzban-sdk'
```

| Field      | Type                                              | Description                                                   |
| ---------- | ------------------------------------------------- | ------------------------------------------------------------- |
| `tag`      | `string`                                          | Inbound tag — the identifier you pass in a user's `inbounds`. |
| `protocol` | `'vmess' \| 'vless' \| 'trojan' \| 'shadowsocks'` | Proxy protocol.                                               |
| `network`  | `string`                                          | Transport network, e.g. `tcp`, `ws`, `grpc`.                  |
| `tls`      | `string`                                          | TLS mode, e.g. `none`, `tls`, `reality`.                      |
| `port`     | `number \| string`                                | Listening port.                                               |

***

### `ProxyHost` [#proxyhost]

A proxy host entry attached to an inbound tag. `getHosts` returns these grouped by inbound tag.

```ts
import type { ProxyHost } from 'marzban-sdk'
```

| Field               | Type                                           | Description                                                                 |
| ------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
| `remark`            | `string`                                       | Display label for the host. Supports template variables like `{SERVER_IP}`. |
| `address`           | `string`                                       | Host address (domain or IP). Also supports template variables.              |
| `port`              | `number \| null`                               | Override port; `null` uses the inbound's own port.                          |
| `sni`               | `string \| null`                               | Server Name Indication.                                                     |
| `host`              | `string \| null`                               | HTTP `Host` header.                                                         |
| `path`              | `string \| null`                               | Request path for `ws` / `grpc` / `http` transports.                         |
| `security`          | `'inbound_default' \| 'none' \| 'tls'`         | TLS security mode. Default: `inbound_default`.                              |
| `alpn`              | `'' \| 'h2' \| 'h3' \| 'http/1.1' \| …`        | ALPN value. Default: `''`.                                                  |
| `fingerprint`       | `'' \| 'chrome' \| 'firefox' \| 'safari' \| …` | TLS fingerprint to mimic. Default: `''`.                                    |
| `allowinsecure`     | `boolean \| null`                              | Allow insecure (unverified) TLS certificates.                               |
| `is_disabled`       | `boolean \| null`                              | Disable this host entry.                                                    |
| `mux_enable`        | `boolean \| null`                              | Enable connection multiplexing.                                             |
| `fragment_setting`  | `string \| null`                               | TLS fragmentation settings.                                                 |
| `noise_setting`     | `string \| null`                               | Noise (obfuscation) settings.                                               |
| `random_user_agent` | `boolean \| null`                              | Send a randomised User-Agent per request.                                   |
| `use_sni_as_host`   | `boolean \| null`                              | Use the SNI value as the `Host` header.                                     |

***

## Methods [#methods]

### `getSystemStats()` [#getsystemstats]

Get overall system resource and traffic statistics.

**Returns** `SystemStats`

```ts
const stats = await sdk.system.getSystemStats()

console.log(stats.version)
console.log(stats.users_active, '/', stats.total_user)
console.log(`CPU: ${stats.cpu_usage.toFixed(1)}%`)
console.log(`RAM: ${stats.mem_used} / ${stats.mem_total} bytes`)
```

***

### `getInbounds()` [#getinbounds]

List all available inbounds configured in Xray core — inbound tags grouped by protocol.

**Returns** `Record`

```ts
const inbounds = await sdk.system.getInbounds()

// Returns Record
// e.g. { vless: [...], vmess: [...], trojan: [...] }
for (const [protocol, tags] of Object.entries(inbounds)) {
  console.log(protocol, tags)
}
```

Use this to discover which inbound tags to pass when creating users.

***

### `getHosts()` [#gethosts]

Get the current proxy host configuration for all inbound tags — proxy hosts grouped by inbound tag.

**Returns** `Record`

```ts
const hosts = await sdk.system.getHosts()

// Returns Record
for (const [tag, hostList] of Object.entries(hosts)) {
  for (const host of hostList) {
    console.log(tag, host.remark, host.address, host.port)
  }
}
```

***

### `modifyHosts(data)` [#modifyhostsdata]

Replace proxy hosts for the inbound tags present in the payload. Merges by
tag — a tag omitted from the payload is left untouched; pass an empty array
for a tag to clear its hosts.

**Returns** `Record` — the updated host map

```ts
await sdk.system.modifyHosts({
  'VLESS TCP REALITY': [
    {
      remark: 'Main Server',
      address: '203.0.113.10',
      port: 443,
      sni: 'example.com',
      host: 'example.com',
      security: 'tls',
      fingerprint: 'chrome',
    },
  ],
})
```

## Common patterns [#common-patterns]

### Dashboard stats widget [#dashboard-stats-widget]

```ts
import { formatBytes } from 'marzban-sdk'

const stats = await sdk.system.getSystemStats()

console.log(`
  Marzban ${stats.version}
  CPU:     ${stats.cpu_usage.toFixed(1)}% (${stats.cpu_cores} cores)
  Memory:  ${formatBytes(stats.mem_used)} / ${formatBytes(stats.mem_total)}
  Users:   ${stats.users_active} active / ${stats.total_user} total
  Online:  ${stats.online_users}
`)
```


# User Templates (/docs/modules/user-templates)



User templates let you define reusable configurations — proxy settings, data limits, expiry, and inbounds — that can be applied when creating new users.

## Types [#types]

### `UserTemplateResponse` [#usertemplateresponse]

Returned by `getUserTemplates`, `getUserTemplateEndpoint`, `addUserTemplate`, and `modifyUserTemplate`.

```ts
import type { UserTemplateResponse } from 'marzban-sdk'
```

| Field             | Type                       | Description                                                 |
| ----------------- | -------------------------- | ----------------------------------------------------------- |
| `id`              | `number`                   | Unique template ID                                          |
| `name`            | `string \| null`           | Display name of the template                                |
| `data_limit`      | `number \| null`           | Data limit in bytes; `0` or `null` = unlimited              |
| `expire_duration` | `number \| null`           | Subscription duration in seconds; `0` or `null` = no expiry |
| `inbounds`        | `Record` | Inbound tags per protocol                                   |
| `username_prefix` | `string \| null`           | Auto-applied prefix when generating usernames               |
| `username_suffix` | `string \| null`           | Auto-applied suffix when generating usernames               |

### `UserTemplateCreate` [#usertemplatecreate]

Payload for `addUserTemplate` — same shape as `UserTemplateResponse` without the server-assigned `id`. All fields optional.

```ts
import type { UserTemplateCreate } from 'marzban-sdk'
```

| Field             | Type                       | Required | Description                                                 |
| ----------------- | -------------------------- | -------- | ----------------------------------------------------------- |
| `name`            | `string \| null`           | No       | Display name of the template                                |
| `data_limit`      | `number \| null`           | No       | Data limit in bytes; `0` or `null` = unlimited              |
| `expire_duration` | `number \| null`           | No       | Subscription duration in seconds; `0` or `null` = no expiry |
| `inbounds`        | `Record` | No       | Inbound tags per protocol                                   |
| `username_prefix` | `string \| null`           | No       | Auto-applied prefix when generating usernames               |
| `username_suffix` | `string \| null`           | No       | Auto-applied suffix when generating usernames               |

### `UserTemplateModify` [#usertemplatemodify]

Payload for `modifyUserTemplate` — the same fields as `UserTemplateCreate`, all optional. Omitted fields are left unchanged.

```ts
import type { UserTemplateModify } from 'marzban-sdk'
```

***

## Methods [#methods]

### `getUserTemplates()` [#getusertemplates]

List all user templates.

**Returns** `UserTemplateResponse[]`

```ts
const templates = await sdk.userTemplate.getUserTemplates()

for (const tpl of templates) {
  console.log(tpl.id, tpl.name)
  console.log(tpl.data_limit)       // bytes
  console.log(tpl.expire_duration)  // seconds
}
```

***

### `getUserTemplateEndpoint(id)` [#getusertemplateendpointid]

Get a single template by ID.

**Returns** `UserTemplateResponse`

```ts
const template = await sdk.userTemplate.getUserTemplateEndpoint(1)
console.log(template.name)
console.log(template.inbounds)
```

***

### `addUserTemplate(data)` [#addusertemplatedata]

Create a new template from a `UserTemplateCreate` payload.

**Returns** `UserTemplateResponse`

```ts
const template = await sdk.userTemplate.addUserTemplate({
  name: '10GB / 30 days',
  data_limit: 10 * 1024 ** 3,   // 10 GB in bytes
  expire_duration: 30 * 86400,   // 30 days in seconds
  inbounds: { vless: ['VLESS TCP REALITY'] },
})

console.log('Created template ID:', template.id)
```

***

### `modifyUserTemplate(id, data)` [#modifyusertemplateid-data]

Update an existing template from a `UserTemplateModify` payload.

**Returns** `UserTemplateResponse`

```ts
await sdk.userTemplate.modifyUserTemplate(1, {
  name: '20GB / 30 days',
  data_limit: 20 * 1024 ** 3,
})
```

***

### `removeUserTemplate(id)` [#removeusertemplateid]

Delete a template.

**Returns** `any`

```ts
await sdk.userTemplate.removeUserTemplate(1)
```

## Common patterns [#common-patterns]

### Bootstrap standard templates on first run [#bootstrap-standard-templates-on-first-run]

```ts
import { parseSize } from 'marzban-sdk'

const templates = await sdk.userTemplate.getUserTemplates()

if (templates.length === 0) {
  await sdk.userTemplate.addUserTemplate({
    name: 'Basic – 5GB / 30d',
    data_limit: parseSize('5GB'),
    expire_duration: 30 * 86400,
    inbounds: { vless: ['VLESS TCP REALITY'] },
  })

  await sdk.userTemplate.addUserTemplate({
    name: 'Pro – Unlimited / 30d',
    data_limit: 0,
    expire_duration: 30 * 86400,
    inbounds: {
      vless: ['VLESS TCP REALITY'],
      vmess: ['VMess Websocket'],
    },
  })
}
```

### Create a user from a template [#create-a-user-from-a-template]

```ts
const template = await sdk.userTemplate.getUserTemplateEndpoint(1)

const user = await sdk.user.addUser({
  username: 'new_user',
  inbounds: template.inbounds ?? {},
  data_limit: template.data_limit ?? 0,
  expire: template.expire_duration
    ? Math.floor(Date.now() / 1000) + template.expire_duration
    : 0,
})
```


# Users (/docs/modules/users)



`sdk.user` provides access to all user-related endpoints. Every method returns a fully typed Promise.

## Types [#types]

### `UserResponse` [#userresponse]

Returned by `getUser`, `addUser`, `modifyUser`, and most other user methods.

```ts
import type { UserResponse } from 'marzban-sdk'
```

| Field                       | Type                                                            | Description                                      |
| --------------------------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `username`                  | `string`                                                        | Unique username                                  |
| `status`                    | `'active' \| 'disabled' \| 'limited' \| 'expired' \| 'on_hold'` | Current account state                            |
| `proxies`                   | `Record`                                 | Protocol-specific proxy settings                 |
| `inbounds`                  | `Record`                                      | Inbound tags per protocol                        |
| `data_limit`                | `number \| null`                                                | Max data in bytes; `null` or `0` = unlimited     |
| `data_limit_reset_strategy` | `'no_reset' \| 'day' \| 'week' \| 'month' \| 'year'`            | When the data counter resets                     |
| `used_traffic`              | `number`                                                        | Bytes consumed in the current period             |
| `lifetime_used_traffic`     | `number`                                                        | Total bytes consumed since account creation      |
| `expire`                    | `number \| null`                                                | Unix timestamp expiry; `null` or `0` = no expiry |
| `created_at`                | `string`                                                        | ISO 8601 creation timestamp                      |
| `links`                     | `string[]`                                                      | Ready-to-use proxy connection strings            |
| `subscription_url`          | `string`                                                        | Full subscription URL for the user's VPN client  |
| `note`                      | `string \| null`                                                | Optional admin note                              |
| `on_hold_expire_duration`   | `number \| null`                                                | Seconds the account stays in `on_hold`           |
| `on_hold_timeout`           | `string \| null`                                                | ISO timestamp when `on_hold` starts/ends         |
| `admin`                     | `Admin \| null`                                                 | Owner admin object                               |

### `UsersResponse` [#usersresponse]

Returned by `getUsers` — a single page of users plus the total count.

```ts
import type { UsersResponse } from 'marzban-sdk'
```

| Field   | Type             | Description                                          |
| ------- | ---------------- | ---------------------------------------------------- |
| `users` | `UserResponse[]` | The page of users for the current `offset` / `limit` |
| `total` | `number`         | Total number of users across all pages               |

### `UserCreate` [#usercreate]

Payload for `addUser`.

```ts
import type { UserCreate } from 'marzban-sdk'
```

| Field                       | Type                                                 | Required | Description                     |
| --------------------------- | ---------------------------------------------------- | -------- | ------------------------------- |
| `username`                  | `string`                                             | Yes      | 3–32 chars, `[a-z0-9_]`         |
| `proxies`                   | `Record`                      | No       | Protocol-specific config        |
| `inbounds`                  | `Record`                           | No       | Inbound tags per protocol       |
| `data_limit`                | `number \| null`                                     | No       | Max bytes; `0` = unlimited      |
| `data_limit_reset_strategy` | `'no_reset' \| 'day' \| 'week' \| 'month' \| 'year'` | No       | Default: `'no_reset'`           |
| `expire`                    | `number \| null`                                     | No       | Unix timestamp; `0` = no expiry |
| `status`                    | `'active' \| 'on_hold'`                              | No       | Default: `'active'`             |
| `note`                      | `string \| null`                                     | No       | Optional admin note             |
| `on_hold_expire_duration`   | `number \| null`                                     | No       | Seconds in `on_hold`            |
| `on_hold_timeout`           | `string \| null`                                     | No       | ISO timestamp for `on_hold`     |

### `UserModify` [#usermodify]

Payload for `modifyUser` — the same fields as `UserCreate` minus `username` (which can't change), all optional. Omitted fields are left unchanged.

```ts
import type { UserModify } from 'marzban-sdk'
```

It also accepts a few modify-only fields: `sub_updated_at`, `sub_last_user_agent`, `online_at`, `auto_delete_in_days`, and `next_plan`.

### `UserUsagesResponse` [#userusagesresponse]

Returned by `getUserUsage` — per-node traffic for a single user.

```ts
import type { UserUsagesResponse } from 'marzban-sdk'
```

| Field      | Type                  | Description                     |
| ---------- | --------------------- | ------------------------------- |
| `username` | `string`              | The user these usages belong to |
| `usages`   | `UserUsageResponse[]` | Per-node traffic entries        |

Each `UserUsageResponse` entry:

| Field          | Type             | Description                                          |
| -------------- | ---------------- | ---------------------------------------------------- |
| `node_id`      | `number \| null` | Node ID; `null` for aggregated or unassigned traffic |
| `node_name`    | `string`         | Node display name                                    |
| `used_traffic` | `number`         | Bytes consumed on this node                          |

### `UsersUsagesResponse` [#usersusagesresponse]

Returned by `getUsersUsage` — aggregated per-node traffic across all users.

```ts
import type { UsersUsagesResponse } from 'marzban-sdk'
```

| Field    | Type                  | Description                                                      |
| -------- | --------------------- | ---------------------------------------------------------------- |
| `usages` | `UserUsageResponse[]` | Per-node traffic entries (same shape as in `UserUsagesResponse`) |

***

## Methods [#methods]

### `addUser(data)` [#adduserdata]

Create a new user from a `UserCreate` payload.

**Returns** `UserResponse`

```ts
const user = await sdk.user.addUser({
  username: 'alice',
  proxies: { vless: {} },
  inbounds: { vless: ['VLESS TCP REALITY'] },
  data_limit: 10 * 1024 ** 3, // 10 GB
  expire: Math.floor(Date.now() / 1000) + 30 * 86400,
})

console.log(user.subscription_url)
console.log(user.links)
```

***

### `getUser(username)` [#getuserusername]

Fetch a single user by username.

**Returns** `UserResponse`

```ts
const user = await sdk.user.getUser('alice')
console.log(user.status)        // 'active' | 'disabled' | ...
console.log(user.used_traffic)  // bytes consumed
console.log(user.subscription_url)
```

***

### `getUsers(params?)` [#getusersparams]

List users with optional filtering and pagination.

**Returns** `UsersResponse`

```ts
const result = await sdk.user.getUsers({
  offset: 0,
  limit: 50,
  status: 'active',
  sort: 'created_at',
})

console.log(result.users)  // UserResponse[]
console.log(result.total)  // number
```

***

### `modifyUser(username, data)` [#modifyuserusername-data]

Update an existing user from a `UserModify` payload. Fields not provided are left unchanged.

**Returns** `UserResponse`

```ts
const updated = await sdk.user.modifyUser('alice', {
  data_limit: 20 * 1024 ** 3,
  expire: 0,
  status: 'disabled',
})
```


  `username` cannot be changed — it identifies the user, not an updatable field.


***

### `removeUser(username)` [#removeuserusername]

Permanently delete a user.

**Returns** `any`

```ts
await sdk.user.removeUser('alice')
```

***

### `resetUserDataUsage(username)` [#resetuserdatausageusername]

Reset a single user's `used_traffic` counter to zero.

**Returns** `UserResponse`

```ts
const user = await sdk.user.resetUserDataUsage('alice')
console.log(user.used_traffic) // 0
```

***

### `resetUsersDataUsage()` [#resetusersdatausage]

Reset data usage for **all** users at once.

**Returns** `any`

```ts
await sdk.user.resetUsersDataUsage()
```

***

### `revokeUserSubscription(username)` [#revokeusersubscriptionusername]

Revoke a user's subscription token, generating a new one.

**Returns** `UserResponse`

```ts
const user = await sdk.user.revokeUserSubscription('alice')
console.log(user.subscription_url) // new URL
```

***

### `getUserUsage(username, params?)` [#getuserusageusername-params]

Get per-node usage stats for one user, optionally filtered by time range.

**Returns** `UserUsagesResponse`

```ts
const usage = await sdk.user.getUserUsage('alice', {
  start: '2024-01-01T00:00:00',
  end:   '2024-01-31T23:59:59',
})
```

***

### `getUsersUsage(params?)` [#getusersusageparams]

Get aggregated usage stats across all users.

**Returns** `UsersUsagesResponse`

```ts
const usage = await sdk.user.getUsersUsage({ start: '2024-01-01T00:00:00' })
```

***

### `setOwner(username, params)` [#setownerusername-params]

Assign an admin as the owner of a user.

**Returns** `UserResponse`

```ts
await sdk.user.setOwner('alice', { admin_username: 'bob' })
```

***

### `getExpiredUsers(params?)` [#getexpiredusersparams]

List users whose accounts have expired, optionally filtered by expiry range.

**Returns** `string[]` — the expired usernames

```ts
const expired = await sdk.user.getExpiredUsers({
  expired_after:  '2024-01-01T00:00:00',
  expired_before: '2024-06-01T00:00:00',
})
```

***

### `deleteExpiredUsers(params?)` [#deleteexpiredusersparams]

Delete all expired users, optionally filtered by expiry range.

**Returns** `string[]` — the deleted usernames

```ts
await sdk.user.deleteExpiredUsers({ expired_before: '2024-01-01T00:00:00' })
```

***

### `activeNextPlan(username)` [#activenextplanusername]

Activate the next plan for a user (one-time use, resets after activation).

**Returns** `UserResponse`

```ts
await sdk.user.activeNextPlan('alice')
```

## Common patterns [#common-patterns]

### Create a user with a 30-day trial [#create-a-user-with-a-30-day-trial]

```ts
import { createMarzbanSDK, parseSize } from 'marzban-sdk'

const sdk = await createMarzbanSDK({ /* ... */ })

const user = await sdk.user.addUser({
  username: 'trial_user',
  proxies: { vless: {}, vmess: {} },
  inbounds: {
    vless: ['VLESS TCP REALITY'],
    vmess: ['VMess Websocket'],
  },
  data_limit: parseSize('5GB'),
  expire: Math.floor(Date.now() / 1000) + 30 * 86400,
})

console.log('Subscription:', user.subscription_url)
```

### Paginate through all users [#paginate-through-all-users]

```ts
const PAGE = 100
let offset = 0
let total = Infinity

while (offset < total) {
  const page = await sdk.user.getUsers({ offset, limit: PAGE })
  total = page.total
  for (const user of page.users) {
    console.log(user.username, user.status)
  }
  offset += PAGE
}
```


# WebSocket Logs (/docs/realtime/websocket-logs)



`sdk.logs` provides live log streaming over WebSocket. It supports both the **core Xray process** and **individual node** logs, with automatic token refresh on `403 Forbidden`.

## How it works [#how-it-works]

Each `connect*` call:

1. Ensures the SDK has a valid token (re-authenticates if needed).
2. Opens a WebSocket connection to the Marzban backend.
3. Returns a **close function** — call it to terminate the connection.

Active connections are tracked internally. `sdk.destroy()` closes all of them at once.

## Connect to core logs [#connect-to-core-logs]

```ts
const closeStream = await sdk.logs.connectByCore({
  interval: 1, // polling interval in seconds (default: 1)
  onMessage: (data) => {
    console.log('[Core log]', data)
  },
  onError: (event) => {
    console.error('WebSocket error:', event)
  },
})

// Later, close the stream
closeStream()
```

## Connect to node logs [#connect-to-node-logs]

```ts
const closeStream = await sdk.logs.connectByNode(nodeId, {
  interval: 1,
  onMessage: (data) => {
    console.log(`[Node ${nodeId} log]`, data)
  },
  onError: (event) => {
    console.error('Node log error:', event)
  },
})

closeStream()
```

## Multiple concurrent streams [#multiple-concurrent-streams]

```ts
const [closeCore, closeNode1, closeNode2] = await Promise.all([
  sdk.logs.connectByCore({ onMessage: d => process.stdout.write(d) }),
  sdk.logs.connectByNode(1, { onMessage: d => process.stdout.write(d) }),
  sdk.logs.connectByNode(2, { onMessage: d => process.stdout.write(d) }),
])

// Close all streams at once
await sdk.destroy()
```

## LogOptions [#logoptions]

| Option      | Type                          | Default  | Description                            |
| ----------- | ----------------------------- | -------- | -------------------------------------- |
| `onMessage` | `(data: any) => void`         | required | Called on each incoming log line       |
| `onError`   | `(event: ErrorEvent) => void` | —        | Called after max retries are exhausted |
| `interval`  | `number`                      | `1`      | Polling interval in seconds            |

## 403 / auth retry behaviour [#403--auth-retry-behaviour]

If the server returns `403 Forbidden` on a WebSocket connection, the SDK:

1. Closes the errored socket.
2. Re-authenticates using stored credentials.
3. Opens a new socket with the fresh token.
4. Repeats up to `config.retries` times (default: 3).
5. Calls `onError` if all retries are exhausted.

## Cleanup [#cleanup]

Always close streams when you no longer need them to avoid resource leaks:

```ts
// Close a single stream
const close = await sdk.logs.connectByCore({ onMessage: handler })
// ...
close()

// Close all active streams + release other SDK resources
await sdk.destroy()
```

## Custom agent & self-signed certificates [#custom-agent--self-signed-certificates]

`sdk.logs` reuses the `httpsAgent` from your SDK config (see [Self-signed certificates & custom CA](/docs/configuration/config-options#self-signed-certificates--custom-ca)) — nothing extra to configure:

```ts
import { readFileSync } from 'node:fs'
import https from 'node:https'
import { createMarzbanSDK } from 'marzban-sdk'

const sdk = await createMarzbanSDK({
  baseUrl: 'https://panel.example.com',
  username: 'admin',
  password: 'secret',
  httpsAgent: new https.Agent({ ca: readFileSync('ca.pem') }),
})

// Trusts the same CA as the REST calls above.
await sdk.logs.connectByCore({ onMessage: data => console.log(data) })
```

This is Node-only: neither the browser's native `WebSocket` nor Node's own global `WebSocket` (21+) can be given a custom agent, so a configured `httpsAgent` forces the connection through the `ws`-package-backed client instead — see "Browser support" below. In the browser the agent is ignored (with a warning), the same as the HTTP client.

## Browser support [#browser-support]

The WebSocket client auto-detects the runtime:

* **Modern environments** (browser, Deno, Bun, Node.js 21+) — uses the native `WebSocket` global.
* **Older Node.js (18–20)** — falls back to the `ws` package (included as an optional dependency).

No configuration required for that detection — it's fully transparent, **unless** you set `httpsAgent`, which forces the `ws`-backed client outside the browser (see above) regardless of what's natively available.

## React / frontend example [#react--frontend-example]

```tsx
import { useEffect } from 'react'
import type { MarzbanSDK } from 'marzban-sdk'

function NodeLogViewer({ sdk, nodeId }: { sdk: MarzbanSDK; nodeId: number }) {
  useEffect(() => {
    let close: (() => void) | undefined

    sdk.logs
      .connectByNode(nodeId, {
        onMessage: (data) => console.log(data),
        onError: (e) => console.error('Stream error', e),
      })
      .then(fn => { close = fn })

    return () => { close?.() }
  }, [sdk, nodeId])

  return 
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 | `total` is optional in the `{ items, total? }` shape because not every paginated endpoint reports one — `getUsers` does, but `getAdmins`/`getUserTemplates` return a bare array with no count of the whole collection. Omit `total` for those; `paginateAll` falls back to stopping once a page comes back shorter than `pageSize` (or empty) instead. ## Collecting everything into an array [#collecting-everything-into-an-array] `paginateAll` yields one item at a time — spread it or push into an array if you want them all at once: ```ts const admins = [] for await (const admin of paginateAll( (offset, limit) => sdk.admin.getAdmins({ offset, limit }).then(items => ({ items })), { pageSize: 50 } )) { admins.push(admin) } ``` ## Stopping early [#stopping-early] Since it's a generator, `break` out of the loop stops fetching further pages — the next page is never requested: ```ts for await (const user of paginateAll((offset, limit) => sdk.user.getUsers({ offset, limit }).then(r => ({ items: r.users, total: r.total })) )) { if (user.status === 'expired') { console.log('First expired user:', user.username) break } } ``` # Template Variables (/docs/utilities/template-variables) Marzban allows using template variables (like `{USERNAME}`, `{DATA_LEFT}`) in host-settings remark and address fields. The SDK provides typed utilities to work with these variables safely. ## Import [#import] ```ts import { Variable, VariableBraced, varAs, varExtract, varValidate, interpolateTemplateVariables, } from 'marzban-sdk' ``` ## `Variable` enum [#variable-enum] All supported variable names: | Constant | Token | Description | | ----------------------------- | ---------------------- | --------------------------------- | | `Variable.SERVER_IP` | `{SERVER_IP}` | Master server IPv4 address | | `Variable.USERNAME` | `{USERNAME}` | User's username | | `Variable.DATA_USAGE` | `{DATA_USAGE}` | Data consumed by the user | | `Variable.DATA_LEFT` | `{DATA_LEFT}` | Remaining data | | `Variable.DATA_LIMIT` | `{DATA_LIMIT}` | Total data limit | | `Variable.DAYS_LEFT` | `{DAYS_LEFT}` | Remaining days (integer) | | `Variable.TIME_LEFT` | `{TIME_LEFT}` | Human-friendly remaining time | | `Variable.EXPIRE_DATE` | `{EXPIRE_DATE}` | Expiry in Gregorian calendar | | `Variable.JALALI_EXPIRE_DATE` | `{JALALI_EXPIRE_DATE}` | Expiry in Jalali calendar | | `Variable.STATUS_EMOJI` | `{STATUS_EMOJI}` | Status as emoji (✅ ⌛️ 🪫 ❌ 🔌) | | `Variable.PROTOCOL` | `{PROTOCOL}` | Protocol name (vless, vmess, …) | | `Variable.TRANSPORT` | `{TRANSPORT}` | Transport type (tcp, ws, grpc, …) | *** ## `varAs(variable)` [#varasvariable] Convert a `Variable` enum member to its braced string token. The return type is a precise literal, e.g. `"{USERNAME}"` — not plain `string`. ```ts varAs(Variable.USERNAME) // "{USERNAME}" varAs(Variable.DATA_LEFT) // "{DATA_LEFT}" ``` *** ## `VariableBraced` [#variablebraced] A frozen map of all pre-braced tokens, keyed by variable name. Useful for building template strings with autocomplete: ```ts const remark = `User: ${VariableBraced.USERNAME} | Left: ${VariableBraced.DATA_LEFT}` // "User: {USERNAME} | Left: {DATA_LEFT}" ``` *** ## `varExtract(template)` [#varextracttemplate] Extract all variable names from a template string (without braces, preserving order and duplicates): ```ts varExtract('Hello {USERNAME}, you have {DATA_LEFT} left') // ["USERNAME", "DATA_LEFT"] varExtract('{USERNAME} - {USERNAME}') // ["USERNAME", "USERNAME"] (duplicates preserved) varExtract('') // [] varExtract('no vars') // [] ``` *** ## `varValidate(template)` [#varvalidatetemplate] Validate that a template contains only known Marzban variables: ```ts const result = varValidate('Hi {USERNAME}, ip: {SERVER_IP}') // { isValid: true, unknownVariables: [] } const bad = varValidate('Hi {USERNAME} and {UNKNOWN_VAR}') // { isValid: false, unknownVariables: ["UNKNOWN_VAR"] } ``` *** ## `interpolateTemplateVariables(template, values)` [#interpolatetemplatevariablestemplate-values] Substitute variable tokens with actual values. Unknown or unmapped tokens are left intact: ```ts import { interpolateTemplateVariables, Variable } from 'marzban-sdk' const remark = 'Hello {USERNAME}, {DATA_LEFT} remaining until {EXPIRE_DATE}' const filled = interpolateTemplateVariables(remark, { [Variable.USERNAME]: 'alice', [Variable.DATA_LEFT]: '4.50 GB', [Variable.EXPIRE_DATE]: '2024-02-15', }) // "Hello alice, 4.50 GB remaining until 2024-02-15" ``` Tokens not present in `values` are left as-is: ```ts interpolateTemplateVariables('{USERNAME} — {STATUS_EMOJI}', { [Variable.USERNAME]: 'alice', // STATUS_EMOJI not provided }) // "alice — {STATUS_EMOJI}" ``` ## Common usage patterns [#common-usage-patterns] ### Build a host remark template [#build-a-host-remark-template] ```ts import { VariableBraced } from 'marzban-sdk' const remark = `${VariableBraced.USERNAME} | ${VariableBraced.DATA_LEFT} | ${VariableBraced.TIME_LEFT}` // "{USERNAME} | {DATA_LEFT} | {TIME_LEFT}" // Pass to Marzban host settings await sdk.system.modifyHosts({ 'VLESS TCP REALITY': [{ remark, address: '203.0.113.10', port: 443, }], }) ``` ### Validate a user-submitted template [#validate-a-user-submitted-template] ```ts function validateHostRemark(remark: string) { const { isValid, unknownVariables } = varValidate(remark) if (!isValid) { throw new Error(`Unknown template variables: ${unknownVariables.join(', ')}`) } } validateHostRemark('{USERNAME} - {DATA_LIMIT}') // OK validateHostRemark('{USERNAME} - {TYPO}') // throws ``` # Event Types (/docs/webhooks/event-types) Marzban emits a webhook for each of **12 user-lifecycle events**. Every payload shares a common base and adds a few event-specific fields. ## Payload shape [#payload-shape] Every webhook is a JSON object with these base fields: | Field | Type | Description | | ------------- | --------------- | ------------------------------------------------ | | `action` | `WebhookAction` | Which event fired — one of the 12 names below | | `username` | `string` | Username of the affected user | | `enqueued_at` | `number` | Unix timestamp (float) when the event was queued | | `send_at` | `number` | Unix timestamp (float) when the event was sent | | `tries` | `number` | Number of delivery attempts | Most events also carry the full `UserResponse` under `user`, and admin-initiated ones add the acting `Admin` under `by`. A complete `user_created` payload: ```json { "action": "user_created", "username": "alice", "enqueued_at": 1705312800.0, "send_at": 1705312800.1, "tries": 0, "user": { "username": "alice", "status": "active", "data_limit": 10737418240, "expire": 1707904800, "subscription_url": "https://vpn.example.com/sub/abc123" }, "by": { "username": "admin", "is_sudo": true } } ``` ## Events reference [#events-reference] All 12 actions, the fields each adds on top of the base, and when they fire: | Action | Extra fields | Fires when | | ----------------------- | ----------------------- | --------------------------------------------------------------- | | `user_created` | `user`, `by` | A new user was created | | `user_updated` | `user`, `by` | A user's profile was modified | | `user_deleted` | `by` | A user was deleted (no `user` — it's gone) | | `user_enabled` | `user`, `by?` | A user was re-enabled (`by` is null for an automated re-enable) | | `user_disabled` | `user`, `by`, `reason?` | A user was disabled | | `user_limited` | `user` | A user hit their data limit | | `user_expired` | `user` | A user's subscription expired | | `data_usage_reset` | `user`, `by` | A user's data usage was reset manually | | `data_reset_by_next` | `user` | Data was reset by the "next plan" mechanism | | `subscription_revoked` | `user`, `by` | A subscription token was revoked and reissued | | `reached_usage_percent` | `user`, `used_percent` | A user crossed a configured usage-percent threshold | | `reached_days_left` | `user`, `days_left` | A user crossed a configured days-left threshold | ## Payload examples [#payload-examples] `user_created` is shown above. Two events add fields worth seeing in full: ### `user_disabled` — adds `reason` [#user_disabled--adds-reason] ```json { "action": "user_disabled", "username": "alice", "enqueued_at": 1705312900.0, "send_at": 1705312900.1, "tries": 0, "user": { "username": "alice", "status": "disabled" }, "by": { "username": "admin", "is_sudo": true }, "reason": "Suspicious activity" } ``` ### `reached_usage_percent` — adds a numeric threshold field [#reached_usage_percent--adds-a-numeric-threshold-field] ```json { "action": "reached_usage_percent", "username": "alice", "enqueued_at": 1705313000.0, "send_at": 1705313000.0, "tries": 0, "user": { "username": "alice", "used_traffic": 9663676416 }, "used_percent": 90.0 } ``` `reached_days_left` has the same shape, with `days_left` (a number) in place of `used_percent`. ## Working with events in TypeScript [#working-with-events-in-typescript] The SDK ships three things for handling events safely: the `WebhookType` union for narrowing, the `ACTIONS` constant for action names, and the `WebhookSchema` validator for untrusted input. ### Narrowing the union [#narrowing-the-union] Every payload is a member of the `WebhookType` discriminated union, keyed by `action`. Switch on it and TypeScript unlocks exactly the event-specific fields that event carries: ```ts import type { WebhookType } from 'marzban-sdk' function handleEvent(payload: WebhookType) { switch (payload.action) { case 'user_created': // payload.user and payload.by are available here console.log('New user:', payload.user.username) break case 'reached_usage_percent': // payload.used_percent is available here console.log(`${payload.username} used ${payload.used_percent}%`) break case 'user_deleted': // payload.user is NOT available — the user is gone console.log('Deleted:', payload.username) break } } ``` ### Action constants [#action-constants] Rather than hard-code action strings, import the `ACTIONS` constant (every name) and the `WebhookAction` type (their union): ```ts import { ACTIONS, type WebhookAction } from 'marzban-sdk' // Subscribe without magic strings — ACTIONS is fully autocompleted sdk.webhook.on(ACTIONS.user_created, payload => { console.log('New user:', payload.user.username) }) // Iterate every action, e.g. to attach one handler to all of them for (const action of Object.values(ACTIONS)) { sdk.webhook.on(action, payload => log(payload.action)) } // Annotate your own helpers function describe(action: WebhookAction): string { return action.replace(/_/g, ' ') } ``` `ACTIONS.user_created === 'user_created'`, so the constants are fully interchangeable with the raw strings — they only add autocomplete and a single source of truth. ### Schema validation [#schema-validation] Need to validate an untrusted payload yourself? The matching Zod schemas are exported too. `sdk.webhook.parseWebhook` already runs `WebhookSchema` for you (and verifies the signature), so reach for these only when you parse events outside the SDK: ```ts import { WebhookSchema, WebhookActionSchema } from 'marzban-sdk' // Validate a full payload — returns a typed WebhookType on success const result = WebhookSchema.safeParse(rawJson) if (result.success) { handleEvent(result.data) } else { console.error('Invalid webhook:', result.error.issues) } // Validate just an action name (e.g. from a query param or filter) WebhookActionSchema.parse('user_created') // ok WebhookActionSchema.parse('nope') // throws ZodError ``` For batches, `WebhookArrayType` is the inferred type of an array of events — exactly what `parseWebhook` returns. # Express (/docs/webhooks/express) Express buffers request bodies as parsed JSON by default, but signature verification requires the **raw bytes**. You need to configure `express.raw()` for the webhook route. ## Setup [#setup] ```bash npm install express marzban-sdk ``` ## Full example [#full-example] ```ts import express from 'express' import { createMarzbanSDK, isWebhookSignatureError } from 'marzban-sdk' const app = express() const sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) // Subscribe to events sdk.webhook.on('user_created', payload => { console.log('New user:', payload.user.username) }) sdk.webhook.on('user_limited', payload => { console.log('User reached limit:', payload.username) }) sdk.webhook.on('*', payload => { // Wildcard — fires for every event console.log('Event:', payload.action, payload.username) }) // Important: use express.raw() to preserve the raw body for signature verification app.post( '/webhook', express.raw({ type: 'application/json' }), async (req, res) => { try { await sdk.webhook.handleWebhook( req.body, // Buffer (raw bytes) req.headers['x-signature'] as string ) res.sendStatus(200) } catch (err) { if (isWebhookSignatureError(err)) { res.status(401).json({ error: 'Invalid signature' }) } else { console.error('Webhook error:', err) res.status(400).json({ error: 'Bad request' }) } } } ) app.listen(3000, () => console.log('Webhook server listening on :3000')) ``` ## Without signature verification [#without-signature-verification] If you're in development or trust your network, skip the secret: ```ts const sdk = await createMarzbanSDK({ baseUrl: 'http://localhost:7777', username: 'admin', password: 'secret', // no webhook.secret }) app.post('/webhook', express.json(), async (req, res) => { await sdk.webhook.handleWebhook(req.body) // pre-parsed JSON, no signature check res.sendStatus(200) }) ``` ## Using parseWebhook for custom logic [#using-parsewebhook-for-custom-logic] ```ts app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const payloads = await sdk.webhook.parseWebhook( req.body, req.headers['x-signature'] as string ) for (const payload of payloads) { // Handle each event your own way await db.webhookEvents.insert({ action: payload.action, data: payload }) } res.sendStatus(200) }) ``` ## Batch events [#batch-events] Marzban can send multiple events in a single request. The `batch` event fires once with all payloads: ```ts sdk.webhook.on('batch', payloads => { console.log(`Received ${payloads.length} events in one request`) for (const payload of payloads) { console.log(payload.action, payload.username) } }) ``` # Fastify (/docs/webhooks/fastify) Fastify does not parse the body by default unless you add a content-type parser. For webhook signature verification you need access to the raw bytes — register a custom parser that preserves them. ## Setup [#setup] ```bash npm install fastify marzban-sdk ``` ## Full example [#full-example] ```ts import Fastify from 'fastify' import { createMarzbanSDK, isWebhookSignatureError } from 'marzban-sdk' const app = Fastify() const sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) // Subscribe to events sdk.webhook.on('user_created', payload => { console.log('User created:', payload.user.username) }) // Register a raw body parser for the webhook route app.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (_req, body, done) => done(null, body) ) app.post('/webhook', async (req, reply) => { try { await sdk.webhook.handleWebhook( req.body as Buffer, // raw Buffer (req.headers['x-signature'] as string) ) reply.send({ ok: true }) } catch (err) { if (isWebhookSignatureError(err)) { reply.code(401).send({ error: 'Invalid signature' }) } else { reply.code(400).send({ error: 'Bad webhook payload' }) } } }) await app.listen({ port: 3000 }) console.log('Fastify webhook server listening on :3000') ``` ## Scoped content-type parser [#scoped-content-type-parser] If you only want the raw-buffer parser on the webhook route (to avoid affecting other routes), use a scoped plugin: ```ts import Fastify from 'fastify' const app = Fastify() // Regular JSON for all routes app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => done(null, JSON.parse(body as string)) ) // Scoped raw parser for /webhook only app.register(async function webhookPlugin(fastify) { fastify.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (_req, body, done) => done(null, body) ) fastify.post('/webhook', async (req, reply) => { await sdk.webhook.handleWebhook(req.body as Buffer, req.headers['x-signature'] as string) reply.send({ ok: true }) }) }) ``` # Hono / Edge (/docs/webhooks/hono-edge) Hono is built on the Web Fetch API and runs natively on Cloudflare Workers, Vercel Edge Functions, Deno Deploy, and Bun. Reading the raw body is a first-class operation — no middleware required. ## Setup [#setup] ```bash npm install hono marzban-sdk ``` ## Cloudflare Workers / Hono [#cloudflare-workers--hono] ```ts import { Hono } from 'hono' import { createMarzbanSDK, isWebhookSignatureError, isWebhookValidationError } from 'marzban-sdk' const app = new Hono() // Initialize SDK once at module level (cold-start) const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) sdk.webhook.on('user_created', payload => { console.log('User created:', payload.user.username) }) sdk.webhook.on('user_limited', payload => { console.log('User limited:', payload.username) }) app.post('/webhook', async c => { const rawBody = await c.req.arrayBuffer() const signature = c.req.header('x-signature') try { await sdk.webhook.handleWebhook(rawBody, signature) return c.json({ ok: true }) } catch (err) { if (isWebhookSignatureError(err)) { return c.json({ error: 'Invalid signature' }, 401) } if (isWebhookValidationError(err)) { return c.json({ error: 'Invalid payload' }, 400) } throw err } }) export default app ``` ## Vercel Edge Function [#vercel-edge-function] ```ts // api/webhook/route.ts (Edge Runtime) import { createMarzbanSDK, isWebhookSignatureError } from 'marzban-sdk' export const runtime = 'edge' const sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET }, }) export async function POST(req: Request) { const rawBody = await req.arrayBuffer() const signature = req.headers.get('x-signature') ?? undefined try { await sdk.webhook.handleWebhook(rawBody, signature) return new Response(JSON.stringify({ ok: true }), { status: 200 }) } catch (err) { if (isWebhookSignatureError(err)) { return new Response('Unauthorized', { status: 401 }) } return new Response('Bad Request', { status: 400 }) } } ``` ## Deno / Bun [#deno--bun] ```ts import { createMarzbanSDK } from 'marzban-sdk' const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', webhook: { secret: Deno.env.get('MARZBAN_WEBHOOK_SECRET') }, }) sdk.webhook.on('user_expired', payload => { console.log('Expired:', payload.username) }) Deno.serve(async (req) => { if (req.method !== 'POST' || new URL(req.url).pathname !== '/webhook') { return new Response('Not Found', { status: 404 }) } const rawBody = await req.arrayBuffer() const signature = req.headers.get('x-signature') ?? undefined await sdk.webhook.handleWebhook(rawBody, signature) return new Response('OK') }) ``` All edge runtimes support the Web Crypto API (`crypto.subtle`), so HMAC-SHA256 signature verification works out of the box without any polyfills. # NestJS (/docs/webhooks/nestjs) NestJS parses JSON bodies globally by default. To verify webhook signatures you need to enable `rawBody: true` in the app factory and use `RawBodyRequest` in your controller. ## Setup [#setup] ```bash npm install @nestjs/core @nestjs/common @nestjs/platform-express marzban-sdk ``` ## Enable raw body [#enable-raw-body] ```ts // main.ts import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, // required for webhook signature verification }) await app.listen(3000) } bootstrap() ``` ## SDK module (singleton) [#sdk-module-singleton] ```ts // marzban/marzban.module.ts import { Module, Global } from '@nestjs/common' import { MarzbanService } from './marzban.service' @Global() @Module({ providers: [MarzbanService], exports: [MarzbanService], }) export class MarzbanModule {} ``` ```ts // marzban/marzban.service.ts import { Injectable, OnModuleInit } from '@nestjs/common' import { createMarzbanSDK, MarzbanSDK } from 'marzban-sdk' @Injectable() export class MarzbanService implements OnModuleInit { sdk!: MarzbanSDK async onModuleInit() { this.sdk = await createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) this.sdk.webhook.on('user_created', payload => { console.log('User created:', payload.user.username) }) this.sdk.webhook.on('user_limited', payload => { console.log('User limited:', payload.username) }) } } ``` ## Webhook controller [#webhook-controller] ```ts // webhook/webhook.controller.ts import { Controller, Post, Req, Res, HttpCode, Headers, } from '@nestjs/common' import type { RawBodyRequest } from '@nestjs/common' import type { Request, Response } from 'express' import { MarzbanService } from '../marzban/marzban.service' import { isWebhookSignatureError, isWebhookValidationError } from 'marzban-sdk' @Controller('webhook') export class WebhookController { constructor(private readonly marzban: MarzbanService) {} @Post() @HttpCode(200) async handleWebhook( @Req() req: RawBodyRequest, @Res() res: Response, @Headers('x-signature') signature: string, ) { try { await this.marzban.sdk.webhook.handleWebhook(req.rawBody!, signature) res.json({ ok: true }) } catch (err) { if (isWebhookSignatureError(err)) { res.status(401).json({ error: 'Invalid signature' }) } else if (isWebhookValidationError(err)) { res.status(400).json({ error: 'Invalid payload' }) } else { throw err } } } } ``` ## Wire everything up [#wire-everything-up] ```ts // app.module.ts import { Module } from '@nestjs/common' import { MarzbanModule } from './marzban/marzban.module' import { WebhookController } from './webhook/webhook.controller' @Module({ imports: [MarzbanModule], controllers: [WebhookController], }) export class AppModule {} ``` `req.rawBody` is a `Buffer` when `rawBody: true` is set in `NestFactory.create`. It is `undefined` if you forget to enable that option. # Next.js (/docs/webhooks/nextjs) Next.js App Router Route Handlers expose the raw `Request` object from the Web Fetch API, which makes reading the raw body straightforward — no extra middleware needed. ## Setup [#setup] ```bash npm install marzban-sdk ``` ## Singleton SDK instance [#singleton-sdk-instance] Create the SDK once outside the handler so it's reused across invocations: ```ts // lib/marzban.ts import { createMarzbanSDK } from 'marzban-sdk' let sdkPromise: ReturnType | null = null export function getSDK() { if (!sdkPromise) { sdkPromise = createMarzbanSDK({ baseUrl: process.env.MARZBAN_URL!, username: process.env.MARZBAN_USER!, password: process.env.MARZBAN_PASS!, webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) } return sdkPromise } ``` ## Route handler [#route-handler] ```ts // app/api/webhook/route.ts import { NextRequest, NextResponse } from 'next/server' import { isWebhookSignatureError, isWebhookValidationError } from 'marzban-sdk' import { getSDK } from '@/lib/marzban' export async function POST(req: NextRequest) { const sdk = await getSDK() // Subscribe to events (idempotent — safe to call on every invocation) sdk.webhook.on('user_created', payload => { console.log('User created:', payload.user.username) }) try { // Read the raw body as ArrayBuffer for signature verification const rawBody = await req.arrayBuffer() const signature = req.headers.get('x-signature') ?? undefined await sdk.webhook.handleWebhook(rawBody, signature) return NextResponse.json({ ok: true }) } catch (err) { if (isWebhookSignatureError(err)) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) } if (isWebhookValidationError(err)) { return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }) } throw err } } ``` Next.js Route Handlers are stateless by design — each invocation may run in a fresh context. For production workloads, move event handler registration to a shared module (as shown with `getSDK()` above) and prefer persisting events to a database inside the handler rather than relying on in-memory listeners. ## Pages Router (API Routes) [#pages-router-api-routes] If you're using the Pages Router, disable the default body parser and read the raw body manually: ```ts // pages/api/webhook.ts import type { NextApiRequest, NextApiResponse } from 'next' import getRawBody from 'raw-body' import { getSDK, isWebhookSignatureError } from 'marzban-sdk' export const config = { api: { bodyParser: false } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') return res.status(405).end() const sdk = await getSDK() const rawBody = await getRawBody(req) try { await sdk.webhook.handleWebhook(rawBody, req.headers['x-signature'] as string) res.status(200).json({ ok: true }) } catch (err) { if (isWebhookSignatureError(err)) { res.status(401).json({ error: 'Invalid signature' }) } else { res.status(400).json({ error: 'Bad request' }) } } } ``` # Signature Verification (/docs/webhooks/signature-verification) When Marzban is configured with a webhook secret, it signs each request body with **HMAC-SHA256** and sends the signature in the `x-signature` header. MarzbanSDK verifies this signature automatically when you provide the same secret. Signature verification uses the **Web Crypto API** (`crypto.subtle`). It is available in Node.js 18+, Bun, Deno, and all modern browsers — but the SDK **blocks** verification from the browser main thread to prevent secret exposure. Always handle webhooks in a server-side runtime. ## Configure the secret [#configure-the-secret] Pass the secret to the SDK at init time: ```ts const sdk = await createMarzbanSDK({ baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', webhook: { secret: process.env.MARZBAN_WEBHOOK_SECRET, }, }) ``` ## How verification works [#how-verification-works] When a `secret` is configured, `handleWebhook` and `parseWebhook` enforce two checks: 1. A **signature must be present** — missing `x-signature` throws `WebhookSignatureError`. 2. **Raw bytes are required** — you must pass `string`, `Uint8Array`, or `ArrayBuffer` (not a pre-parsed object), so the HMAC can be computed over the exact original bytes. ```ts // ✅ Correct — raw body passed await sdk.webhook.parseWebhook(req.rawBody, req.headers['x-signature']) // ❌ Wrong — pre-parsed JSON cannot be verified await sdk.webhook.parseWebhook(req.body, req.headers['x-signature']) ``` ## Algorithm [#algorithm] ``` HMAC-SHA256(key=secret, data=rawRequestBody) ``` The resulting 32-byte digest is hex-encoded and compared against the value in `x-signature` using a constant-time comparison. ## Standalone verification utility [#standalone-verification-utility] You can call `verifyWebhookSignature` directly if you need the raw boolean result: ```ts import { verifyWebhookSignature } from 'marzban-sdk' const isValid = await verifyWebhookSignature( signature, // hex string from x-signature header secret, // your webhook secret rawBodyBytes // Uint8Array of the request body ) ``` ## Error handling [#error-handling] ```ts import { isWebhookSignatureError, isWebhookEnvironmentError } from 'marzban-sdk' try { await sdk.webhook.handleWebhook(rawBody, signature) } catch (err) { if (isWebhookSignatureError(err)) { // Missing signature, wrong format, or HMAC mismatch res.status(401).send('Invalid signature') } else if (isWebhookEnvironmentError(err)) { // Called from a browser context — move webhook handling to a server console.error('Webhook verification is server-side only') } } ``` ## Skip verification [#skip-verification] Omit the `secret` from config to process webhooks without signature checking (useful in development): ```ts const sdk = await createMarzbanSDK({ // webhook: { secret: ... } ← not set → verification skipped baseUrl: 'https://vpn.example.com', username: 'admin', password: 'secret', }) // No signature check — any body is accepted await sdk.webhook.handleWebhook(req.body) ``` In production, always configure a secret. Unauthenticated webhook endpoints can be abused to trigger your listeners with arbitrary payloads.