Справочник API
Всё, что делает веб-интерфейс панели, идёт через этот API, и так же могут работать ваши собственные программы: биллинг, бот, скрипт мониторинга или дополнение. Эта страница перечисляет все маршруты; она собрана из того же документа, который панель отдаёт по адресу /api/openapi.json. Его также можно скачать в YAML для генераторов кода и API-клиентов.
Перед вызовом
- Базовый адрес. Собственный адрес панели, с базовым путём, если он есть:
https://panel.example.com:28431/secret-path/api/v1/users. - Используйте
/api/v1. Каждый маршрут доступен дважды: под/api/…и под/api/v1/…. Префикс/api/v1— стабильный контракт для интеграций; префикс без версии принадлежит собственному интерфейсу панели и может меняться. - Аутентификация токеном. Создайте API-токен на странице API-токенов и передавайте его как
Authorization: Bearer <token>. Токен действует от имени администратора, которому принадлежит, никогда с большими правами, и только на маршрутах, разрешённых его областями. - Списки постраничные. Под
/api/v1список отвечает{items, total, limit, offset}и принимаетlimit,offset,qиupdated_since. - Повторы безопасны. Передайте заголовок
Idempotency-Keyпри создании, и повтор после тайм-аута вернёт первый ответ, а не создаст запись дважды. - События вместо опроса. Чтобы узнавать об изменениях, подпишитесь на события через вебхук.
curl -s https://panel.example.com:28431/api/v1/users?limit=5 \
-H "Authorization: Bearer $TOKEN"Маршруты
auth
POST/api/loginLog in and set the session cookie
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
usernamerequired | string | |
passwordrequired | string | |
remember | boolean | Ask for the long-lived session (30 days of inactivity instead of 24 hours). Both slide with use. |
Responses
200Logged in;
nexora_sessioncookie set. Sessions survive a panel restart; see /api/me/sessions. For an account with two-factor authentication on, the password alone earns no session: the body isMfaPendinginstead, no cookie is set, and the login finishes at/api/login/mfawith a code from the authenticator or a recovery code.401ErrorError
POST/api/login/mfaFinish a two-factor login
The second step for an account with 2FA on: the pending token from /api/login plus a six-digit TOTP code or one of the single-use recovery codes. A wrong code counts against the same per-address limiter as a wrong password and does not consume the pending token; a right one does. The pending token lives five minutes and dies with a restart.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
pendingrequired | string | |
coderequired | string | A TOTP code (spaces ignored) or a recovery code (case and dash ignored) |
Responses
POST/api/logoutLog out (clears the session cookie)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Logged out
PUT/api/me/passwordChange the current admin's password (any role)
Verifies the current password, then revokes the admin's other sessions.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
currentPasswordrequired | string | |
newPasswordrequired | string |
Responses
200Changed
401ErrorError
GET/api/me/2faThe caller's two-factor state
Whether TOTP is on, since when, and how many recovery codes remain. Never the seed. API tokens have no second factor and are refused (400).
Responses
200OK
400ErrorError
DELETE/api/me/2faTurn two-factor authentication off
Takes a TOTP or recovery code — whoever finds a session open must not be able to remove the second factor. Clears the seed and the recovery codes and stops the confirmation prompts on every session of the account.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
coderequired | string |
Responses
POST/api/me/2fa/totpStart TOTP enrolment
A fresh base32 secret and the otpauth:// URI an authenticator app enrols from. The secret is held on the calling session only — nothing is written to the account until /api/me/2fa/totp/confirm sees a code from it — so an abandoned enrolment leaves nothing behind, and a panel restart forgets it. Refused (400) while 2FA is already on: changing the seed is disable-then-enrol, and disabling takes a code.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200OK
400ErrorError
POST/api/me/2fa/totp/confirmFinish TOTP enrolment
A code from the pending secret turns 2FA on and answers the ten single-use recovery codes — the only time they are shown. Every session of the account starts asking for confirmation on sensitive routes from here on.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
coderequired | string |
Responses
POST/api/me/2fa/recoveryReplace the recovery codes
A new set of ten, invalidating the old ones. Takes a TOTP code.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
coderequired | string |
Responses
200OK
401ErrorError
POST/api/me/confirmRe-present the second factor on this session
Sensitive routes — admin management, API tokens, licence install, restore, restart, and the panel-wide settings in PanelSettingKeys — answer 428 with {"confirm": "totp"} to a session whose second factor is older than ten minutes. A code here re-opens the window and the client retries. Sessions without 2FA, and API tokens, are never asked.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
coderequired | string |
Responses
200Confirmed
401ErrorError
GET/api/me/sessionsThe caller's live login sessions
Every login of the calling account that has not expired, most recently active first, with the one behind this request flagged current. Sessions are rows in admin_sessions and survive a panel restart. An API token has no login sessions and is refused (400); the route is closed to tokens in the scope table anyway.
Responses
200OK
400ErrorError
DELETE/api/me/sessionsEnd every other session of the calling account
"Sign out everywhere else": every session of the caller's account but the one making the request.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Revoked
400ErrorError
DELETE/api/me/sessions/{id}End one of the caller's sessions
By session id from the listing. Ending the current one is a logout. A session that is not the caller's answers 404, the same as one that does not exist.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
idrequired | path | integer |
Responses
200Revoked
404ErrorError
PUT/api/me/dashboard-tilesSave the calling admin's dashboard tile selection
The pinned tile keys in order, comma-separated. An empty string means nothing is pinned, which is distinct from the value being null (never chosen — the UI then shows its defaults). Read it back from /api/me. Always the caller's own row; API tokens have no account and are refused.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
valuerequired | string |
Responses
GET/api/login/oidcThe login page's single sign-on buttons (public)
The providers that are switched on, in order — which button, nothing about their settings.
Responses
200OK
POST/api/login/oidc/startStart a sign-in at one provider (public)
Answers the provider's authorization URL (code flow with PKCE, a state, and for OpenID Connect a nonce) for the browser to go to. redirectUri is this panel's /api/login/oidc/callback as the browser sees it, on the host the request came to — the address to register at the provider. The sign-in must come back within ten minutes, in the same browser: the answer sets the nexora_oidc cookie, and the callback refuses a state that browser was not given. Refused from a banned address; 409 for a provider that is off, 429 when the address already has ten sign-ins waiting or the panel a thousand.
Request body
| Field | Type | Description |
|---|---|---|
providerrequired | integer | A provider id from GET /api/login/oidc |
redirectUrirequired | string | |
remember | boolean | The long session, as on the password login |
Responses
GET/api/login/oidc/callbackWhere the provider sends the browser back (public)
One callback for every provider. Exchanges the code and verifies the id_token (signature against the issuer's keys, issuer, audience, expiry, nonce) — or, for an OAuth 2.0 provider without OpenID Connect, reads the user endpoint with the access token. Every answer is a 302 into the SPA. A linked identity signs in — / with the session cookie, or login?sso=mfa#<pending> for an account with TOTP, to be finished at POST /api/login/mfa — and anything else lands on login?sso=<unlinked|error|expired|blocked|account-expired>, the same address whoever the identity was. A link started from POST /api/me/oidc/link lands on dashboard?sso=<linked|link-taken|link-error>.
| Parameter | In | Type | Description |
|---|---|---|---|
code | query | string | |
state | query | string | |
error | query | string |
Responses
302Back into the panel
GET/api/me/oidcThe caller's own single sign-on links (session only)
Responses
200OK
POST/api/me/oidc/linkLink an identity at the provider to the caller (session only)
As POST /api/login/oidc/start, but whoever signs in at the provider is linked to the calling account instead of logged in. Costs the account's current password (403 when wrong, counted like a failed login), since a link outlives a password change. The link is made only while the login that started it is still there and the account's password unchanged. An identity linked to another account is refused (link-taken). 409 for a provider that is off.
Request body
| Field | Type | Description |
|---|---|---|
providerrequired | integer | |
redirectUrirequired | string | |
passwordrequired | string | The account's current password |
Responses
DELETE/api/me/oidc/links/{id}Unlink one of the caller's identities (session only)
admins
GET/api/rolesList roles (sudo only)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
403ErrorError
POST/api/rolesCreate a role (sudo only)
Refused with 403 when the role would grant a permission the caller does not hold itself, or is based on a tier above the caller's own.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Role
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The three built-in names are reserved. |
base | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
builtin | boolean | One of the three fixed roles: not editable, not deletable. |
description | string | |
scopes | string[] | The granted permissions, from the list GET /api/scopes serves. At least one is required — an empty set would be a role that reaches nothing, which nobody means to create. A role cannot grant a scope the caller does not hold itself (403), or the first custom role is a way to become a main admin. |
userLimit | integer | Max users an account on this role may own, 0 = no cap. A ceiling over the per-account allowance, never a grant: the effective cap is the smaller of the two non-zero values, and creating past it answers 402. Only bites where ownership exists — a resale-based role. |
maxDeviceLimit | integer | Ceiling on the per-user device (HWID) limit this role may hand out, 0 = no cap. With a cap set, a user carrying no device limit at all is refused rather than clamped: 0 means unlimited everywhere in this schema. |
maxDuration | integer | Ceiling in seconds on a duration-based plan this role may hand out (the window a user may sit in before its first connection starts the clock), 0 = no cap. Same refusal as maxDeviceLimit for an uncapped value. |
admins | integer | How many accounts carry this role |
createdAt | integer | |
updatedAt | integer |
Responses
PUT/api/roles/{id}Update a role (sudo only)
A built-in role answers 400. A rename carries the accounts holding the role with it, and any change ends every session on it: a session carries the scope set it was issued with.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Role
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The three built-in names are reserved. |
base | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
builtin | boolean | One of the three fixed roles: not editable, not deletable. |
description | string | |
scopes | string[] | The granted permissions, from the list GET /api/scopes serves. At least one is required — an empty set would be a role that reaches nothing, which nobody means to create. A role cannot grant a scope the caller does not hold itself (403), or the first custom role is a way to become a main admin. |
userLimit | integer | Max users an account on this role may own, 0 = no cap. A ceiling over the per-account allowance, never a grant: the effective cap is the smaller of the two non-zero values, and creating past it answers 402. Only bites where ownership exists — a resale-based role. |
maxDeviceLimit | integer | Ceiling on the per-user device (HWID) limit this role may hand out, 0 = no cap. With a cap set, a user carrying no device limit at all is refused rather than clamped: 0 means unlimited everywhere in this schema. |
maxDuration | integer | Ceiling in seconds on a duration-based plan this role may hand out (the window a user may sit in before its first connection starts the clock), 0 = no cap. Same refusal as maxDeviceLimit for an uncapped value. |
admins | integer | How many accounts carry this role |
createdAt | integer | |
updatedAt | integer |
Responses
DELETE/api/roles/{id}Delete a role (sudo only)
Refused with 400 when the role is held by any account, naming them, and for a built-in role.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
GET/api/adminsList admins (sudo only)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
403ErrorError
POST/api/adminsCreate an admin (sudo only)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
usernamerequired | string | |
passwordrequired | string | |
role | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
volume | integer | Reseller allowance in bytes, 0 = unlimited |
period | ResalePeriod | How often a reseller's volume allowance resets ("" = never). |
periodDay | integer | |
expiry | integer | |
userLimit | integer |
Responses
PUT/api/admins/{id}Update an admin's username/role (sudo only)
The last sudo admin cannot be demoted. A role change revokes the admin's sessions, and needs the caller to hold every scope of both the new role and the one the account leaves; any edit of an account on the caller's own tier or above needs the caller to hold every scope of its role (403 otherwise).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
usernamerequired | string | |
rolerequired | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
volume | integer | Reseller allowance in bytes, 0 = unlimited |
period | ResalePeriod | How often a reseller's volume allowance resets ("" = never). |
periodDay | integer | |
expiry | integer | |
userLimit | integer |
Responses
DELETE/api/admins/{id}Delete an admin (sudo only)
Self-deletion and deleting the last sudo admin are rejected, and so is deleting an account whose scopes the caller does not cover (403).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
POST/api/admins/{id}/ownerHand the panel to another main admin (the owner's login only; an API token is 403)
Transfers ownership: the flag moves, so there is never a second owner. The caller must be the current owner (403 otherwise, even for another main admin) and the target must already be on a main-admin role (400 otherwise) — promoting it here would decide somebody's role for them as a side effect. The previous owner keeps the role it had and becomes an ordinary main admin.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
PUT/api/admins/{id}/passwordReset an admin's password (sudo only)
Revokes the target admin's sessions (except the caller's own login on self-reset; an API token resetting its own account's password counts as another caller). Another account is reset only by a caller holding every scope it holds (403 otherwise — a narrowed sudo role with admins:write cannot take the owner's account). Resetting another account's password is the recovery path and also removes what reaches that account besides its password — its single sign-on links, its paired Telegram chats and its confirmed email addresses.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
passwordrequired | string |
Responses
DELETE/api/admins/{id}/sessionsEnd every login session of an admin (sudo; another account only when the caller holds every scope of its role, 403 otherwise)
The account keeps working and its next login starts a fresh session; this ends the ones that exist — a lost laptop, a departed operator. The caller's own session is kept when the id is their own account.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
idrequired | path | integer |
Responses
200Revoked
404ErrorError
GET/api/scopesThe scope vocabulary, and the scope each route demands
Read this instead of hardcoding the permission model — both halves come from the tables the server enforces with. scopes is every scope a token may be granted; routes maps each authenticated route pattern ("GET /api/users") to the scope it requires, where "" means any token reaches it and "-" means no token does. GET /api/me reports which scopes the calling token actually holds.
Responses
200OK
GET/api/tokensList third-party API tokens (sudo only)
Only a hash is stored, so token is never present in listings.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
403ErrorError
POST/api/tokensCreate a third-party API token (sudo only)
The token authenticates on this API via Authorization: Bearer. The response is the only time the plaintext token is revealed — the server stores a SHA-256 of it.
adminId is the operator the token acts as; it defaults to the caller, and deleting that operator deletes the token. A token never holds more than its caller does: 403 when what it would reach is not covered by the caller's scopes — its scopes, or the owner's whole role when none are given or when the owner is another account (a token is also its owner's identity). role defaults to the owner's role and is rejected if it outranks it. scopes narrows which routes the token may reach (empty = every route its role allows, the behaviour of tokens issued before scopes existed); a :write scope implies the matching :read. rateLimit is requests per minute, 0 for the panel default.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
desc | string | |
role | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
adminId | integer | Operator the token acts as; defaults to the caller |
scopes | string[] | See GET /api/scopes; empty = unrestricted within the role |
addon | string | Addon id (a slug: a-z, 0-9, _ and -, up to 32 characters). It is what a |
rateLimit | integer | Requests per minute, 0 = panel default |
expiry | integer | Unix seconds, 0 = never |
Responses
DELETE/api/tokens/{id}Delete a third-party API token (sudo only)
The token stops authenticating immediately. A token an addon holds (managedBy) is 409; it goes with the addon.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
GET/api/changesRead the admin audit log (sudo only)
Newest first. Records exist only for actions taken while the changes_logging setting was on; the log is empty otherwise.
Secret-looking fields in obj (passwords, private keys, UUIDs, tokens) are replaced with [redacted] — both when the record is written and again when it is read, so records written by older panels are covered too. changes_retention_days bounds how far back the log goes.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
actor | query | string | Exact operator username |
key | query | string | Exact entity type (user, node, admin, …) |
action | query | string | Exact action (create, update, delete, …) |
from | query | integer | Unix seconds, inclusive lower bound |
to | query | integer | Unix seconds, inclusive upper bound |
Responses
200OK
403ErrorError
DELETE/api/changesPurge audit records (sudo only)
The purge is itself recorded, so an emptied log still says who emptied it.
| Parameter | In | Type | Description |
|---|---|---|---|
before | query | integer | Unix seconds; omit to delete the whole log |
Responses
200OK
403ErrorError
GET/api/changes/facetsDistinct actors, entity types and actions in the audit log (sudo only)
The filter vocabulary actually present in the log, for building a filter UI.
Responses
200OK
403ErrorError
users
GET/api/usersList users
A reseller sees only the users it owns; operators see every user. q matches the name, group, description, remark and subscription id. Every filter ANDs with the others and with q / updated_since, and X-Total-Count is the filtered total.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
status | query | string | Comma-separated subset of |
online | query | boolean |
|
group | query | string | Exact group label |
admin_id | query | integer | Owner: a reseller's admin id, or 0 for users owned by the panel. Ignored for a reseller session, which only ever sees its own. |
template_id | query | integer | Users the template provisions: its explicit members plus every all-templates user |
node_id | query | integer | Users the node serves, through its template; 404 for an unknown node |
inbound_id | query | integer | Users reaching the inbound through any template that carries it |
protocol | query | string | Users reaching any inbound of this type ( |
expires_after | query | integer | Unix seconds, inclusive lower bound on |
expires_before | query | integer | Unix seconds, inclusive upper bound on |
expiry | query | "never" |
|
pending | query | boolean | Whether the plan has started. A plan measured from the first connection carries a provisional expiry until somebody connects, so |
used_min | query | integer | Inclusive lower bound on |
used_max | query | integer | Inclusive upper bound on |
auto_reset | query | boolean | |
sub_fetched | query | boolean | Whether the subscription body has ever been handed to a client ( |
contact_telegram_id | query | string | Exact (case-insensitive) match on the account's |
contact_email | query | string | Exact (case-insensitive) match on the account's |
contact_phone | query | string | Exact (case-insensitive) match on the account's |
contact_bale_id | query | string | Exact (case-insensitive) match on the account's |
contact_soroush_id | query | string | Exact (case-insensitive) match on the account's |
contact_rubika_id | query | string | Exact (case-insensitive) match on the account's |
sort | query | "id" | "name" | "usage" | "remaining" | "expiry" | "online_at" | "sub_fetched_at" | "sub_seen_at" | "created_at" | "updated_at" |
|
order | query | "asc" | "desc" |
Responses
POST/api/usersCreate a user
A reseller always creates users for itself (adminId is forced to its own account) and is refused once its user limit is reached or its account has expired.
An operator or a token naming adminId hands the new account to that reseller, and the account is held to the reseller's own rules exactly as if it had made it: adminId must name a reseller (400 for an operator or nobody; 0 is the panel's own), the reseller's user cap and expiry apply (403, or 402 when its role's cap is the one that binds), and a plansOnly reseller's planId rule below. The same holds when an edit moves an existing account to another reseller.
Sending planId applies that plan: every limit it carries (volume, the length, the speed and IP caps, the periodic reset, the template membership and the group label) is written over whatever else the body contained, and the username is wrapped in the plan's prefix and suffix. An unknown id is a 400. The field is honoured on create only — see PUT /api/users/{id}.
For a plansOnly reseller planId is required and must be one of its planIds; both failures answer 403.
Credentials: each of config.uuid, config.password, config.ss16 and config.ss32 the body leaves out or empty is generated for this account (a missing or null config counts as all four left out). Ones the body carries are kept exactly, so an import keeps the links its customers already have. A body that carries none of the four also gets config.flow: xtls-rprx-vision unless it names a flow — the set the panel's own form and POST /api/users/generate mint; the node is given the flow only where vision can travel.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body User
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
enable | boolean | Absent on a create is on; on an edit (a full replace) absent is off. A person's off — this field set to false, or the bulk disable — is never undone by the quota enforcer; only a person turns it back on. |
disabledReason | string | Why the account is off: |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
desc | string | |
contact | object | The customer's own details, as one flat object of string pairs. Six keys are standard — |
subToken | string | |
subId | string | Subscription identifier used in /sub/{id}; defaulted random on create |
remark | string | Label prepended to generated share-link names |
volume | integer | Byte cap, 0 = unlimited |
speedLimit | integer | Throughput cap in bytes per second, per direction and per node, 0 = unlimited |
ipLimit | integer | Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited |
deviceLimit | integer | Distinct devices that may hold the subscription, counted by the |
expiry | integer | Unix seconds, 0 = never. With |
duration | integer | Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed |
activatedAt | integer | Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it. |
up | integer | |
down | integer | |
onlineAt | integer | Unix ts of last traffic; online if within ~90s |
subFetchedAt | integer | Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs. |
subSeenAt | integer | Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never |
subClient | string | User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text. |
subFetchIp | string | Address the body was last fetched from |
lastInbound | string | Tag of the inbound this user's most recent connection arrived on, as the node reported it — the one-word answer to which protocol the user actually uses. Empty until the first drain that carried it. |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
nextReset | integer | The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not. |
totalUp | integer | |
totalDown | integer | |
group | string | Free text label for bulk operations |
adminId | integer | Reseller (role resale) that owns this user; 0 = owned by the panel. Resellers may only see and manage their own users, and always create users for themselves. |
allTemplates | boolean | Provision the user on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour |
subUrl | string | This account's absolute subscription URL, computed per response and never stored. It is returned because a wildcard sub_domain mints a hostname per subscriber, derived from the account, which no client of this API can compute — build links from this rather than from the sub_domain setting. Omitted where a response does not carry it. |
licensed | boolean | Whether the row is inside the licence cap: one of the first N users by id, N being the licensed count. A row outside it is listed and can be deleted, is served on no node, and answers 402 to every other write. Computed per response; the cap moves whenever a row is deleted or a licence installed. |
planId | integer | Create only: apply this plan's limits over the rest of the body. Ignored on update — see PUT /api/users/{id}. Nothing links the user to the plan afterwards. |
createdAt | integer | |
updatedAt | integer |
Responses
GET/api/users/summaryCount users by status, plus the group labels in use
The readout above the users table: how many users the caller may see, how many are online (traffic within 90 s), one counter per status — the same test each status filter applies, so the two agree — and the distinct non-empty group labels, sorted. A reseller's numbers cover only its own users.
Responses
200OK
POST/api/users/bulk/{op}Run one operation over a whole selection
One request, one statement per batch, and one push per affected node — rather than repeating a single-user route, which reconciles the fleet once per row.
The selection arrives one of two ways. Send {"ids": [...]} to name the rows. Send no body and use the query parameters of GET /api/users — every filter above means exactly what it means there, applied by the same code — to act on everyone a filter finds; X-Total-Count from that same list request is therefore a preview of what this will touch. Sending neither is a 400: all=true is the only way to say "every user", and it has to be typed.
A reseller reaches only the accounts it owns (ids naming anything else are dropped, not refused) and is denied outright when usersReadOnly is set. At most 5000 users per call; a wider filter answers 400 rather than silently acting on part of it.
enable deliberately skips accounts the quota enforcer would disable again within the minute — past their expiry, or over their volume — and counts them as blocked. reset-traffic zeroes the current cycle and moves the next periodic reset, leaving lifetime totals alone, exactly as the scheduled reset does. reset-devices forgets every device the selection has registered, freeing the places deviceLimit counts; affected is the number of users that had any, not the number of devices dropped, and a device already holding the configs keeps connecting — what changes is who may fetch next.
add-days, add-months, add-traffic and limits rewrite what an account was sold, so a plansOnly reseller is refused them (403) while the rest stay open to it.
Bulk operations emit no per-user events: one user.bulk event carries the summary (op, matched, affected, blocked, failed).
| Parameter | In | Type | Description |
|---|---|---|---|
oprequired | path | "enable" | "disable" | "reset-traffic" | "reset-devices" | "delete" | "add-days" | "add-months" | "add-traffic" | "limits" | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
all | query | "true" | Run on every user the caller owns. Required when no |
q | query | string | Case-insensitive substring search over the resource's text columns. |
status | query | string | |
online | query | boolean | |
group | query | string | |
admin_id | query | integer | |
template_id | query | integer | |
node_id | query | integer | |
inbound_id | query | integer | |
protocol | query | string | |
expires_after | query | integer | |
expires_before | query | integer | |
expiry | query | "never" | |
pending | query | boolean | |
used_min | query | integer | |
used_max | query | integer | |
auto_reset | query | boolean | |
sub_fetched | query | boolean | Whether the subscription body has ever been handed to a client ( |
contact_telegram_id | query | string | Exact (case-insensitive) match on the account's |
contact_email | query | string | Exact (case-insensitive) match on the account's |
contact_phone | query | string | Exact (case-insensitive) match on the account's |
contact_bale_id | query | string | Exact (case-insensitive) match on the account's |
contact_soroush_id | query | string | Exact (case-insensitive) match on the account's |
contact_rubika_id | query | string | Exact (case-insensitive) match on the account's |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
limit | query | integer | Parsed and range-checked (a negative or non-integer value is a 400) but not applied: this route acts on the whole selection, and |
offset | query | integer | Parsed and range-checked but not applied; see |
sort | query | "id" | "name" | "usage" | "remaining" | "expiry" | "online_at" | "sub_fetched_at" | "sub_seen_at" | "created_at" | "updated_at" | Accepted and validated (a bad value is a 400); it does not change which rows the operation acts on. |
order | query | "asc" | "desc" | Accepted and validated; see |
Request body
| Field | Type | Description |
|---|---|---|
ids | integer[] | The users to act on. When absent, the query parameters select them. |
months | integer |
|
fromNow | boolean |
|
calendar | "" | "jalali" |
|
days | integer |
|
bytes | integer |
|
volume | integer |
|
expiry | integer |
|
speedLimit | integer |
|
ipLimit | integer |
|
deviceLimit | integer |
|
autoReset | boolean |
|
resetDays | integer |
|
resetPeriod | "" | "weekly" | "monthly" | "yearly" |
|
resetStartDay | integer |
|
resetStartMonth | integer |
|
resetCalendar | "" | "jalali" |
|
Responses
POST/api/users/renew/{id}Renew a user
What a sale renews: time, traffic, or a fresh cycle of usage, for one account. Time counts from the later of now and the current expiry — renewed before its date the account keeps what it had left, renewed after it starts again today — so a customer who paid for thirty days gets thirty. The credentials, the subscription link and the usage history are kept.
days or months (with calendar, as add-months counts them), not both. An account whose plan has not started (measured from the first connection) stays on hold: days lengthen the plan and its provisional date together, and months are 409, since a calendar month is not a length. An account that never expires is 409 for time, and one with unlimited traffic is 409 for traffic — a renewal never takes an unlimited allowance away. resetUsage zeroes the current cycle's counters (the lifetime totals stay) and moves a reset cycle's next date. A plan cannot be named: a sales program maps its product to these amounts.
An account the quota enforcer switched off for its date or its traffic is judged again at once and comes back on (user.enabled) rather than on the next minute's pass; one a person switched off stays off. Two renewals of one account at the same moment both count. Raises user.renewed {userId, name, adminId, expiry, volume}. A reseller renews its own accounts; a usersReadOnly or a plansOnly one is 403.
Why not /api/users/{id}/renew: that pattern and POST /api/users/bulk/{op} would both match /api/users/bulk/renew.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
days | integer | |
months | integer | |
calendar | "" | "jalali" | With |
traffic | integer | Bytes added to the volume cap. |
resetUsage | boolean |
Responses
POST/api/users/generateCreate a batch of accounts
The body is an ordinary user create — planId, template membership, group, the per-user limits — plus how many accounts to make and what to call them, so a batch is bought exactly the way one account is.
pattern must carry a counter, a random part, or both. {n} numbers plainly and {nnn} pads to three digits, so a series sorts the way it was created; accounts are numbered start upward. {r} draws six random characters and {r8} draws eight, from lowercase letters and digits with the confusable ones (0/o, 1/l, i) left out — a name a customer can read down a phone but a neighbour cannot guess.
A name already taken is skipped, not fatal, so a batch run twice fills the gaps. A name carrying a random part is redrawn a few times first: on a counter a taken name is the account this series already made, but on a random draw it is only bad luck.
A planId whose plan defines namePrefix / nameSuffix wraps every generated name, exactly as it wraps a single account's. Each account is also given its own credentials (uuid, password, ss16, ss32). A secret sent in the body's config would otherwise be the same secret on every account in the batch, and a body carrying none at all — which is what every caller sends — would leave the whole batch with no credentials and unusable share links.
The licence cap and a reseller's own allowance are checked once for the whole batch and the request is refused (402) rather than trimmed — an operator told forty of a hundred fit has lost nothing, one silently given forty has to work out which sixty customers have no account. At most 500 per call. The response carries the whole created rows, subscription tokens included, because handing the links over is the point.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
Responses
GET/api/users/exportDownload the selection as a sheet or a config pack
csv is the delivery and accounting sheet: one row per account with its limits, what it has spent and its subscription URL. Byte columns are bytes, not "1.5 GB" — the file is read by another program.
zip is the offline half: one directory per account holding its subscription URL, its share links and every downloadable client config (.conf, .ovpn) it has.
Selected by ids or by the same filters GET /api/users takes. Reach is the reach of GET /api/users/{id}/links, which this is the bulk spelling of: an operator sees every account, a reseller only its own. At most 50000 rows for csv and 500 for zip; a wider selection answers 400 rather than a silently short file, as does a request that names no selection at all. The sheet begins with a UTF-8 BOM so a spreadsheet opening the downloaded file decodes it as UTF-8; a program reading the header row should strip it. Always recorded in the audit log, whether or not changes_logging is on — it is the one route that hands over credentials in bulk.
| Parameter | In | Type | Description |
|---|---|---|---|
format | query | "csv" | "zip" | |
ids | query | string | Comma-separated user ids. When absent, the filters below select the rows. |
all | query | "true" | Export every user the caller owns. Required when no |
q | query | string | Case-insensitive substring search over the resource's text columns. |
status | query | string | |
online | query | boolean | |
group | query | string | |
admin_id | query | integer | |
template_id | query | integer | |
node_id | query | integer | |
inbound_id | query | integer | |
protocol | query | string | |
expires_after | query | integer | |
expires_before | query | integer | |
expiry | query | "never" | |
pending | query | boolean | |
used_min | query | integer | |
used_max | query | integer | |
auto_reset | query | boolean | |
sub_fetched | query | boolean | Whether the subscription body has ever been handed to a client ( |
contact_telegram_id | query | string | Exact (case-insensitive) match on the account's |
contact_email | query | string | Exact (case-insensitive) match on the account's |
contact_phone | query | string | Exact (case-insensitive) match on the account's |
contact_bale_id | query | string | Exact (case-insensitive) match on the account's |
contact_soroush_id | query | string | Exact (case-insensitive) match on the account's |
contact_rubika_id | query | string | Exact (case-insensitive) match on the account's |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
limit | query | integer | Parsed and range-checked (a negative or non-integer value is a 400) but not applied: this route acts on the whole selection, and |
offset | query | integer | Parsed and range-checked but not applied; see |
sort | query | "id" | "name" | "usage" | "remaining" | "expiry" | "online_at" | "sub_fetched_at" | "sub_seen_at" | "created_at" | "updated_at" | Accepted and validated (a bad value is a 400); it does not change which rows the operation acts on. |
order | query | "asc" | "desc" | Accepted and validated; see |
Responses
GET/api/users/{id}Read one user
One account in exactly the shape the list answers it, subUrl included. An addon keeps panel ids in its own database and reads the accounts back here. A reseller is answered 404 for an account it does not own, the same as for one that does not exist.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
PUT/api/users/{id}Update a user
planId is ignored here. A plan is initialisation: re-applying a fixed-date one on every save would extend the account each time it was edited, so an existing user's limits are only ever changed field by field. Changing duration restarts the plan from the next connection; every other edit leaves the clock where it stands.
A reseller marked usersReadOnly is answered 403 here and on DELETE. A plansOnly reseller may edit the name, description, credentials, remark and enable switch; every limit it sends — volume, expiry, duration, speed, address limit, the periodic reset, the template membership and the group — is replaced with the value already on the row, so an edit can never change what was sold.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body User
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
enable | boolean | Absent on a create is on; on an edit (a full replace) absent is off. A person's off — this field set to false, or the bulk disable — is never undone by the quota enforcer; only a person turns it back on. |
disabledReason | string | Why the account is off: |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
desc | string | |
contact | object | The customer's own details, as one flat object of string pairs. Six keys are standard — |
subToken | string | |
subId | string | Subscription identifier used in /sub/{id}; defaulted random on create |
remark | string | Label prepended to generated share-link names |
volume | integer | Byte cap, 0 = unlimited |
speedLimit | integer | Throughput cap in bytes per second, per direction and per node, 0 = unlimited |
ipLimit | integer | Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited |
deviceLimit | integer | Distinct devices that may hold the subscription, counted by the |
expiry | integer | Unix seconds, 0 = never. With |
duration | integer | Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed |
activatedAt | integer | Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it. |
up | integer | |
down | integer | |
onlineAt | integer | Unix ts of last traffic; online if within ~90s |
subFetchedAt | integer | Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs. |
subSeenAt | integer | Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never |
subClient | string | User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text. |
subFetchIp | string | Address the body was last fetched from |
lastInbound | string | Tag of the inbound this user's most recent connection arrived on, as the node reported it — the one-word answer to which protocol the user actually uses. Empty until the first drain that carried it. |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
nextReset | integer | The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not. |
totalUp | integer | |
totalDown | integer | |
group | string | Free text label for bulk operations |
adminId | integer | Reseller (role resale) that owns this user; 0 = owned by the panel. Resellers may only see and manage their own users, and always create users for themselves. |
allTemplates | boolean | Provision the user on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour |
subUrl | string | This account's absolute subscription URL, computed per response and never stored. It is returned because a wildcard sub_domain mints a hostname per subscriber, derived from the account, which no client of this API can compute — build links from this rather than from the sub_domain setting. Omitted where a response does not carry it. |
licensed | boolean | Whether the row is inside the licence cap: one of the first N users by id, N being the licensed count. A row outside it is listed and can be deleted, is served on no node, and answers 402 to every other write. Computed per response; the cap moves whenever a row is deleted or a licence installed. |
planId | integer | Create only: apply this plan's limits over the rest of the body. Ignored on update — see PUT /api/users/{id}. Nothing links the user to the plan afterwards. |
createdAt | integer | |
updatedAt | integer |
Responses
PATCH/api/users/{id}Change some fields of a user
Writes exactly the fields sent and nothing else: every other column is left byte for byte as it was, so editing one field can never wipe the credentials or the limits the way a PUT that left them out does. The rules an edit is held to are the PUT's — a reseller's reach and its plansOnly freeze, the licence cap, the role caps, enable: false recorded as a person's — and so is adminId (an account handed to a reseller must fit under its user cap).
version, optional, is the account's updatedAt as the caller read it. When the account has changed since, the edit is refused with 409 and the current version, and nothing is written; read the account again and decide afresh. A write always moves the version forward, even within the second it counts in, so two edits from one version cannot both pass. A field sent with the value it already has is not a change: nothing is written and the version stays.
A field that cannot be changed here — the usage counters, the subscription token, planId, anything unknown — is a 400 naming it, never silently ignored.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body UserPatch
| Field | Type | Description |
|---|---|---|
version | integer | The |
name | string | |
enable | boolean | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
desc | string | |
group | string | |
contact | object | |
adminId | integer | |
subId | string | |
remark | string | |
volume | integer | |
expiry | integer | |
duration | integer | |
activatedAt | integer | |
nextReset | integer | |
speedLimit | integer | |
ipLimit | integer | |
deviceLimit | integer | |
autoReset | boolean | |
resetDays | integer | |
resetPeriod | string | |
resetStartDay | integer | |
resetStartMonth | integer | |
resetCalendar | string | |
allTemplates | boolean | |
templateIds | integer[] |
Responses
200UserA user
400ErrorError
402ErrorThe user is outside the licence cap, or the reseller named by
adminIdis at its role's user cap.403ErrorA reseller marked
usersReadOnly, or the reseller named byadminIdis at its own user cap or has expired.404ErrorError
409The account changed since
version. The body carries the current one.
DELETE/api/users/{id}Delete a user
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
GET/api/users/{id}/node-usageOne user's traffic split across nodes
The mirror of GET /api/nodes/{id}/stats: that one answers "this node, which users", this one "this user, which nodes". A node deleted since the traffic was recorded keeps its row with an empty name.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
limit | query | integer | Rows to return, heaviest first. Values above the maximum are clamped, not refused |
Responses
200UserNodeUsageOK
400ErrorError
GET/api/users/{id}/addressesWhere the user is connected from right now
The source prefixes the fleet currently sees for this user, merged from every node's report and newest first. Live and memory-only: an address stays for ten minutes after its last connection, and a panel that has just restarted answers an empty list until the next round of node reports a few seconds later. A reseller may read its own users.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200UserAddressesOK
404ErrorError
GET/api/users/{id}/connectionsWhere the user is connected right now, per node and inbound
Asked of every connected node when the call is made and filtered to this user — one row per node and inbound with the number of sessions open there. A node that does not answer in time is left out. Empty, never null, for a fleet with nothing to say. A reseller may read its own users.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200UserConnectionsOK
404ErrorError
GET/api/users/{id}/connections/{nodeId}The user's live connections on one node, row by row
Read from that node when the call is made. One node by design: the rollup above is the cheap fleet-wide answer that says which node to ask, and only the node asked walks its registry. inbound and outbound narrow the rows alone; total, inbounds and outbounds describe the user's whole presence on the node, so a picker built from them keeps its options. Rows are newest first and cut at the node's cap, with truncated saying so. supported is false for a node that predates per-session rows: it still counts them. Nothing is stored. A reseller may read its own users.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
nodeIdrequired | path | integer | |
inbound | query | string | Only rows that arrived on this inbound tag |
outbound | query | string | Only rows routed to this outbound tag |
Responses
200UserSessionsOK
404ErrorError
502ErrorError
POST/api/users/{id}/connections/closeDrop the user's live connections
Closes the connections the body selects: on one node (nodeId) or every connected node, one inbound or outbound, or — with an empty body — all of them everywhere. No single-row close: a connection is seconds of life and the client opens the next one unasked. Not a ban: the client reconnects on its next packet unless the user is also disabled. Sent to one node it fails as that node fails; sent to the fleet it reports what each node could do and fails nothing. Audited. A reseller may act on its own users.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body CloseConnsFilter
| Field | Type | Description |
|---|---|---|
nodeId | integer | One node; 0 or absent means every connected node |
inbound | string | |
outbound | string |
Responses
200CloseConnsResultOK
404ErrorError
502ErrorError
GET/api/users/{id}/devicesDevices holding the user's subscription
Every device that has fetched this user's subscription and identified itself with an x-hwid header, newest activity first, with the platform, OS version and model the client reported. A record of fetches, not of connections: the node never learns a device id. A reseller may read its own users.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200UserDevicesOK
404ErrorError
DELETE/api/users/{id}/devices/{hwid}Forget a device
Frees the device's place under the user's deviceLimit. The device is not cut off — it holds the configs already and the node authenticates by credential — but its next fetch, and any new device's, is judged against the cap again. How an operator lets a customer who replaced their phone back in.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
hwidrequired | path | string |
Responses
GET/api/users/{id}/app-configsDedicated-app client configs (OpenVPN .ovpn / OpenConnect info / WireGuard template) across the user's nodes
plans
GET/api/plansList plans
Plans are ordered by sortOrder, then by id. Readable by every operator and by a reseller, which needs them to create an account; only operators decide what is on offer. A plansOnly reseller is served exactly the plans it was granted, so the list it sees is the list it may sell.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/plansCreate a plan
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Plan
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
desc | string | |
sortOrder | integer | Display order; equal values fall back to the id |
volume | integer | Byte cap the account starts with, 0 = unlimited |
days | integer | Plan length in days, 0 = never expires |
onFirstUse | boolean | true: |
speedLimit | integer | Throughput cap in bytes per second, per direction, 0 = unlimited |
ipLimit | integer | Concurrent source addresses, 0 = unlimited |
deviceLimit | integer | Devices that may hold the subscription (by |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
allTemplates | boolean | Provision on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership, used when allTemplates is false |
group | string | Group label written onto every account this plan creates |
namePrefix | string | Prepended to the username the caller chose; empty = unchanged |
nameSuffix | string | Appended to the username the caller chose; empty = unchanged |
createdAt | integer | |
updatedAt | integer |
Responses
PUT/api/plans/{id}Update a plan
Users created from the plan are untouched: a plan is the state an account starts in, not a link it keeps.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Plan
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
desc | string | |
sortOrder | integer | Display order; equal values fall back to the id |
volume | integer | Byte cap the account starts with, 0 = unlimited |
days | integer | Plan length in days, 0 = never expires |
onFirstUse | boolean | true: |
speedLimit | integer | Throughput cap in bytes per second, per direction, 0 = unlimited |
ipLimit | integer | Concurrent source addresses, 0 = unlimited |
deviceLimit | integer | Devices that may hold the subscription (by |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
allTemplates | boolean | Provision on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership, used when allTemplates is false |
group | string | Group label written onto every account this plan creates |
namePrefix | string | Prepended to the username the caller chose; empty = unchanged |
nameSuffix | string | Appended to the username the caller chose; empty = unchanged |
createdAt | integer | |
updatedAt | integer |
Responses
DELETE/api/plans/{id}Delete a plan
Withdraws the plan from sale. Accounts already created from it keep every limit they were given.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
inbounds
GET/api/inboundsList inbounds
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/inboundsCreate an inbound
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Inbound
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | |
type | string | vless, vmess, trojan, shadowsocks, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
remark | string | |
useNodeCert | boolean | Replace this inbound's TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this inbound's TLS (wins over useNodeCert) |
clientConfig | JSON | Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template |
frontedOnly | boolean | Drop this inbound's direct entries from a subscription when a domain front rendered for it. Applied per user and only when a front actually rendered for that user, so a front scoped to an origin node they cannot reach never takes the inbound out of their file. |
createdAt | integer | |
updatedAt | integer |
Responses
200InboundAn inbound
PUT/api/inbounds/{id}Update an inbound
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Inbound
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | |
type | string | vless, vmess, trojan, shadowsocks, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
remark | string | |
useNodeCert | boolean | Replace this inbound's TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this inbound's TLS (wins over useNodeCert) |
clientConfig | JSON | Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template |
frontedOnly | boolean | Drop this inbound's direct entries from a subscription when a domain front rendered for it. Applied per user and only when a front actually rendered for that user, so a front scoped to an origin node they cannot reach never takes the inbound out of their file. |
createdAt | integer | |
updatedAt | integer |
Responses
200InboundAn inbound
DELETE/api/inbounds/{id}Delete an inbound
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
PUT/api/inbounds/{id}/frontsSet which domain fronts are enabled on this inbound
The same inbound_fronts join PUT /api/fronts/{id} writes from the other end. An inbound lists its fronts and a front lists its inbounds; whichever the operator is looking at is the one they edit. An empty list removes every front from this inbound. When the change moves which client-address headers an XHTTP inbound trusts (see clientIpHeader on DomainFront), the inbound is re-sent to every node serving it, which restarts it there and drops its live connections.
Refused when the inbound's protocol cannot sit behind a CDN, exactly as the front side refuses it.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
frontIds | integer[] |
Responses
certificates
GET/api/certificatesList panel certificates (shared TLS certs assignable to inbounds/endpoints, usable as node fallbacks)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/certificatesDefine a panel certificate and issue it (self-signed locally, ACME via the designated node)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body PanelCertRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
typerequired | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs (required except for custom, where they derive from the PEM) |
challenge | "http-01" | "tls-alpn-01" | "dns-01" | ACME only |
email | string | |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 only; "" clears it |
dnsToken | string | dns-01 provider credential; empty on update keeps the stored one |
obtainNodeId | integer | Who answers the ACME challenge. Null (the default) means the panel itself: dns-01 works for any name, while http-01 and tls-alpn-01 need the name to resolve to the panel and port 80/443 free on it — requesting either for an IP SAN is refused with 400. Set it to a node to have that node's agent answer instead, which is the only way to satisfy an on-host challenge for a name pointing at the node. |
certificate | string | PEM certificate chain (custom type only; empty on update keeps the stored pair) |
key | string | PEM private key (custom type only) |
client | JSON | Client-side TLS template (never reissues the certificate) |
Responses
200PanelCertificateOK
502ErrorError
PUT/api/certificates/{id}Update a panel certificate (a pure rename keeps the PEM; parameter changes reissue it and resync using nodes)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body PanelCertRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
typerequired | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs (required except for custom, where they derive from the PEM) |
challenge | "http-01" | "tls-alpn-01" | "dns-01" | ACME only |
email | string | |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 only; "" clears it |
dnsToken | string | dns-01 provider credential; empty on update keeps the stored one |
obtainNodeId | integer | Who answers the ACME challenge. Null (the default) means the panel itself: dns-01 works for any name, while http-01 and tls-alpn-01 need the name to resolve to the panel and port 80/443 free on it — requesting either for an IP SAN is refused with 400. Set it to a node to have that node's agent answer instead, which is the only way to satisfy an on-host challenge for a name pointing at the node. |
certificate | string | PEM certificate chain (custom type only; empty on update keeps the stored pair) |
key | string | PEM private key (custom type only) |
client | JSON | Client-side TLS template (never reissues the certificate) |
Responses
200PanelCertificateOK
404ErrorError
502ErrorError
DELETE/api/certificates/{id}Delete a panel certificate, clearing assignments/fallbacks and resyncing the nodes that used it
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200OK
409ErrorThe certificate serves the panel's own HTTPS listener (web_tls_mode=self_signed with web_cert_id pointing at it). Deleting it would leave the panel with a TLS mode and nothing behind it, so change the mode first.
POST/api/certificates/{id}/renewReissue the panel certificate with its stored parameters and resync using nodes
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200PanelCertificateOK
404ErrorError
502ErrorError
templates
GET/api/templatesList templates
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/templatesCreate a template
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Template
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
remark | string | |
route | JSON | An arbitrary JSON value (a core config fragment, etc.). |
core | JSON | dns/log/ntp blocks |
inbounds | Inbound[] | |
outbounds | Outbound[] | |
endpoints | Endpoint[] | |
ruleSets | RuleSet[] | |
inboundIds | integer[] | |
outboundIds | integer[] | |
endpointIds | integer[] | |
ruleSetIds | integer[] | |
createdAt | integer | |
updatedAt | integer |
Responses
200TemplateA template
PUT/api/templates/{id}Update a template
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Template
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
remark | string | |
route | JSON | An arbitrary JSON value (a core config fragment, etc.). |
core | JSON | dns/log/ntp blocks |
inbounds | Inbound[] | |
outbounds | Outbound[] | |
endpoints | Endpoint[] | |
ruleSets | RuleSet[] | |
inboundIds | integer[] | |
outboundIds | integer[] | |
endpointIds | integer[] | |
ruleSetIds | integer[] | |
createdAt | integer | |
updatedAt | integer |
Responses
200TemplateA template
DELETE/api/templates/{id}Delete a template
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
nodes
GET/api/nodesList nodes
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/nodesCreate a node
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Node
| Field | Type | Description |
|---|---|---|
id | integer | |
licensed | boolean | Whether the node is inside the licence's node cap: one of the first N nodes by id. A node outside it is listed, is served an empty plan, and answers 402 to every write but delete. Computed per response. |
name | string | |
address | string | |
port | integer | |
status | "connecting" | "connected" | "error" | "disabled" | |
message | string | Why the node is not working; empty while it is connected |
warnings | string[] | Config items the node is not running: those its core skipped on its last successful load, then those the last live sync could not apply (kept until a sync applies them). The node is serving everything else; these are not connection errors. |
remark | string | |
linkAddress | string | The node's public address, or several: a comma-separated list whose entries are each a bare address or "label|address". The first is the primary — what endpoints and app configs use, and what a tunnel dials; every entry after it adds a copy of each client link. Empty falls back to address. Never sent to a node. |
isLocal | boolean | |
routing | JSON | An arbitrary JSON value (a core config fragment, etc.). |
coreVersion | string | |
templateId | integer | |
multiplier | number | Traffic multiplier applied to user usage on this node (0 treated as 1) |
enabled | boolean | Admin on/off intent; toggled via /enabled |
limitBytes | integer | Periodic usage cap in bytes (0 = no limit) |
limitPeriod | "" | "weekly" | "monthly" | "yearly" | Periodic limit window |
limitStartDay | integer | Weekly 0-6 (Sun-Sat); monthly/yearly 1-31 |
limitStartMonth | integer | Yearly anchor month 1-12 |
limitDisabled | boolean | Auto-disabled by the periodic usage limit |
periodUsed | integer | Raw bytes counted against limitBytes so far in the current period; 0 without an active limit |
periodStart | integer | Unix seconds the current limit period began; 0 without an active limit |
online | integer | Distinct users that sent traffic through this node inside the online window (90s). Absent rather than 0 when the usage series is switched off — the difference between "nobody is connected" and "nothing is counting". |
live | object | What the node said about itself on its last heartbeat. Absent for a node that has not answered since the panel started, has stopped answering, or predates the figures — "not reported", never a zero that reads as idle. |
fallbackCertMode | "" | "node" | "panel" | Certificate substituted into TLS configs that end up without one |
fallbackCertId | integer | Panel certificate used when fallbackCertMode=panel |
up | integer | |
down | integer | |
sshHost | string | Host for the automatic installer; empty falls back to address |
sshPort | integer | |
sshUser | string | |
sshKeyId | integer | Panel SSH key used for this host; null = the default key |
sshKeyInstalled | boolean | The panel's key is authorised on this host, so updates need no password |
setup | string | Last-seen deployment: docker, podman, containerd, lxc or linux |
arch | string | Last-seen GOARCH of the running agent |
nodeVersion | string | Last-seen node agent version (coreVersion is the proxy engine's) |
infoSeenAt | integer | When the three fields above were last confirmed |
lastStatusChange | integer | |
createdAt | integer | |
updatedAt | integer |
Responses
200NodeA node
PUT/api/nodes/{id}Update a node
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Node
| Field | Type | Description |
|---|---|---|
id | integer | |
licensed | boolean | Whether the node is inside the licence's node cap: one of the first N nodes by id. A node outside it is listed, is served an empty plan, and answers 402 to every write but delete. Computed per response. |
name | string | |
address | string | |
port | integer | |
status | "connecting" | "connected" | "error" | "disabled" | |
message | string | Why the node is not working; empty while it is connected |
warnings | string[] | Config items the node is not running: those its core skipped on its last successful load, then those the last live sync could not apply (kept until a sync applies them). The node is serving everything else; these are not connection errors. |
remark | string | |
linkAddress | string | The node's public address, or several: a comma-separated list whose entries are each a bare address or "label|address". The first is the primary — what endpoints and app configs use, and what a tunnel dials; every entry after it adds a copy of each client link. Empty falls back to address. Never sent to a node. |
isLocal | boolean | |
routing | JSON | An arbitrary JSON value (a core config fragment, etc.). |
coreVersion | string | |
templateId | integer | |
multiplier | number | Traffic multiplier applied to user usage on this node (0 treated as 1) |
enabled | boolean | Admin on/off intent; toggled via /enabled |
limitBytes | integer | Periodic usage cap in bytes (0 = no limit) |
limitPeriod | "" | "weekly" | "monthly" | "yearly" | Periodic limit window |
limitStartDay | integer | Weekly 0-6 (Sun-Sat); monthly/yearly 1-31 |
limitStartMonth | integer | Yearly anchor month 1-12 |
limitDisabled | boolean | Auto-disabled by the periodic usage limit |
periodUsed | integer | Raw bytes counted against limitBytes so far in the current period; 0 without an active limit |
periodStart | integer | Unix seconds the current limit period began; 0 without an active limit |
online | integer | Distinct users that sent traffic through this node inside the online window (90s). Absent rather than 0 when the usage series is switched off — the difference between "nobody is connected" and "nothing is counting". |
live | object | What the node said about itself on its last heartbeat. Absent for a node that has not answered since the panel started, has stopped answering, or predates the figures — "not reported", never a zero that reads as idle. |
fallbackCertMode | "" | "node" | "panel" | Certificate substituted into TLS configs that end up without one |
fallbackCertId | integer | Panel certificate used when fallbackCertMode=panel |
up | integer | |
down | integer | |
sshHost | string | Host for the automatic installer; empty falls back to address |
sshPort | integer | |
sshUser | string | |
sshKeyId | integer | Panel SSH key used for this host; null = the default key |
sshKeyInstalled | boolean | The panel's key is authorised on this host, so updates need no password |
setup | string | Last-seen deployment: docker, podman, containerd, lxc or linux |
arch | string | Last-seen GOARCH of the running agent |
nodeVersion | string | Last-seen node agent version (coreVersion is the proxy engine's) |
infoSeenAt | integer | When the three fields above were last confirmed |
lastStatusChange | integer | |
createdAt | integer | |
updatedAt | integer |
Responses
DELETE/api/nodes/{id}Delete a node
Stops the node's engine as it is deleted, so it serves nothing more. stopped is false when the node could not be reached: it keeps serving what it had until its process restarts.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
POST/api/nodes/{id}/syncPush the full config to the node
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
409The node is switched off
POST/api/nodes/{id}/reconnectRe-establish the mTLS connection
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
409The node is switched off
GET/api/nodes/install/versionsNode binaries this panel has staged, and their versions
Responses
200OK
POST/api/nodes/{id}/install/probeInspect a node's host over SSH and report the install plan
First contact pins the host's SSH key; afterwards a different key is a 409, because the answer to that is never "retry".
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body SSHCredentials
| Field | Type | Description |
|---|---|---|
sshHost | string | Defaults to the node's sshHost, then its address |
sshPort | integer | |
sshUser | string | |
password | string | |
privateKey | string | PEM private key |
passphrase | string |
Responses
200ProbeResultOK
401ErrorError
409ErrorError
502ErrorError
POST/api/nodes/{id}/installInstall, update or remove the node on its server over SSH
Returns immediately with the job to watch; the install runs in the background. One install at a time per node.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body InstallRequest
Responses
200InstallJobOK
401ErrorError
409ErrorError
GET/api/nodes/install/jobs/{job}Poll an install job's state and output
| Parameter | In | Type | Description |
|---|---|---|---|
jobrequired | path | string | |
cursor | query | integer | Lines already seen; 0 for the whole log |
Responses
200OK
404ErrorError
POST/api/nodes/install/jobs/{job}/cancelAbort a running install
Signals the remote script and drops the connection. Whatever it had already done on the host stays done.
| Parameter | In | Type | Description |
|---|---|---|---|
jobrequired | path | string |
Responses
200OK
404ErrorError
GET/api/nodes/install/jobs/{job}/streamWebSocket stream of an install job's output
Upgrades to a WebSocket; each message is a JSON {job, lines, cursor} — the same shape the polling endpoint returns. The socket closes once the job has finished and its last lines are sent.
| Parameter | In | Type | Description |
|---|---|---|---|
jobrequired | path | string |
Responses
101Switching Protocols
404ErrorError
POST/api/nodes/{id}/movePoint a node at a different server
Changing the address alone is not enough: the control API's certificate pin, the SSH host key and the cached deployment facts all belong to the old machine and would refuse or misdirect every connection to the new one. This drops all three together and is the only safe way to move a node.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body
| Field | Type | Description |
|---|---|---|
addressrequired | string | |
port | integer | |
sshHost | string | |
sshPort | integer | |
sshUser | string |
Responses
POST/api/nodes/{id}/ssh/revokeRemove the panel's SSH key from the node's server
The key is one key for every host, so it stays in authorized_keys when another node or an addon the panel installed is on the same machine (by any of its names or addresses) at the same login, or when that cannot be ruled out because a lookup failed. Then keyKept is true, reason says why and keptFor names the record it stays for, when one was found. Either way this node no longer uses the key: the last record on the machine to be revoked removes it. A record whose name does not resolve at all is left out of the comparison and named in the log (and in reason when the key stays).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body SSHCredentials
| Field | Type | Description |
|---|---|---|
sshHost | string | Defaults to the node's sshHost, then its address |
sshPort | integer | |
sshUser | string | |
password | string | |
privateKey | string | PEM private key |
passphrase | string |
Responses
POST/api/nodes/{id}/repinRe-pin the node server certificate
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
POST/api/nodes/{id}/enabledEnable/disable a node
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
enabledrequired | boolean |
Responses
200OK
PUT/api/nodes/{id}/templateAssign an outbound/route template to a node
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
templateId | integer |
Responses
200OK
PUT/api/nodes/{id}/routingSet the node's inline routing (overrides template route)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
routing | JSON | An arbitrary JSON value (a core config fragment, etc.). |
Responses
200OK
GET/api/nodes/{id}/statsPer-user traffic on a node over a window
Who spent what on this node, one row per user, summed over the window and heaviest first. The window vocabulary is GET /api/stats's. Raw bytes as recorded on the node; the node multiplier scales what a user is charged, not this figure. A user deleted since the traffic was recorded keeps its row with an empty name.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
Responses
GET/metricsPrometheus exposition
The panel's own figures in the Prometheus text exposition format, for a scrape config to pull. Deliberately outside /api, and the one authenticated route with no /api/v1 alias: a scrape config names a path, and /metrics is the one every operator and dashboard expects.
It is authenticated like everything else — stats:read, so an API token with that scope and Authorization: Bearer is what a scrape config carries. There is no unauthenticated mode: the document names every node and its address, and an endpoint that hands that out cannot be un-shipped once installs are running it. Put it behind your own proxy if you want it open.
No series carries a per-user label. That is a design rule, not an omission: one label per account on a ten-thousand-user panel is ten thousand series that never retire. The series count is bounded by the node count. "Who spent what" is GET /api/reports/top-users, which can be asked about a window instead of scraped for ever.
A figure a node did not report has no series rather than a zero one — disk, memory, load, connections, engine restarts, rejected connections (nexora_node_rejected_connections_total, one series per block outbound the node has actually sent something to, labelled by its tag) and the per-node online count are all conditional, because zero means "did not say" everywhere else in this API and a zero here draws a graph in which the disk emptied itself. The one deliberate exception is nexora_event_deliveries, which always reports every status, so an alert on dead deliveries can evaluate on a healthy panel.
Responses
200OK
GET/api/nodes/{id}/healthA node's disk, memory and load over a window, with its uptime bar
The stored readings behind the live figure on the node row, plus the uptime bar derived from them. Both come from one call because they are the same evidence read two ways: a reading is a point on the chart and proof the node was up in that interval, so two routes could disagree.
The five-minute sampler stores one reading per connected node; readings older than seven days are folded to one an hour, in place, so samples may carry either resolution and bucket says which the bar was derived at (300 or 3600). A reading whose disk and memory totals are both zero is never stored — zero means the node did not say — so an absent point is not a zero one.
uptime tiles the window exactly once with runs of equal state: up (the panel was running and the node reported), down (the panel was running and the node did not), and unknown, which is three causes that all mean "we cannot say" — the panel itself was off, the interval predates the node, or this node has never produced a reading, which is an engine too old to report host metrics rather than an outage. The panel's own uptime comes from panel_runs, a row per process, because panel.started is a notification that is only written when a subscriber wants it and is pruned after a week.
recording is false when node_health_retention_days is 0, so an empty answer reads as "switched off" rather than "no history".
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
Responses
200NodeHealthOK
400ErrorError
404ErrorError
GET/api/reports/top-usersHeaviest users across the fleet over a window
One row per user, summed over every node they were seen on, heaviest first. nodes counts the distinct nodes a user spent traffic on in the window. Read from the hourly rollups rather than the stats series, so the answer survives past the graph retention window and is unaffected by graphing being switched off. A user deleted since the traffic was recorded keeps its row with an empty name.
| Parameter | In | Type | Description |
|---|---|---|---|
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
limit | query | integer | Rows to return, heaviest first. Values above the maximum are clamped, not refused |
Responses
GET/api/reports/node-usageTraffic per node over a window
What each node moved over the window, heaviest first. The node list carries lifetime totals and current-period usage against the node's limit; this is the same figure over a window the caller chooses. Raw bytes — the multiplier scales what a user is charged, not this.
| Parameter | In | Type | Description |
|---|---|---|---|
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
limit | query | integer | Rows to return, heaviest first. Values above the maximum are clamped, not refused |
Responses
200NodeTotalsOK
400ErrorError
GET/api/statsTraffic over time for one user, inbound, outbound or endpoint
The time series behind the usage graphs, downsampled to at most 360 points. up and down are dense arrays of equal length, one entry per bucket, in bytes; the timestamp of point i is startTime + i * bucketSpan. A bucket with no traffic is a zero, not a gap — the collector runs whether or not anything is flowing.
bucketSpan is always a whole multiple of the stats_bucket_seconds setting, and the returned window is rounded outwards onto that grid, so it can be slightly wider than the one requested.
Samples are kept for stats_retention_days (default 30); setting that to 0 stops the recording and empties the table, after which every series answers with zeros. Traffic is raw bytes — a node's multiplier scales what a user is charged, not what crossed the wire.
Resellers may read their own users only; every other resource and anyone else's user answers 403.
| Parameter | In | Type | Description |
|---|---|---|---|
resourcerequired | query | "user" | "inbound" | "outbound" | "endpoint" | "node" | What the tag names |
tagrequired | query | string | The user name, the inbound/outbound/endpoint tag, or — for resource=node — the node's name. An unknown node name answers 404. |
nodeId | query | integer | Narrow to one node; 0 or absent sums every node the tag runs on |
hours | query | integer | Preset window, counted back from now. Ignored when start/end are given |
start | query | integer | Window start (unix seconds); must be given with end |
end | query | integer | Window end (unix seconds); must be given with start |
Responses
200StatsSeriesOK
400ErrorError
403ErrorError
GET/api/nodes/{id}/logsTail the node engine logs
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
since | query | integer | Cursor from the previous call |
Responses
200OK
GET/api/nodes/{id}/infoNode host + runtime metrics (cpu/mem/uptime)
GET/api/nodes/{id}/connectionsLive connections on this node, per user and inbound
Read from the node on demand: the answer is only true for the seconds somebody is looking at it, and nothing is stored. The total and the per-inbound rollup are the same figures the node reports on every heartbeat (see Node.live); the per-user rows are what this call exists for.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200NodeConnectionsOK
404ErrorError
502ErrorError
POST/api/nodes/{id}/connections/closeDrop one user's live connections on this node
The same close as POST /api/users/{id}/connections/close, reached from the node's connections drawer where the row names a user and the node is already chosen. The user is named, not numbered, because the node knows names — one deleted while still connected can still be dropped. Operator-only, like the drawer; costs the users:write scope, because it acts on a user's sessions.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
Responses
200CloseConnsResultOK
502ErrorError
POST/api/nodes/{id}/log-levelSet the node engine log level live (no restart)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
levelrequired | "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "panic" |
Responses
200OK
409The node is switched off
POST/api/nodes/{id}/check-outboundURL-test an outbound/endpoint by tag (latency check)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
tagrequired | query | string | Outbound or endpoint tag |
link | query | string | Test URL (default: generate_204) |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200OutboundCheckResultOK
502ErrorError
GET/api/nodes/{id}/route-testThe inbounds a route test can start from on this node
Read from the node's running inventory rather than from its template, so a tunnel's inbound on an origin — where a relayed user's connections arrive — is listed too. Each row carries the panel's type and remark for the tag when the panel has one. Operator-only.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200RouteTestFormOK
404ErrorError
409ErrorError
502ErrorError
POST/api/nodes/{id}/route-testHow the node would route a described connection
The node walks its own route rules for the connection, in order, and answers with every rule it tried, the one that ended the walk and the outbound or tunnel the connection goes to. Nothing is dialed and nothing is sniffed, so the sniffed protocol and domain are the caller's to supply; a sniff rule given neither is marked skipped. A node older than the route answers 501. Operator-only.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body RouteTestRequest
| Field | Type | Description |
|---|---|---|
inboundrequired | string | The inbound's tag on the node |
user | string | The user's name; empty for a connection that carries none |
network | "tcp" | "udp" | Default tcp |
source | string | The client's address, an IP with or without a port; optional |
addressrequired | string | The destination as the client asked |
portrequired | integer | |
domain | string | The sniffed domain (TLS SNI, HTTP Host); optional |
protocol | string | The sniffed protocol (tls, http, quic, bittorrent, …); optional |
Responses
POST/api/nodes/{id}/route-test/dialThe route test, then one real connection down the path it found
The same walk, followed by one TCP connection from the node through the outbound it ended at, to the destination it ended with. Nothing is sent on it. Through a tunnel or proxy the node then holds it for six seconds, longer than a far node takes to give up on a destination: a far end that could not reach it closes the connection (reported as an error); through a direct outbound the connect itself is the answer. A destination that speaks first is reported as answered; one still silent at the end of a hold through a tunnel or proxy is OK but unconfirmed, since a far end still trying looks the same. UDP, a rejected route and an outbound the router would refuse are not dialed, and the result says why. Needs nodes:write, like check-outbound.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body RouteTestRequest
| Field | Type | Description |
|---|---|---|
inboundrequired | string | The inbound's tag on the node |
user | string | The user's name; empty for a connection that carries none |
network | "tcp" | "udp" | Default tcp |
source | string | The client's address, an IP with or without a port; optional |
addressrequired | string | The destination as the client asked |
portrequired | integer | |
domain | string | The sniffed domain (TLS SNI, HTTP Host); optional |
protocol | string | The sniffed protocol (tls, http, quic, bittorrent, …); optional |
Responses
POST/api/nodes/{id}/ruleset-testWhich of the node's loaded rule sets an address falls in
Only rule sets the node has loaded can answer. With no tags, every rule set the node's route declares answers, in its order; a tag the node never loaded answers loaded false. A node older than the route answers 501. Operator-only.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body RuleSetTestRequest
| Field | Type | Description |
|---|---|---|
tags | string[] | Empty: every rule set the node's route declares |
addressrequired | string | |
port | integer |
Responses
200RuleSetTestResultOK
400ErrorError
409ErrorError
501ErrorError
502ErrorError
GET/api/nodes/{id}/certificateThe node's active managed TLS certificate (null when none)
POST/api/nodes/{id}/certificateIssue (self-signed) or obtain (ACME) the node's certificate
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body CertRequest
| Field | Type | Description |
|---|---|---|
typerequired | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs (required except for custom, where they derive from the PEM) |
challenge | "http-01" | "tls-alpn-01" | "dns-01" | ACME only |
email | string | |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 only; "" clears it |
dnsToken | string | dns-01 provider credential (multi-field split on '😂 |
certificate | string | PEM certificate chain (custom type only) |
key | string | PEM private key (custom type only) |
client | JSON | Client-side TLS template (see Certificate.client) |
Responses
200CertificateOK
502ErrorError
DELETE/api/nodes/{id}/certificateRemove the node's managed certificate (inbounds fall back to inline)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200OK
POST/api/nodes/{id}/certificate/renewReissue the node's certificate with its stored parameters
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200CertificateOK
404ErrorError
502ErrorError
PUT/api/nodes/{id}/certificate/clientUpdate the node certificate's client-side TLS template (no reissue, no resync)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
client | JSON | An arbitrary JSON value (a core config fragment, etc.). |
Responses
200CertificateOK
node-mappings
GET/api/nodes/{id}/outboundsA node's extra outbounds
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
POST/api/nodes/{id}/outboundsAdd an outbound to a node
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Outbound
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
createdAt | integer | |
updatedAt | integer |
Responses
200OutboundOK
GET/api/nodes/{id}/endpointsA node's endpoints (wireguard/tailscale/warp)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
POST/api/nodes/{id}/endpointsAdd an endpoint to a node (live, no restart)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Endpoint
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | wireguard, tailscale, openvpn-server, openconnect-server, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
useNodeCert | boolean | Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this endpoint's TLS (wins over useNodeCert) |
createdAt | integer | |
updatedAt | integer |
Responses
200EndpointOK
tunnels
GET/api/tunnelsList tunnels
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/tunnelsCreate a tunnel
Links a relay node to one or more origin nodes. Both halves of the node configuration are derived from the row at sync time and never stored, so there is nothing to regenerate: editing the tunnel, or the address of a node it touches, changes what the next sync emits.
An omitted enabled means enabled. tls and transport are completed on save — REALITY gets its key pair, plain TLS a self-signed certificate the far side pins — so a body may leave them empty and still produce a working tunnel. Two simultaneous creates of one name answer 409 to the loser; the name is unique and immutable.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Tunnel
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The config tag is tunnel- |
tag | string | The tag route rules address on the relay. |
mode | "reverse" | "direct" | reverse — the relay listens and origins dial in, so an origin behind NAT or with every port firewalled still works and users never face it. direct — the relay dials out to origins that listen. |
relayNodeId | integer | Where users connect. |
relayName | string | |
originIds | integer[] | Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to. |
originNames | string[] | |
profiles | TunnelProfile[] | The ways this tunnel is carried. Several profiles are several ways onto the same tunnel, not several tunnels: their links land in one session pool on the node under one peer identity, so a new stream takes whichever profile is least busy and a protocol that gets blocked costs throughput rather than the tunnel — while a user still sticks to the same origin, because what they are pinned to is the peer and every profile shares it. A request may instead use the older flat shape — listenPort with tls and transport beside it — which means exactly one profile and is translated. It edits the first profile rather than replacing the list, so a field it does not mention stays as it was; it cannot describe a second profile. Responses always carry profiles. |
balance | "balance" | "priority" | How a new stream chooses among the profiles that are up. |
workers | integer | Parallel links per origin for a profile that sets none of its own, each with its own congestion window. |
streamWindow | integer | Per-stream receive window in bytes; 0 takes the node default. |
enabled | boolean | |
ownsRelayFinal | boolean | Whether the relay's default outbound (route.final) is this tunnel. The panel derives it per node rather than taking it from a route, because a route lives on a template shared with nodes that do not carry the tag and the core resolves final eagerly at start — a template final naming a tunnel stops the core of every node that is not the relay. Precedence: the relay node's own routing.final wins, then this tunnel, then the template's final. |
finalReason | "disabled" | "noOrigin" | "shared" | "nodeFinal" | Why the tunnel is not the relay's default outbound, absent when it is. disabled/noOrigin — the tunnel produces no half at all. shared — the relay relays more than one tunnel, so which traffic goes down which is a choice the panel does not make; write route rules. nodeFinal — the relay carries its own final, which wins deliberately and is the supported way to keep a tunnel for selected traffic only. |
createdAt | integer | |
updatedAt | integer |
Responses
PUT/api/tunnels/{id}Update a tunnel
A partial update: the body is applied over the stored row, so a field it does not mention keeps its current value. originIds replaces the whole origin set when present. The name cannot be changed — it is the tag both halves carry and the namespace every credential is derived in — and a body carrying a different one is refused.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Tunnel
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The config tag is tunnel- |
tag | string | The tag route rules address on the relay. |
mode | "reverse" | "direct" | reverse — the relay listens and origins dial in, so an origin behind NAT or with every port firewalled still works and users never face it. direct — the relay dials out to origins that listen. |
relayNodeId | integer | Where users connect. |
relayName | string | |
originIds | integer[] | Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to. |
originNames | string[] | |
profiles | TunnelProfile[] | The ways this tunnel is carried. Several profiles are several ways onto the same tunnel, not several tunnels: their links land in one session pool on the node under one peer identity, so a new stream takes whichever profile is least busy and a protocol that gets blocked costs throughput rather than the tunnel — while a user still sticks to the same origin, because what they are pinned to is the peer and every profile shares it. A request may instead use the older flat shape — listenPort with tls and transport beside it — which means exactly one profile and is translated. It edits the first profile rather than replacing the list, so a field it does not mention stays as it was; it cannot describe a second profile. Responses always carry profiles. |
balance | "balance" | "priority" | How a new stream chooses among the profiles that are up. |
workers | integer | Parallel links per origin for a profile that sets none of its own, each with its own congestion window. |
streamWindow | integer | Per-stream receive window in bytes; 0 takes the node default. |
enabled | boolean | |
ownsRelayFinal | boolean | Whether the relay's default outbound (route.final) is this tunnel. The panel derives it per node rather than taking it from a route, because a route lives on a template shared with nodes that do not carry the tag and the core resolves final eagerly at start — a template final naming a tunnel stops the core of every node that is not the relay. Precedence: the relay node's own routing.final wins, then this tunnel, then the template's final. |
finalReason | "disabled" | "noOrigin" | "shared" | "nodeFinal" | Why the tunnel is not the relay's default outbound, absent when it is. disabled/noOrigin — the tunnel produces no half at all. shared — the relay relays more than one tunnel, so which traffic goes down which is a choice the panel does not make; write route rules. nodeFinal — the relay carries its own final, which wins deliberately and is the supported way to keep a tunnel for selected traffic only. |
createdAt | integer | |
updatedAt | integer |
Responses
DELETE/api/tunnels/{id}Delete a tunnel
Removes both halves from both nodes' configuration on the next sync.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
404ErrorError
GET/api/tunnels/{id}/statusLive tunnel health
Asks both nodes what the tunnel is actually doing. A tunnel's health cannot be read from its configuration: a half dialing into a black hole and one carrying every user's traffic have identical config hashes. Every half is asked in parallel with its own 15-second budget, so the call is bounded at about 15 seconds however many origins the tunnel has.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200TunnelStatusOK
404ErrorError
POST/api/tunnels/{id}/resetRebuild both halves of a tunnel live
Removes the tunnel's resource from every node that runs a half of it and adds it straight back, which drops the links under it and restarts the dial loop from its one-second floor. It is the one operation a tunnel needs that a config push cannot do: a wedged half — links up as far as the node is concerned but carrying nothing, or a dialer parked at the one-minute backoff ceiling behind a block that has since been lifted — is byte-for-byte the configuration the panel wants, so the diff-based sync finds nothing to do. Nothing else on the node is touched, and the documents are rebuilt from the database rather than read back from the node, so a reset also repairs a half that had drifted. Each node is asked in parallel with its own 15-second budget and reported on its own: a reset that fixed the relay and failed on one origin answers 200 with that origin marked, because one verdict for the tunnel would hide which half is still wedged.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200TunnelResetOK
404ErrorError
409ErrorThe tunnel is disabled (no half is served to reset), or its relay node no longer exists.
POST/api/tunnels/{id}/duplicateDuplicate a tunnel onto another relay
A fleet of relays reaching the same origins is a tunnel per relay, not one tunnel with several: that way a credential lifted off one relay opens only that relay, and adding a relay is a new row rather than an edit that rewrites the tunnels already carrying traffic. This is how such a row is made — the profiles, the origins, the mode and the carrying settings are copied, because they are what an operator would otherwise retype for every relay.
What is not copied is the key material: the certificate and its private key, or the REALITY key pair and short id. They are generated again for the copy, which is what keeps the relays isolated from each other. A REALITY handshake target and a server name the operator typed are choices rather than credentials and do survive.
name is required and must be free; relayNodeId defaults to the source's relay, which is only useful when the profiles are then given different ports. Validated exactly as a create is, so a port already taken on the target relay is refused rather than written.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
namerequired | string | The copy's name, which becomes its tag and the namespace its credentials derive in. |
relayNodeId | integer | The relay to put the copy on. Omitted means the source's own relay. |
Responses
POST/api/tunnels/bulkOne operation over several tunnels
What keeps a fleet of per-relay tunnels from drifting apart: the usual failure is the third relay's tunnel quietly missing the origin the other two gained. One request, one audit record and one push per node however many tunnels moved.
Every row goes through the same validation and the same save path as a single edit, so an operation cannot write something the single-row route would have refused — and it is applied per row: a tunnel the validation refuses is reported in problems while the others still apply. matched is what the ids found, affected what actually changed (a row already in the requested state counts for neither), and failed the rows refused.
The operation is named in the body rather than in the path because /api/tunnels/bulk/{op} and /api/tunnels/{id}/reset would both match /api/tunnels/bulk/reset.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
oprequired | "enable" | "disable" | "delete" | "edit" |
|
idsrequired | integer[] | The tunnels to act on. Required: a selection has to be typed, so an empty or absent list is refused rather than read as every tunnel. At most 200. |
originIds | integer[] | |
workers | integer | |
streamWindow | integer | |
balance | "balance" | "priority" |
Responses
200TunnelBulkResultOK
400ErrorError
settings
GET/api/settingsList settings (key/value)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
PUT/api/settings/{key}Create/update a setting
Panel-wide keys (web_domain, web_basepath, web_cert_file, web_key_file, web_listen_ip, web_listen_port, web_tls_mode, web_cert_id, acme_directory, web_masquerade_dir, preset_dir, sub_domain, sub_basepath, timezone, event_retention_days, node_health_retention_days, ruleset_dir, trusted_proxies, login_allowlist, and the backup_* schedule keys) are sudo-only, and for an account with two-factor authentication on they answer 428 until the second factor is presented again (POST /api/me/confirm). trusted_proxies and login_allowlist are comma- or space-separated IP addresses and CIDRs and answer 400 otherwise; web_* keys apply after a Panel restart, backup_* keys on the next hourly tick. node_health_retention_days is how many days of per-node disk, memory and load readings to keep (empty = 90, 0 = stop recording and empty the table on the next hourly pass, otherwise 1-730 or 400); inside that window the last 7 days keep their five-minute resolution and everything older is folded to one reading an hour, in place. Other keys accept any admin; among them alert_disk_percent and alert_memory_percent, the thresholds the five-minute host sampler raises node.disk_high / node.memory_high at (empty = 90, 0 = off, otherwise 1–100 or 400), alert_rejected_per_hour, the line node.rejections_high is raised at — connections one block outbound refused inside a sliding hour, read from the node's own tally on every heartbeat (empty = 100, 0 = off, otherwise a non-negative count or 400), and the two account thresholds the minute-by-minute quota enforcer raises user.quota_warning and user.expiring at: alert_user_volume_percent, a share of the account's own cap (empty = 80, 0 = off, otherwise 1–100 or 400), and alert_user_expiry_days, a comma list of days before the account's date (empty = "7,1", 0 = off, otherwise positive day counts or 400). Each warning is said once per cap or per expiry and re-arms itself when either moves; a cycle reset that drops usage back under the line re-arms the volume one. Two keys are never writable here and answer 403 for anyone: setup_token (written only by the first-run wizard) and backup_passphrase (written by PUT /api/backups/schedule, or by nexora-panel config set on a headless host) — this route echoes the saved row back, and both are bearer secrets. The same two are omitted from GET /api/settings. Several keys are validated on write and answer 400 when malformed: web_listen_ip (an IP literal, or empty to keep the address the process started with), web_listen_port (1-65535, or empty), timezone (an IANA zone name such as Asia/Tehran, or empty for the host zone) - the zone database is compiled into the panel, so no system tzdata is required - acme_directory and sub_support_url (an absolute http(s) URL, or empty), web_tls_mode (none | self_signed | files) and web_cert_id (a number, or empty). ruleset_dir must be an absolute path, or empty for the directory beside the database. The backup keys are checked here too, since this route and nexora-panel config set reach the same rows the schedule endpoint guards: backup_dir (an absolute path, or empty for the default beside the database), backup_interval_hours (a number of hours up to 8760, or empty for the daily default), backup_keep (a number of archives, 0 = keep everything) and backup_enabled / backup_include_history / backup_include_audit (true or false).
The TLS keys are also checked against each other, because a mode with nothing behind it silently serves plain HTTP: self_signed is refused unless web_cert_id already names an existing panel certificate, files is refused unless web_cert_file and web_key_file are already set, and neither may be emptied while the mode still uses it. So write a mode's inputs first, then the mode, then clear the previous mode's inputs.
web_masquerade_dir is an absolute directory served as an ordinary web site at every address on the panel's port that is not the panel or a subscription — what an anonymous visitor or a prober sees instead of a 404. Empty answers 404, as before. It answers 400 for a relative path, /, a path that does not exist and one that is not a directory: a relative path would resolve against the panel's own working directory. Directory listings and dotfiles are never served and no symlink out of the directory is followed. It never reaches a request the panel accepted, so a 401 on /api/* is still a JSON 401 — and with no web_basepath the panel itself answers at the root, leaving nothing for the decoy to cover.
preset_dir is a directory of *.json catalogue files the preset catalogue (GET /api/presets) merges onto the one this build ships, in filename order, matching by tag: a new tag is added, an existing one replaced. A file that does not parse is skipped with a line on the panel's log rather than taking the catalogue with it, and the catalogue response names in sources the files that were actually read. Relative paths resolve against the panel's working directory; empty falls back to ./presets.
A saved timezone is reported to every session in /api/me right away; the server side adopts it at the next restart. Writing web_domain or sub_domain reissues the panel's own certificate so it keeps covering every one of those names.
An entry may be a wildcard, written *.sub.example.com and nothing else (a*.x and a wildcard at the apex answer 400). It mints a different subscription hostname for every subscriber, derived from the account and stable across fetches, so one blocked hostname costs one customer rather than everybody. It needs a wildcard DNS record, and the panel answers exactly one label under it. A plain entry is unaffected.
sub_domain is a comma-separated list of up to 8 domains, stored normalised (trimmed, empty and repeated entries dropped), and answers 400 for an empty entry, a repeat, whitespace inside a name, a URL, or a malformed wildcard. Every domain in it serves /sub, and on those names the panel itself is reachable only at web_basepath — with no base path set they serve subscriptions and nothing else at all, which is also the one way this setting locks an operator out (the panel then answers on no listed name, and nexora-panel config set sub_domain "" is the way back). The first is the one generated subscription links are published on, so a blocked domain is replaced by adding its successor and moving it to the front, while links already handed out keep working on the domain that issued them until it is removed. A single value — every install that predates the list — behaves exactly as before. Every key here is also writable offline with nexora-panel config set, which is the way back in when one of them makes the panel unreachable.
| Parameter | In | Type | Description |
|---|---|---|---|
keyrequired | path | string | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
valuerequired | string |
Responses
GET/api/panel/infoPanel host + runtime metrics (the panel's own counterpart of /api/nodes/{id}/info)
GET/api/panel/updateThe release check, the install method and the last (or running) upgrade (sudo only)
Responses
200PanelUpdateOK
403ErrorError
POST/api/panel/updateUpgrade the panel to the newest release (sudo only)
Takes a backup, downloads and verifies the release, writes the new binary beside the old one, swaps and exits; systemd restarts the panel on the new build. Answers 202 immediately — follow it with GET /api/panel/update, which keeps answering after the restart because the record and the log are files.
Refused with 409 and the reason when this install cannot replace itself — a container cannot replace its own image — and when it is already on the newest release. Migrations are forward-only, so a panel that fails to come back is rolled back to the previous binary and the database stays where the new build left it; the backup taken in the first step is the way back from the schema.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
202PanelUpdateStarted
403ErrorError
409ErrorError
428ErrorError
POST/api/panel/update/checkAsk the release stream now (sudo only)
Allowed while the daily check is switched off: that setting governs what the panel does on its own, not what an operator asks for.
Responses
200PanelUpdateOK
403ErrorError
502ErrorError
POST/api/panel/update/node-cacheDownload the newest node release into this panel's cache (sudo only)
Separate from upgrading the panel. Asks the node repository for its newest release itself, then downloads and verifies the archive for each architecture the fleet runs on (amd64 and arm64 always) and replaces the cached binary and its version label. An architecture already at that release is kept, and one the release does not publish is reported in failed while the others still land. Answers 502 when nothing could be cached. The cache is what a node install uploads; without a current one the install downloads from GitHub instead.
Responses
POST/api/restartRestart the panel (sudo only)
Gracefully shuts the panel down right after responding; the process manager (systemd/compose) starts it again with the new web settings. Admin sessions are in-memory and do not survive the restart.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
GET/api/backupDownload a database backup (sudo only)
Streams a portable, logical dump of the whole panel database: one NDJSON file per table inside a gzipped tar, with a manifest.json first. It restores across both database backends, which also makes it the SQLite↔PostgreSQL migration path.
The archive carries every credential the panel holds — admin password hashes, user UUIDs, certificate private keys, the mTLS client key and each node's pinned certificate — because a dump without them restores a panel nobody can log into and that reaches no node. Treat the file as equivalent to the panel itself, and prefer encrypting it with X-Backup-Passphrase.
What is not in the archive: the config file naming the database, the subscription themes on disk, and any staged node binaries.
idempotency_records and acme_locks are always excluded: both are transient and expire. The two size levers below leave out the traffic history and the audit log; neither holds live state (a user's spent quota is on the user row), so a config-only archive is a complete restore of everything operational.
| Parameter | In | Type | Description |
|---|---|---|---|
history | query | boolean | Include the traffic time series and hourly rollups (stats, node_usages, node_client_usages). These are the bulk of a busy panel's database. |
audit | query | boolean | Include the audit log (changes). |
X-Backup-Passphrase | header | string | When set, the archive is encrypted with it (scrypt + chunked AES-256-GCM) and cannot be read without it — there is no recovery. A header rather than a query parameter, because a query string reaches browser history, proxies and access logs. |
Responses
200The archive
403ErrorError
POST/api/backup/previewValidate a backup archive without restoring it (sudo only)
Reads an uploaded archive end to end and reports what it holds. It touches no panel data and changes nothing.
Verification is against the archive's own manifest: every table it claims must be present with exactly the row count it claims, and every row must parse. That is what catches the failure that actually happens — a half-finished download or a partly written file — before it is restored rather than after.
The request body is the archive itself; there is no multipart wrapper, so nothing is spooled to a temporary file.
| Parameter | In | Type | Description |
|---|---|---|---|
X-Backup-Passphrase | header | string | Required for an encrypted archive; omitting it answers 400 with code |
Responses
200BackupPreviewOK
400BackupErrorNot a readable archive. The body carries a stable
codealongsideerror:encrypted(a passphrase is needed),passphrase(wrong passphrase, or a damaged file — AEAD cannot tell them apart), orformat(not a backup, truncated, or a row count that disagrees with the manifest).403ErrorError
413BackupErrorLarger than 1 GiB
POST/api/backup/restoreReplace the database from a backup archive (sudo only, no token may call it) — a main admin holding every permission
Stages an uploaded archive as the panel's next database and restarts to apply it.
Nothing is written into the database the panel is serving out of. The replacement is built as a separate file beside it, opened, migrated and checked for an admin who can log in; only then is a marker left asking the next start-up to swap the two files. Every failure — a truncated archive, a row that will not load, an archive with no admins — therefore costs an error and leaves the running panel untouched.
A full backup of the current database (traffic history and audit log included) is written into the backup directory first and its path returned as snapshot, so a restore is always undoable from the host it ran on. It is encrypted with the schedule's passphrase when one is set — an install that configured one did so precisely so that no plaintext archive touches its disk. Retention never deletes it.
On PostgreSQL there is no file to swap, so the safety is bought a different way: the truncate, the load, the sequence reset and the admin check all run in one transaction, and a failure at any point rolls back to the database that was already there. The restore is then already applied — staged is false — and the restart only reloads what the process holds in memory. A SQLite panel on an in-memory or URI DSN has nothing to swap and answers 501 with code unsupported, rather than reporting a success that vanishes on restart.
No API token may call this route, whatever its scopes: it rewrites every admin password, so a token that could call it could make itself permanent.
| Parameter | In | Type | Description |
|---|---|---|---|
confirmrequired | query | "replace-database" | Must be the literal |
keep_web_settings | query | boolean | Keep this installation's own address settings (web_domain, web_basepath, web_listen_ip, web_listen_port, web_tls_mode, web_cert_file, web_key_file, web_cert_id) instead of the archive's. Taking the archive's points the listener at an address this machine may not have, or demands a Host header that does not resolve here — and the panel that comes back up is the one that would have to undo it. Absent means on. |
keep_license | query | boolean | Keep this installation's licence token instead of the archive's. A licence belongs to one installation, so an archive from another machine carries one that cannot validate here. Absent means on. |
X-Backup-Passphrase | header | string | Required for an encrypted archive. |
Responses
200BackupRestoreLoaded — staged or already applied
400BackupErrorRefused, with a stable
code:confirm(the confirmation is missing or wrong),no_admins(the archive holds no admin accounts, so restoring it would leave a panel nobody can log into),encrypted,passphrase, orformat.403ErrorError
409BackupErrorA restore is already in progress (code
busy)413BackupErrorLarger than 1 GiB (code
too_large)500ErrorError
501BackupErrorThis database or runtime cannot be restored in place (code
unsupported)
GET/api/backupsList the backup archives kept on the panel host (sudo only)
Newest first, with each archive's manifest read in place so a directory of files is legible without downloading any of them. An encrypted archive is listed with encrypted: true and no manifest — that is what encrypting it means.
Files in the directory that are not this panel's own archives are ignored rather than listed: offering a restore of something the panel never wrote is worse than not showing it.
Responses
200BackupListOK
403ErrorError
500ErrorError
POST/api/backupsTake a backup onto the panel host now (sudo only)
Writes an archive into the backup directory rather than through the caller's browser, which is what an operator wants before a risky change.
The file is written under a temporary name and renamed once complete, so a backup interrupted half way — the process killed, the volume full — never appears in the listing as something restorable.
| Parameter | In | Type | Description |
|---|---|---|---|
history | query | boolean | Include the traffic time series and hourly rollups. |
audit | query | boolean | Include the audit log. |
X-Backup-Passphrase | header | string | Encrypts this archive. Omitted, the schedule's own passphrase is used, so the directory is never half encrypted. |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200BackupFileOK
403ErrorError
500ErrorError
GET/api/backups/scheduleThe scheduled-backup configuration (sudo only)
passphraseSet reports whether scheduled archives are encrypted; the passphrase itself is never returned. nextDue is derived from the newest archive on disk rather than stored, so a panel that was switched off does not come back holding a stale appointment.
Responses
200BackupScheduleOK
403ErrorError
500ErrorError
PUT/api/backups/scheduleSave the scheduled-backup configuration (sudo only)
The passphrase is write-only. An omitted or empty passphrase leaves the stored one alone — a form that saved what it was shown, and it is never shown one, would otherwise silently stop encrypting every archive from then on. Clearing it is the separate clearPassphrase flag.
Retention is applied on the hourly tick whether the schedule is on or off and whether or not a backup was due, because archives also arrive from POST /api/backups. Pre-restore snapshots are never pruned.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body BackupScheduleUpdate
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
intervalHours | integer | 0 takes the default (24h): enabled with no interval is a half-filled form, not a request for nothing. Capped at a year. Null or absent leaves the stored value alone — a cleared number box must not silently rewrite the schedule. |
dir | string | Absolute path, or empty for the default beside the database. A relative path is refused: it would resolve against the panel process's working directory. Null or absent leaves the stored directory alone. |
keep | integer | 0 keeps everything, and is stored as 0 — not as the default. Null or absent leaves the stored value alone, which is how a cleared box differs from a deliberate 0. |
includeHistory | boolean | |
includeAudit | boolean | |
passphrase | string | Empty leaves the stored one alone |
clearPassphrase | boolean | Removes the stored passphrase, so scheduled archives stop being encrypted |
Responses
200BackupScheduleOK
400ErrorAn unreadable body, a negative
intervalHoursorkeep, a relativedir, an interval above a year, orpassphrasesent together withclearPassphrase— which of the two was meant is not something to guess at.403ErrorError
500ErrorError
GET/api/backups/{name}Download one stored archive (sudo only)
Answers Range requests, so a failed download of a large archive resumes rather than starting again.
| Parameter | In | Type | Description |
|---|---|---|---|
namerequired | path | string | The archive's file name, exactly as the listing gives it. Any other name is refused — it never addressed a file. |
Responses
200The archive
400BackupErrorNot a backup file name (code
format)403ErrorError
404ErrorError
500ErrorError
DELETE/api/backups/{name}Delete one stored archive (sudo only)
| Parameter | In | Type | Description |
|---|---|---|---|
namerequired | path | string | The archive's file name, exactly as the listing gives it. Any other name is refused — it never addressed a file. |
Responses
200OK
400BackupErrorNot a backup file name (code
format)403ErrorError
404ErrorError
500ErrorError
POST/api/backups/{name}/previewValidate a stored archive without restoring it (sudo only)
POST /api/backup/preview for an archive already on the host: the server opens its own file, so checking a stored backup is not a download of the whole credential store followed by an upload of it. Same response, same warnings, same X-Backup-Passphrase header for an encrypted one.
| Parameter | In | Type | Description |
|---|---|---|---|
namerequired | path | string | |
X-Backup-Passphrase | header | string |
Responses
200BackupPreviewOK
400BackupErrorNot a readable archive
403ErrorError
404ErrorError
500ErrorError
POST/api/backups/{name}/restoreReplace the database from a stored archive (sudo only, no token may call it) — a main admin holding every permission
POST /api/backup/restore for an archive already on the host. Same confirmation, same guards, same restart, same refusal to every API token — only the source differs.
| Parameter | In | Type | Description |
|---|---|---|---|
namerequired | path | string | |
confirmrequired | query | "replace-database" | |
keep_web_settings | query | boolean | |
keep_license | query | boolean | |
X-Backup-Passphrase | header | string |
Responses
200BackupRestoreStaged or applied; the panel is restarting
400BackupErrorRefused:
confirm,no_admins,encrypted,passphraseorformat403ErrorError
404ErrorError
409BackupErrorA restore is already in progress (code
busy)500ErrorError
501BackupErrorThis database or runtime cannot be restored in place (code
unsupported)
POST/api/settings/incy-key/refreshFetch the INCY deep-link key from INCY and store it
INCY clients import a subscription from an encrypted deep link (incy://crypt1/…) sealed with one AES-256 key every client carries. The panel ships no key: this downloads INCY's published link encoder (the Go port of github.com/INCY-DEV/incy-link-encoder), derives the key the way the encoder does, checks it against the fingerprint the same source pins, and stores it as sub_incy_key together with the scheme name as sub_incy_scheme. From then on every subscription page renders the encrypted INCY link instead of incy://add/. Nothing is written when the download or the fingerprint check fails (502). Operators only; the same keys can be set by hand with PUT /api/settings/{key}.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200The key that was stored
502ErrorError
POST/api/settings/v2raytun-key/refreshFetch the V2RayTun deep-link key from V2RayTun and store it
V2RayTun clients import a subscription from an encrypted deep link (v2raytun://crypt/…) sealed with V2RayTun's RSA-4096 public key — only the app holds the private half. The panel ships no key: this downloads V2RayTun's published crypto-link documentation, takes the public key and the link's scheme segment from it, checks the key parses as an RSA key of a usable size, and stores them as sub_v2raytun_key and sub_v2raytun_scheme. From then on every subscription page renders the encrypted V2RayTun link instead of v2raytun://import/. Nothing is written when the download or the key check fails (502). Operators only; the same keys can be set by hand with PUT /api/settings/{key}.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200The key that was stored
502ErrorError
GET/api/sub-themesSubscription page themes installed on disk
Responses
200OK
webhooks
GET/api/eventsThe event catalog
Every event the panel raises, with its family (the part before the dot). A subscriber's events filter is checked against this list.
Responses
200OK
GET/api/webhooksThe event subscribers (sudo)
Where events go. Each subscriber receives every event its events filter names (empty = all), narrowed by adminId to the events about that account when set. Deliveries are signed (X-Nexora-Signature: t=<unix>,v1=<hex>, the hex being HMAC-SHA256(secret, "X-Nexora-Event and X-Nexora-Delivery (the id a receiver dedups on: a retry repeats it), and are retried with exponential backoff (30 s doubling to 1 h) up to 10 attempts. A subscriber is switched off only once it has been failing for a day and at least 50 attempts — a receiver down for an hour keeps its backlog and receives it on return. The body is {v, id, event, time, data, recipients?}. Receivers should refuse a signature timestamp more than five minutes off.
Responses
200OK
POST/api/webhooksAdd a subscriber (sudo)
The response is the one time the signing secret is shown; one is generated when the body carries none. kind: addon is a subscriber that belongs to an addon's API token — it is what allows a loopback or private address, where an addon runs. An addon registers one through its own token (tokenId left out); the main admin makes one by hand for the operator's own addon by naming, in tokenId, an API token that carries an addon id. A token without an addon id may not make one (400). A manual subscriber (the default) takes no tokenId and is refused those addresses at dial time, so a URL alone cannot point the panel at its own network.
A caller narrower than every scope — an API token with scopes, or a login on a narrowed role — may not make a subscriber that hears an event its scopes do not reach: such an event in events, or an empty events list on a manual subscriber (every event), is 400. Naming a tokenId is for a login holding every scope; any other login is 403.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body EventSubscriberRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
urlrequired | string | Absolute http(s) URL; no credentials in it |
kind | "manual" | "addon" | Create only; addon needs an API-token request or a tokenId |
tokenId | integer | Create only, kind addon from a session: the addon's API token (one carrying an addon id). An addon calling with its own token leaves it out |
events | string[] | Names from GET /api/events; empty = every event |
adminId | integer | 0 = every event; otherwise only the events about this account |
enabled | boolean | Update only |
secret | string | Create only; generated when empty |
Responses
200EventSubscriberCreated;
secretis present in this response only400ErrorError
403ErrorError
PUT/api/webhooks/{id}Update a subscriber (sudo)
Name, URL, filter, account and the enabled switch. Re-enabling resets the failure count. The secret is not editable here; see the rotate route. A subscriber an addon holds (managedBy) is changed on the Addons page, and here takes only its switch, from a login (an API token is 409) (every other field sent as it is — a breaker that switched it off after an outage is undone this way; switching on a suspended addon's is 409); delete and rotate are 409. An addon's subscriber that is not the caller's own — another API token's, or any to a login narrower than every scope — is 403 here, on delete and on rotate. So is a manual subscriber whose stored filter hears an event the caller's scopes do not reach (an empty filter is every event), for a caller narrower than every scope; it is also 403 on redeliver. The event rules of the create apply: from a caller narrower than every scope, an event its scopes do not reach, or an empty list on a manual subscriber, is 400. On an addon subscriber only the events an edit adds are held to its token's grant (400 past it, or when the token has expired or its account is gone); events it already lists are kept, since delivery filters them. Changing the URL drops every delivery still pending or retrying: it is marked dead rather than sent to the new address.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body EventSubscriberRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
urlrequired | string | Absolute http(s) URL; no credentials in it |
kind | "manual" | "addon" | Create only; addon needs an API-token request or a tokenId |
tokenId | integer | Create only, kind addon from a session: the addon's API token (one carrying an addon id). An addon calling with its own token leaves it out |
events | string[] | Names from GET /api/events; empty = every event |
adminId | integer | 0 = every event; otherwise only the events about this account |
enabled | boolean | Update only |
secret | string | Create only; generated when empty |
Responses
200EventSubscriberUpdated
400ErrorError
403ErrorError
404ErrorError
409ErrorError
DELETE/api/webhooks/{id}Remove a subscriber and its delivery log (sudo)
An addon's subscriber that is not the caller's own, or a manual one hearing an event the caller's scopes do not reach, is 403, as on update.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
POST/api/webhooks/{id}/secretRotate the signing secret (sudo)
The new secret is in this response and nowhere else afterwards; the old one stops working with the next delivery. An addon's subscriber that is not the caller's own, or a manual one hearing an event the caller's scopes do not reach, is 403, as on update.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
idrequired | path | integer |
Responses
200EventSubscriberRotated
403ErrorError
404ErrorError
409ErrorError
POST/api/webhooks/{id}/pingSend a test event now (sudo)
Delivers a panel.ping to this subscriber synchronously and answers with the delivery as recorded — the receiver's status code, or the reason the panel refused to connect. Works on a disabled subscriber, which is how one is tested before being switched on.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200EventDeliveryThe delivery
404ErrorError
GET/api/webhooks/{id}/deliveriesA subscriber's delivery log, newest first (sudo)
Bounded by event_retention_days (default 7).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
limit | query | integer |
Responses
200OK
404ErrorError
POST/api/webhooks/{id}/deliveries/{did}/redeliverQueue a delivery again (sudo)
Resets its attempt count and sends it once more under the same delivery id, so the receiver can tell a redelivery from a new event. From a caller narrower than every scope, a delivery carrying an event its scopes do not reach, or any delivery of a manual subscriber whose filter hears such an event, is 403.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
didrequired | path | integer |
Responses
services
GET/api/servicesThe panel services (sudo)
What the panel does outward for the operator — Telegram and email for admins, backup destinations, OIDC login — each off until switched on. Lists every service this build has, configured or not; one never configured is off with nothing set and no run. A secret setting is reported only as set, never its value.
Responses
200OK
PUT/api/services/{key}Configure a service, switch it on or off (sudo)
A setting left out of config is unchanged; one sent as "" is cleared, which is the only way to remove a secret. A setting the service does not own is refused, and so is switching a service on (or keeping it on) while a required setting is empty. The audit record names the settings that changed, never their values.
| Parameter | In | Type | Description |
|---|---|---|---|
keyrequired | path | string | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body PanelServiceUpdate
| Field | Type | Description |
|---|---|---|
enabled | boolean | Left out = unchanged |
config | object | Settings to change; "" clears one |
Responses
200PanelServiceThe service as it now stands
400ErrorError
404ErrorError
POST/api/services/backup_telegram/documentSend a file to the backup chat
Forwards one file of the caller's own — an addon's backup, say — to the Telegram chat the main admin chose for the panel's backups, through the panel's bot. The file is streamed straight to Telegram and never stored by the panel; encrypt it first, since the panel cannot look inside. It goes only to that chat, never to one the caller names. The caption names the sender (the addon, or the token or account), the file and its size.
The body is the file itself (application/octet-stream) with a Content-Length; name is the file name the chat shows. At most 50 MB (the Telegram Bot API's cap), and 6 files an hour per caller. backup_telegram must be on — otherwise 409, and the caller sends the file another way. Scope backup:deliver. An Idempotency-Key is ignored here: the body is not buffered, and a second send is a second copy in the chat.
| Parameter | In | Type | Description |
|---|---|---|---|
namerequired | query | string | The file's name, without a path. |
Responses
POST/api/services/{key}/testTest a service with its stored settings (sudo)
Runs the service's own test (a test message, a dial of the storage, the issuer's metadata) against what is stored, not against an unsaved form, and records it as the service's last run. A service that is switched off can be tested; one with no test answers 400.
| Parameter | In | Type | Description |
|---|---|---|---|
keyrequired | path | string |
Responses
GET/api/me/telegramThe caller's own Telegram chats (session only)
Any account — operator or reseller — pairs its own chat here, as long as the main admin has the Telegram service on (available). A chat hears what its account may read. Closed to API tokens.
Responses
200OK
GET/api/me/telegram/eventsThe events the caller's chat can be given (session only)
| Parameter | In | Type | Description |
|---|---|---|---|
lang | query | "en" | "fa" | "ru" | "zh" |
Responses
200As GET /api/services/telegram/events for the caller's account
POST/api/me/telegram/pairIssue a pairing code for the caller's own account (session only)
As POST /api/services/telegram/pair with adminId fixed to the caller; 409 while the service is off.
Request body
| Field | Type | Description |
|---|---|---|
lang | "en" | "fa" | "ru" | "zh" | |
events | string[] |
Responses
200TelegramPairingThe code
409ErrorError
GET/api/me/telegram/pair/{code}Where the caller's own pairing code stands (session only)
PUT/api/me/telegram/chats/{id}Narrow, switch or re-language one of the caller's chats (session only)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body
| Field | Type | Description |
|---|---|---|
events | string[] | |
enabled | boolean | |
lang | "en" | "fa" | "ru" | "zh" |
Responses
200TelegramChatOK
404ErrorError
DELETE/api/me/telegram/chats/{id}Unpair one of the caller's chats (session only)
GET/api/me/telegram/chats/{id}/deliveriesThe delivery log of one of the caller's chats, newest first (session only)
As GET /api/webhooks/{id}/deliveries — every notice sent to the chat, its status, attempts and the channel's answer; bounded by event_retention_days. Another account's chat reads as 404.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
limit | query | integer |
Responses
200OK
404ErrorError
POST/api/me/telegram/chats/{id}/deliveries/{did}/redeliverSend one of the chat's notices again (session only)
As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count. Another account's chat reads as 404.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
didrequired | path | integer |
Responses
200Queued
404ErrorError
POST/api/services/telegram/pairIssue a code that pairs a Telegram chat to an account (sudo)
Checks the bot (getMe) and answers a one-time code and a t.me link that sends it. Whoever opens the link, or sends the code to the bot, pairs their chat to the account for ten minutes. The panel reads the bot's updates while the service is on (the chat commands /status, /user and /node) and, while it is off, only while a code is waiting. The chat then hears the events that account's role could read, and a reseller's chat only those about its own users. adminId defaults to the caller; lang (en, fa, ru, zh) is the language its messages are written in. A bot with a webhook set, or read by another program, cannot be polled and the code reports that error.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
adminId | integer | |
lang | "en" | "fa" | "ru" | "zh" | |
events | string[] | The chat's filter from the start; empty = every event the account may read, including ones a later release adds |
Responses
200TelegramPairingThe code
400ErrorError
403ErrorError
502ErrorError
GET/api/services/telegram/pair/{code}Where a pairing code stands (sudo)
GET/api/services/telegram/eventsThe events a chat can be given, for the pickers (sudo)
The event catalog grouped by family, each titled in lang (the page's language; default en). With adminId, allowed marks the events that account's role can read — any other is never delivered to its chat, whatever the chat's filter says.
| Parameter | In | Type | Description |
|---|---|---|---|
lang | query | "en" | "fa" | "ru" | "zh" | |
adminId | query | integer |
Responses
200OK
400ErrorError
GET/api/services/telegram/chatsThe paired Telegram chats (sudo)
Responses
200OK
PUT/api/services/telegram/chats/{id}Narrow what a chat hears, switch it, or change its language (sudo)
The account and the chat are fixed; pair again to move a chat. Re-enabling resets the failure count.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
events | string[] | Names from GET /api/events; empty = every event the account may read |
enabled | boolean | |
lang | "en" | "fa" | "ru" | "zh" |
Responses
200TelegramChatOK
400ErrorError
404ErrorError
DELETE/api/services/telegram/chats/{id}Unpair a chat (sudo)
Removes the chat with its subscriber and delivery log.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
404ErrorError
POST/api/services/telegram/chats/{id}/testSend the test message to one chat now (sudo)
A panel.ping through the ordinary delivery path, answered with the delivery as recorded.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200EventDeliveryThe delivery
404ErrorError
GET/api/services/telegram/chats/{id}/deliveriesA chat's delivery log, newest first (sudo)
As GET /api/webhooks/{id}/deliveries — every notice sent to the chat, its status, attempts and the channel's answer; bounded by event_retention_days.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
limit | query | integer |
Responses
200OK
404ErrorError
POST/api/services/telegram/chats/{id}/deliveries/{did}/redeliverSend one of the chat's notices again (sudo)
As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
didrequired | path | integer |
Responses
200Queued
404ErrorError
GET/api/services/email/eventsThe events an address can be given, for the pickers (sudo)
As GET /api/services/telegram/events — the same catalog, titles and allowed marking.
| Parameter | In | Type | Description |
|---|---|---|---|
lang | query | "en" | "fa" | "ru" | "zh" | |
adminId | query | integer |
Responses
200As GET /api/services/telegram/events
400ErrorError
GET/api/services/email/recipientsThe confirmed email addresses (sudo)
Responses
200OK
POST/api/services/email/recipientsMail a confirmation code to an address for an account (sudo)
Sends a six-digit code to address through the stored SMTP settings (the service may still be off — this is how they are proven) and answers the pending confirmation. The address is added only when the code comes back through …/recipients/confirm, within ten minutes and five tries. It then hears the events that account's role could read, a reseller's only those about its own users. adminId defaults to the caller; lang is the language its mail is written in. One code per account every thirty seconds (429 with Retry-After); a server that refuses the mail is a 502 carrying its answer.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
adminId | integer | |
addressrequired | string | |
lang | "en" | "fa" | "ru" | "zh" | |
events | string[] | The address's filter from the start; empty = every event the account may read, including ones a later release adds |
Responses
200EmailPendingThe code was sent
400ErrorError
403ErrorError
429ErrorError
502ErrorError
POST/api/services/email/recipients/confirmConfirm an address with the code mailed to it (sudo)
A wrong code is a 400 naming the tries left; the fifth wrong one drops the code. An unknown or expired id is a 404. The same address confirmed again for the same account replaces the old one.
Request body
| Field | Type | Description |
|---|---|---|
idrequired | string | From the pending confirmation |
coderequired | string |
Responses
200EmailRecipientThe address
400ErrorError
404ErrorError
PUT/api/services/email/recipients/{id}Narrow what an address hears, switch it, or change its language (sudo)
The address and the account are fixed; confirm another address to change either. Re-enabling resets the failure count.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body EmailRecipientUpdate
| Field | Type | Description |
|---|---|---|
events | string[] | Names from GET /api/events; empty = every event the account may read |
enabled | boolean | |
lang | "en" | "fa" | "ru" | "zh" |
Responses
200EmailRecipientOK
400ErrorError
404ErrorError
DELETE/api/services/email/recipients/{id}Remove an address (sudo)
Removes the address with its subscriber and delivery log.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200OK
404ErrorError
POST/api/services/email/recipients/{id}/testMail the test notice to one address now (sudo)
A panel.ping through the ordinary delivery path, answered with the delivery as recorded; its status code is the SMTP server's reply.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200EventDeliveryThe delivery
404ErrorError
GET/api/services/email/recipients/{id}/deliveriesAn address's delivery log, newest first (sudo)
As GET /api/webhooks/{id}/deliveries — every notice sent to the address, its status, attempts and the channel's answer; bounded by event_retention_days.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
limit | query | integer |
Responses
200OK
404ErrorError
POST/api/services/email/recipients/{id}/deliveries/{did}/redeliverSend one of the address's notices again (sudo)
As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
didrequired | path | integer |
Responses
200Queued
404ErrorError
GET/api/me/emailThe caller's own email addresses (session only)
Any account — operator or reseller — adds its own address here, as long as the main admin has the email service on (available). An address hears what its account may read. Closed to API tokens.
Responses
200OK
GET/api/me/email/eventsThe events the caller's address can be given (session only)
| Parameter | In | Type | Description |
|---|---|---|---|
lang | query | "en" | "fa" | "ru" | "zh" |
Responses
200As GET /api/services/telegram/events for the caller's account
POST/api/me/email/recipientsMail a confirmation code to the caller's own address (session only)
As POST /api/services/email/recipients with adminId fixed to the caller; 409 while the service is off.
Request body
| Field | Type | Description |
|---|---|---|
addressrequired | string | |
lang | "en" | "fa" | "ru" | "zh" | |
events | string[] |
Responses
200EmailPendingThe code was sent
400ErrorError
409ErrorError
429ErrorError
502ErrorError
POST/api/me/email/recipients/confirmConfirm the caller's own address (session only)
As POST /api/services/email/recipients/confirm; another account's pending address reads as 404.
Request body
| Field | Type | Description |
|---|---|---|
idrequired | string | |
coderequired | string |
Responses
200EmailRecipientThe address
400ErrorError
404ErrorError
PUT/api/me/email/recipients/{id}Narrow, switch or re-language one of the caller's addresses (session only)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Request body EmailRecipientUpdate
| Field | Type | Description |
|---|---|---|
events | string[] | Names from GET /api/events; empty = every event the account may read |
enabled | boolean | |
lang | "en" | "fa" | "ru" | "zh" |
Responses
200EmailRecipientOK
404ErrorError
DELETE/api/me/email/recipients/{id}Remove one of the caller's addresses (session only)
GET/api/me/email/recipients/{id}/deliveriesThe delivery log of one of the caller's addresses, newest first (session only)
As GET /api/webhooks/{id}/deliveries — every notice sent to the address, its status, attempts and the channel's answer; bounded by event_retention_days. Another account's address reads as 404.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
limit | query | integer |
Responses
200OK
404ErrorError
POST/api/me/email/recipients/{id}/deliveries/{did}/redeliverSend one of the address's notices again (session only)
As POST /api/webhooks/{id}/deliveries/{did}/redeliver — queued again with a fresh attempt count. Another account's address reads as 404.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
didrequired | path | integer |
Responses
200Queued
404ErrorError
GET/api/services/oidc/presetsThe kinds of sign-in provider and the settings each takes (a main admin's login; an API token is 403)
google, microsoft, github, gitlab, keycloak, authentik, okta, auth0, oidc (any OpenID Connect provider by its issuer) and oauth2 (any OAuth 2.0 provider by its addresses), in the picker's order. A setting with a default takes it when left empty.
Responses
200OK
403ErrorError
GET/api/services/oidc/providersThe sign-in providers (a main admin's login; an API token is 403)
Responses
200OK
403ErrorError
POST/api/services/oidc/providersAdd a sign-in provider (a main admin's login; an API token is 403)
kind is one of the presets and fixes which settings config may carry; a setting the kind does not have is refused, and so is switching the provider on while a required one is empty. At most 20.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body SSOProviderWrite
| Field | Type | Description |
|---|---|---|
kind | string | Create only |
name | string | |
enabled | boolean | |
sortOrder | integer | |
config | object |
Responses
200SSOProviderOK
400ErrorError
403ErrorError
PUT/api/services/oidc/providers/{id}Change a sign-in provider (a main admin's login; an API token is 403)
A setting left out of config is unchanged; "" clears it. The kind cannot change. A change to who the provider says people are — its client id, its issuer (resolved, so a trailing slash or a typed default changes nothing), or for a plain OAuth 2.0 provider any of its three addresses or its subject field — removes every link made through it, in the same write; each account links again with its password.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body SSOProviderWrite
| Field | Type | Description |
|---|---|---|
kind | string | Create only |
name | string | |
enabled | boolean | |
sortOrder | integer | |
config | object |
Responses
200SSOProviderOK
400ErrorError
403ErrorError
404ErrorError
DELETE/api/services/oidc/providers/{id}Remove a sign-in provider and the identities linked through it (a main admin's login; an API token is 403)
POST/api/services/oidc/providers/{id}/testCheck a provider with its saved settings (a main admin's login; an API token is 403)
GET/api/services/oidc/linksEvery linked identity, with its account (a main admin's login; an API token is 403)
Responses
200OK
403ErrorError
addons
GET/api/addonsThe registered addons (sudo; no token) — a main admin holding every permission
Every addon the panel works with — separate software it reaches over the network only — with its address, entry link, health path and its two grants: the API token and the event subscription it holds. A signed addon's grants are what its manifest declares; the operator's own addon's are chosen on this page. Either is locked on the tokens and webhooks pages. Every addon route is closed to API tokens: an addon registering addons would be a token minting tokens.
Responses
200OK
403ErrorError
POST/api/addonsRegister the operator's own addon by hand (sudo; no token) — a main admin holding every permission
The form for an addon without a signed manifest. token present creates an API token (bound to the panel owner, carrying the addon id; empty scopes = unrestricted within the owner's role); webhook present creates an addon subscription (empty events = every event). The response carries credentials — the token and the signing secret — once. Plain http is refused for an address that is not on this host or a private network.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body AddonManual
| Field | Type | Description |
|---|---|---|
namerequired | string | |
slug | string | The addon id; derived from the name when empty. Fixed once registered. |
baseUrlrequired | string | |
entryUrl | string | A path under baseUrl or an absolute URL |
healthPath | string | |
token | object | |
webhook | object |
Responses
POST/api/addons/previewRead and verify a signed addon's manifest (sudo; no token) — a main admin holding every permission
Fetches /.well-known/nexora-addon.json under the address, checks its shape, its api version (0 and 1) and its signature against the keys the panel trusts — Nexora's, and the developer keys the addon directory vouches for on that slug — and answers what the consent screen shows, with the addon's directory tier. Nothing is created. 502 when the addon does not answer or its manifest is refused; 409 when an addon with its id is already registered.
Request body
| Field | Type | Description |
|---|---|---|
urlrequired | string |
Responses
200AddonConsentOK
400ErrorError
409ErrorError
502ErrorError
GET/api/addons/directoryThe addon directory as this panel last read it (sudo; no token) — a main admin holding every permission
The signed index of addons.nexora-panel.org, or of the mirror named by the addon_directory_url setting, read once a day beside the release check (and on the same update_check switch) and refused unless the directory key signed it. Each listing comes with its manifest read the way registration reads one, whether this panel meets its requires.panel, who signed it as this panel sees it, and — for an addon registered here — whether the directory lists a newer version. updates counts those. The developer keys a listing vouches for are trusted for that slug when a manifest is verified.
Responses
200AddonDirectoryOK
POST/api/addons/directory/checkRead the addon directory now (sudo; no token) — a main admin holding every permission
Allowed while the daily read is off. 502 when the directory does not answer, its index is not signed by the directory key (a mirror that altered it), or it is older than the one held; the index held stays either way, and the reason is the view's error.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200AddonDirectoryOK
502ErrorError
GET/api/addons/directory/{slug}How a listed addon is installed (sudo; no token) — a main admin holding every permission
The install wizard's source: the listing, the questions its manifest asks (with their labels in every language), the ways it can be installed by a command (script and docker through its install.sh, compose when it ships only a compose file; none before a release), the panel address the addon is told to call, and whether it will register by the claim code the panel issues (claim) or be added by hand (manual, no signature this panel trusts).
| Parameter | In | Type | Description |
|---|---|---|---|
slugrequired | path | string |
Responses
200AddonInstallPlanOK
404ErrorError
GET/api/addons/installsInstalls waiting for their addon to answer (sudo; no token) — a main admin holding every permission
Expired ones (a week) are dropped. The claim code is never read back.
Responses
200OK
POST/api/addons/installsStart installing a listed addon by hand (sudo; no token) — a main admin holding every permission
Checks the answers as the addon kit does (an option hidden by its when is skipped, a blank one takes its default, a required one with neither is refused, every value must be of its type and on one line), issues a claim code, and returns the command to run on the addon's host — the install script with --opt flags, or a compose file and its .env — carrying the answers, the panel address and the code. The command is shown once: a secret answer is in it and nowhere else. One install waits per addon; starting another replaces it. 409 when the addon is already registered, needs a newer panel, or has nothing to install from.
When the manifest asks a path option, the addon's base path is appended to baseUrl and the addon is registered at that address: the answer, "/" for the root asked for on purpose, or — when the option is shown and left blank — twelve random lowercase letters and digits, passed to the addon with the other answers and shown in the install's baseUrl. A baseUrl that already carries a path must carry the same one; another path answers 400.
With ssh the panel installs it on that host itself and answers the job instead of a command: official and verified addons only (403 otherwise), as a service or with Docker (400 otherwise); with ssh.local, on the panel's own server with no SSH (403 when the panel cannot: in a container, or not as root). The compose method writes the answers and nothing else to its .env, so nothing publishes port 80 for an addon's acme-http (the form does not offer it there). Running a script as root on a host is a confirmed route: a session with two-factor authentication whose second factor is older than ten minutes answers 428 until it is presented again (POST /api/me/confirm). The job fails before anything runs on the host when the release's manifest at its tag is not the one the directory lists, and, for a service install, when the release publishes no SHA256SUMS, does not list the binary, or the binary does not match it. A certificate chosen for it is confirmed alike: its claim code fetches the certificate's key.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
slugrequired | string | |
method | "script" | "docker" | "compose" | Empty = the first the plan offers |
baseUrlrequired | string | Where the panel will reach the addon once it runs (plain http only on a private address) |
panelUrl | string | Where the addon reaches the panel; empty = the panel as this request reached it |
answers | object | By option key, a JSON value of the option's type |
ssh | AddonSSH | How the panel reaches an addon's host. Credentials are used for the connection and never stored. |
certificateId | integer | A panel certificate the addon fetches by its claim code, then by its token, and serves. For an addon not on this server only one issued by dns-01, a self-signed or an uploaded one, and never the panel's own HTTPS certificate (400 otherwise) |
Responses
GET/api/addons/jobs/{job}An addon install, update or removal over SSH, and its log after cursor (sudo; no token)
| Parameter | In | Type | Description |
|---|---|---|---|
jobrequired | path | string | |
cursor | query | integer | Lines already read; the answer's cursor is the next one |
Responses
200OK
404ErrorError
POST/api/addons/jobs/{job}/cancelStop a running addon job (sudo; no token) — what it already did on the host stays done
GET/api/addons/hostsHosts the panel installed an addon on over SSH (sudo; no token)
Responses
200OK
POST/api/addons/hosts/{slug}/updateUpdate an addon on its host to the directory's latest release (sudo; no token)
Runs the release's install.sh again in place (its configuration and data are kept), then reads the addon's new manifest at once — applied, or held for approval when it asks for more. Credentials may be left out when the panel's key is authorised on the host. A confirmed route: 428 to a session whose second factor is older than ten minutes. The job fails before anything runs on the host when the release's manifest at its tag is not the one the directory lists (the tag was moved since the directory read it), and, for a service install, when the release publishes no SHA256SUMS, does not list the binary, or the binary does not match it. An addon installed on the panel's own server is updated there with nothing asked; one installed over SSH on this very machine may move to that (local, when the host's mayMoveLocal). 409 when the panel cannot run it here (in a container, or not as root) or the host is not this server.
| Parameter | In | Type | Description |
|---|---|---|---|
slugrequired | path | string | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body AddonSSH
| Field | Type | Description |
|---|---|---|
sshHost | string | |
sshPort | integer | |
sshUser | string | Default root; another user escalates with sudo |
password | string | |
privateKey | string | |
passphrase | string | |
installKey | boolean | Authorise the panel's key on the host so updating and removing it need no password |
source | "upload" | "host" | upload (default): the panel brings the release binary, checked against SHA256SUMS; host: the install script fetches it |
local | boolean | Install on the panel's own server: the panel runs the install itself, with no SSH; the other fields are unused. On an update, moves an addon installed over SSH on this very machine to a local install |
Responses
POST/api/addons/hosts/{slug}/uninstallRemove an addon from its host (sudo; no token)
Runs its install.sh — from the release it was installed at — with --uninstall, and --purge to delete its data too, then takes the panel's key out of the login's authorized_keys when the panel authorised it there and no other addon or node on that machine (by any of its names or addresses) at that login uses it, or might — a lookup that fails keeps the key. A confirmed route (428 to a stale second factor). The job fails before anything runs when the manifest at that release is not the listed one (for the listed release) or not signed by a key trusted for the addon (for an older one). Its registration stays until removed on the Addons page. One installed on the panel's own server is removed there with nothing asked (409 when the panel cannot run it here).
| Parameter | In | Type | Description |
|---|---|---|---|
slugrequired | path | string | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
Responses
DELETE/api/addons/installs/{id}Cancel a waiting install (sudo; no token) — its claim code stops being honoured
POST/api/addons/installs/{id}/checkWhether the installed addon answers yet (sudo; no token) — a main admin holding every permission
Reads the manifest at the install's address (signed, and the same addon) and asks its health path. Ready carries the consent screen. 404 when the install expired or was cancelled.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200AddonInstallCheckOK
404ErrorError
POST/api/addons/installs/{id}/registerRegister an installed addon with the code the panel issued (sudo; no token) — a main admin holding every permission
The claim-code registration of POST /api/addons/register, with the address and the code the install holds; digest is the reviewed manifest's, from the check's consent. 409 for an addon with no signature this panel trusts (it is added by hand).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
digestrequired | string | |
certSha256 | string | The consent's certSha256, when it named one: the addon's own certificate, trusted with the approval |
Responses
POST/api/addons/registerRegister a signed addon by its claim code (sudo; no token) — a main admin holding every permission
The manifest is fetched again and must hash to digest (from the preview the main admin approved; 409 otherwise). The grants it declares are created and POSTed to its setup path with the claim code; an addon that does not accept them (a wrong code) leaves nothing behind, and its answer is the 502's message.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
urlrequired | string | |
claimCoderequired | string | |
digestrequired | string | |
certSha256 | string | The preview's certSha256, when it named one: the addon's own certificate, trusted with the approval |
Responses
PUT/api/addons/{id}Edit the operator's own addon (sudo; no token) — a main admin holding every permission
The hand-made form again. A grant switched on is created (its secret in credentials, once), one switched off is deleted, one changed is edited in place. The addon id cannot change. A signed addon is 409: its grants are what its manifest declares. An address on another host drops the certificate of its own trusted at the old one (pinnedCertSha256), judges local again by the new address, and is 400 when the certificate chosen for it does not fit there.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body AddonManual
| Field | Type | Description |
|---|---|---|
namerequired | string | |
slug | string | The addon id; derived from the name when empty. Fixed once registered. |
baseUrlrequired | string | |
entryUrl | string | A path under baseUrl or an absolute URL |
healthPath | string | |
token | object | |
webhook | object |
Responses
DELETE/api/addons/{id}Remove an addon (sudo; no token) — a main admin holding every permission
The addon's webhook is sent panel.addon_removed first, whatever its filter; then its token, its subscription and its row are deleted. The panel keeps nothing else of an addon.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Removed
404ErrorError
PUT/api/addons/{id}/entrySet the link to an addon's interface (sudo; no token) — a main admin holding every permission
The address the panel reaches an addon at is often one no browser can open (a Docker name, a private address), so the main admin gives the link. A path is joined to the addon's address; empty goes back to the manifest's ui for a signed addon. For a signed addon it overrides the manifest and survives its updates.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
entryUrlrequired | string |
Responses
PUT/api/addons/{id}/certificateChoose the certificate an addon fetches from the panel (sudo; no token) — a main admin holding every permission
The addon fetches it with its token (GET /api/addons/self/certificate) and serves its public address with it, so it needs no ACME of its own. For an addon not on this server only a certificate issued by dns-01, a self-signed or an uploaded one fits: http-01 and tls-alpn-01 prove the panel's server, and the panel's own HTTPS certificate stays on this server. null clears it. A confirmed route: the addon fetches the certificate's key (428 for a second factor older than ten minutes).
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
certificateIdrequired | integer |
Responses
GET/api/addons/{id}/certificate/servedThe certificate an addon serves, and whether the panel trusts it (sudo; no token) — a main admin holding every permission
Only the TLS handshake at the addon's address is tried (any answer will do; an addon added by hand may serve no signed manifest), and local says whether it runs on the panel's own server. Trusted when a CA, a certificate from the panel or one the main admin pinned vouches for it; otherwise sha256 names the certificate it serves now — after it renewed one it made itself, say — to trust with PUT …/certificate/pin.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
200What it serves
404ErrorError
PUT/api/addons/{id}/certificate/pinTrust the certificate an addon serves now (sudo; no token) — a main admin holding every permission
sha256 must be the certificate the addon serves at this moment, one the panel does not trust otherwise (409 when it is another, or trusted already). Empty forgets the pin.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
sha256required | string |
Responses
GET/api/addons/self/certificateThe calling addon's certificate (an addon's token)
The PEM pair of the certificate the main admin chose for the addon whose token calls, and when it ends. Every addon token reaches it, whatever its scopes; a login, a token of no registered addon or of a suspended one is 403. The ETag is the leaf's SHA-256: sent back in If-None-Match, an unchanged certificate is 304. An addon fetches it on its own schedule; a renewal in the panel reaches it on its next fetch. 409 when the certificate is not issued yet, or no longer fits the addon — it became the panel's own HTTPS certificate, or one an http-01 or tls-alpn-01 challenge proved, for an addon not on this server.
| Parameter | In | Type | Description |
|---|---|---|---|
If-None-Match | header | string |
Responses
200AddonCertificateOK
304Unchanged
403ErrorError
404ErrorError
409ErrorError
POST/api/addons/self/certificate/claimAn install's certificate, by its claim code (public)
Before it registers, an addon holding the claim code of a waiting install fetches the certificate chosen for it: the panel reads its manifest over https at the registration, so it serves the certificate first. Public, and limited on its own: wrong codes for an install that waits lock the address out of this route for a while (429) — not out of the login, and with no ban; a right code always answers (80 random bits). A slug with no install waiting counts nothing. 403 for an address already banned; 409 as for GET /api/addons/self/certificate. The waiting install, and so its code, is gone once the addon registers.
Request body
| Field | Type | Description |
|---|---|---|
slugrequired | string | |
claimCoderequired | string |
Responses
POST/api/addons/{id}/suspendSuspend an addon (sudo; no token) — a main admin holding every permission
POST/api/addons/{id}/resumeResume a suspended addon (sudo; no token) — a main admin holding every permission
The token authenticates again and the subscription is switched back on with a clean failure count. An addon whose unhealthy alert is still open comes back unhealthy, and its next healthy answer raises panel.addon_recovered; any other comes back registered with a clean health count.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
POST/api/addons/{id}/checkRead a signed addon's manifest now (sudo; no token) — a main admin holding every permission
What the hourly check does, on demand. result is same, applied (a newer manifest asking for nothing more than the addon holds was applied) or pending (it asks for more and waits for approval). 409 for an addon registered by hand; 502 when the manifest cannot be read or is refused.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer |
Responses
POST/api/addons/{id}/approveApprove a signed addon's waiting update (sudo; no token) — a main admin holding every permission
The manifest is read again and must hash to digest (from pending.digest); its scopes and events then replace the addon's. 409 when nothing waits, when the manifest changed since, or when the update asks for a token or a webhook the addon was never given — those credentials could only be handed over with a claim code, so the addon is removed and registered again.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
digestrequired | string |
Responses
install
GET/api/mtls/certificateThe Panel mTLS client certificate (PEM)
Responses
200OK
POST/api/tokens/node-installCreate a single-use node-install token (sudo only)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
desc | string | |
expiry | integer |
Responses
200TokenOK
GET/install-node.shThe node installer script
Responses
200Shell script
GET/install/caFetch the Panel client CA PEM (guarded by a single-use token)
GET/install/node-binaryDownload the node binary for an arch
| Parameter | In | Type | Description |
|---|---|---|---|
arch | query | "amd64" | "arm64" |
Responses
200Binary
subscription
GET/sub/{token}A user's subscription content (public, token in path)
Returns the client configuration in the format the request asks for (?format=, or detected from the User-Agent, or the sub_fallback_format setting). A browser navigation — an Accept header listing text/html with no known client User-Agent — gets the server-rendered subscription page instead, unless sub_page_disabled is set. Every response carries Subscription-Userinfo, Profile-Title, Profile-Update-Interval and Profile-Web-Page-Url.
A client that identifies its device with an x-hwid header (with x-device-os, x-ver-os and x-device-model beside it) is recorded under the user's devices. With a deviceLimit set, a device the user has not got room for is refused with 403 — page and body alike, since the page carries the links too — and a client sending no id is served unless the device_limit_strict setting is on.
| Parameter | In | Type | Description |
|---|---|---|---|
tokenrequired | path | string |
Responses
GET/sub/{token}/pageThe subscription page for a user (public, token in path)
Renders the active theme regardless of the Accept header — how an operator previews a theme.
| Parameter | In | Type | Description |
|---|---|---|---|
tokenrequired | path | string |
Responses
200HTML page
404ErrorError
GET/sub/{token}/infoThe subscription page's view model as JSON (public, token in path)
The same object a theme is rendered with, minus the configurations themselves (links are omitted and config/app bodies are blanked), so a page can poll for live usage and online state without reloading.
| Parameter | In | Type | Description |
|---|---|---|---|
tokenrequired | path | string |
Responses
200SubscriptionInfoOK
404ErrorError
GET/sub/{token}/_assets/{path}A static file of the active subscription theme (public, token in path)
| Parameter | In | Type | Description |
|---|---|---|---|
tokenrequired | path | string | |
pathrequired | path | string |
Responses
200The file
404ErrorError
tools
POST/api/tools/ech-keypairGenerate an ECH keypair (server "ECH KEYS" PEM + client "ECH CONFIGS" PEM) for a public name
POST/api/tools/link-name-previewRender a link-name template against sample entries
What sub_link_name_template would name three kinds of entry: an ordinary one on a node, a domain-front one (which carries no node, so {NODE} is empty there), and an endpoint (no route, no transport).
Rendered by the same code the subscription uses rather than mirrored in the client, because the rules are not guessable from the field: an unknown variable is kept verbatim so a typo is visible, and an empty one takes the separator it stranded with it. problem is what a save of this template would be refused with, and is empty when it would be accepted.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
template | string | Empty previews the fixed format that predates the setting |
Responses
200OK
400ErrorError
POST/api/tools/reality-keypairGenerate a REALITY x25519 keypair and short id (or derive the public key of a supplied private key)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
privateKey | string |
Responses
200OK
400ErrorError
POST/api/tools/vless-encryptionMint a VLESS Encryption server string (the inbound's `decryption`), or derive the client string of one
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
authentication | "x25519" | "mlkem768" | Which key authenticates the server: X25519 (not post-quantum) or ML-KEM-768 (post-quantum). Read only when decryption is empty. |
mode | "native" | "xorpub" | "random" | The xor mode of the layer. Read only when decryption is empty. |
seconds | integer | How long a session ticket lets a client reconnect in 0-RTT; 0 for never, 65535 at most (the node's conservative cap on the lifetime it tells clients in two bytes). Read only when decryption is empty. |
decryption | string | An existing server string, to derive its client string from |
Responses
200OK
400ErrorError
meta
GET/api/openapi.jsonThis OpenAPI document (JSON)
Responses
200OpenAPI 3 document
GET/api/openapi.yamlThis OpenAPI document (YAML)
Responses
200OpenAPI 3 document
setup
GET/api/setupWhether the first-run setup wizard should run
Public by necessity: on a panel with no admin account there is nobody to authenticate, and this is how the SPA learns to show the wizard instead of the login page. A configured panel answers required: false and nothing else.
While setup is pending the panel answers 404 to every route, this one included, unless the request carries the setup token in t — the link the installer prints. A configured panel no longer gates it, and then t is ignored.
| Parameter | In | Type | Description |
|---|---|---|---|
t | query | string | The one-time setup token ( |
Responses
200SetupStateSetup state
404Setup is pending and the request carried no valid
ttoken — the same answer every other route gives until the panel is configured.
POST/api/setupComplete the first-run setup
Creates the main admin, records every mandatory setting and issues the panel's own certificate — all in one transaction, so a rejected field leaves the panel untouched and still in setup. Authorised by the one-time token the installer printed (nexora-panel setup-token reprints it); the token is retired when setup completes, and the route answers 409 from then on. The web settings are read at startup, so the panel restarts itself and the response says where it will answer.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body SetupRequest
| Field | Type | Description |
|---|---|---|
tokenrequired | string | The one-time setup token printed by the installer. |
usernamerequired | string | |
passwordrequired | string | At least 12 characters mixing three of lowercase, uppercase, digits and symbols. |
listenIp | string | An IP literal, or empty to bind every interface. |
listenPortrequired | string | |
basepath | string | URI prefix the panel is served under; empty serves at the root. |
subBasepath | string | Path subscription links are built on; must differ from basepath. |
tlsModerequired | "self_signed" | "none" | self_signed makes the panel issue and renew its own certificate; none is plain HTTP, chosen deliberately. |
certSans | string[] | IP addresses the generated certificate should name, taken verbatim. A hostname here is a 400: names belong in domain/subDomain, which the panel adds automatically and keeps the certificate in step with afterwards. Empty falls back to the address the wizard was reached at plus this machine's publicly routable addresses. |
domain | string | |
subDomain | string | |
timezone | string |
Responses
POST/api/setup/backup/previewValidate a backup archive during first-run setup
The same inspection as POST /api/backup/preview, minus the node-identity comparisons (mtls_changed, node_pin_changed): an unconfigured panel has no nodes to compare the archive against. It is reachable while the panel has no account to authenticate against — which is exactly the state a server migration starts in.
Authorised by the setup token in t. Without it the request never reaches the handler: the same gate that closes every route on an unconfigured panel answers 404. The handler compares the token again in constant time (403) and answers 409 once setup is complete.
| Parameter | In | Type | Description |
|---|---|---|---|
trequired | query | string | The setup token the installer printed. |
X-Backup-Passphrase | header | string |
Responses
200BackupPreviewOK
400BackupErrorNot a readable archive
403ErrorError
404Missing or wrong
twhile the panel is unconfigured: the setup gate closes the route before the handler sees the request. (The 403 below is the handler's own second comparison, which the gate normally reaches first.)409Setup is complete; this route is closed and the authenticated one takes over
413BackupErrorLarger than 1 GiB (code
too_large)500ErrorError
POST/api/setup/backup/restoreRestore a backup onto an unconfigured panel
Moving a panel to a new server means a fresh install with no admin — the state that closes every authenticated route — so a restore reachable only after logging in would be unreachable exactly when it is wanted. This is that route, held to the same rule as the wizard itself: nothing without the setup token.
The two guards the authenticated restore defaults to are deliberately off here. Both preserve what this install already decided, and this install has decided nothing: keeping its address settings would write empty rows over the archive's real ones, and keeping its licence would keep no licence at all. The archive is the configuration.
The address that comes back is therefore the old server's. That is recoverable by design: -listen / NEXORA_WEB_LISTEN pin the bind address over any setting, and the panel falls back to the address it was started with when the configured one refuses to bind.
| Parameter | In | Type | Description |
|---|---|---|---|
trequired | query | string | The setup token the installer printed. |
confirmrequired | query | "replace-database" | |
X-Backup-Passphrase | header | string |
Responses
200BackupRestoreRestored; the panel is restarting
400BackupErrorRefused:
confirm,no_admins,encrypted,passphraseorformat403ErrorError
404Missing or wrong
twhile the panel is unconfigured: the setup gate closes the route before the handler sees the request. (The 403 below is the handler's own second comparison, which the gate normally reaches first.)409BackupErrorSetup is complete (plain
Error), or a restore is already in progress (codebusy, aBackupError)413BackupErrorLarger than 1 GiB (code
too_large)500ErrorError
501BackupErrorThis database or runtime cannot be restored in place (code
unsupported)
pools
GET/api/outboundsList pool outbounds (selectable by templates)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/outboundsCreate a pool outbound
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Outbound
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
createdAt | integer | |
updatedAt | integer |
Responses
200OutboundOK
GET/api/endpointsList pool endpoints (selectable by templates)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/endpointsCreate a pool endpoint
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Endpoint
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | wireguard, tailscale, openvpn-server, openconnect-server, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
useNodeCert | boolean | Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this endpoint's TLS (wins over useNodeCert) |
createdAt | integer | |
updatedAt | integer |
Responses
200EndpointOK
GET/api/presetsThe preset catalogue
The shelf the panel answers "which rule set, and from where?" off, served rather than bundled into the interface so an operator can add to it without waiting for a release. It is the built-in catalogue with every *.json file in preset_dir merged onto it in filename order, matching by tag — a new tag is added, an existing one replaced, which is how an install re-points an entry at a mirror where the upstream is blocked. A file that does not parse is skipped rather than taking the catalogue with it, and sources names the files that were actually read, so a catalogue that ignored one is visible instead of silent.
Labels are not translated here: a built-in entry carries labelKey, an i18n key the interface resolves, and an operator's own carries label, shown as written. The panel does not know the caller's locale, and a catalogue that had to be translated could not be edited by the person installing it.
Responses
200PresetCatalogueOK
GET/api/rule-setsList rule sets (remote lists the panel mirrors, selectable by templates)
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
updated_since | query | integer | Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens). |
Responses
200OK
POST/api/rule-setsCreate a rule set
The panel downloads the list before the rule set exists: one it could not fetch and recognise is not created at all, so a rule can never be written against a tag that was never going to reach a node.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body RuleSet
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation. |
url | string | Absolute http(s) upstream the panel downloads from. Never sent to a node. |
updateIntervalHours | integer | How often the panel re-downloads from upstream. A refresh that changed the bytes pushes them to the nodes carrying the list. |
format | "binary" | "source" | Detected from the downloaded bytes, never chosen |
available | boolean | The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations. |
size | integer | |
ruleCount | integer | |
contentHash | string | sha256 of the mirrored bytes; a node already holding them is not pushed them again |
lastFetchedAt | integer | Unix seconds; 0 = never downloaded |
lastAttemptAt | integer | |
lastError | string | Why the last refresh did not land. The previous copy is kept. |
createdAt | integer | |
updatedAt | integer |
Responses
PUT/api/rule-sets/{id}Update a rule set
The tag is fixed once any rule matches on it: renaming would leave those rules naming a rule set that no longer exists, which the node refuses its whole config for.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body RuleSet
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation. |
url | string | Absolute http(s) upstream the panel downloads from. Never sent to a node. |
updateIntervalHours | integer | How often the panel re-downloads from upstream. A refresh that changed the bytes pushes them to the nodes carrying the list. |
format | "binary" | "source" | Detected from the downloaded bytes, never chosen |
available | boolean | The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations. |
size | integer | |
ruleCount | integer | |
contentHash | string | sha256 of the mirrored bytes; a node already holding them is not pushed them again |
lastFetchedAt | integer | Unix seconds; 0 = never downloaded |
lastAttemptAt | integer | |
lastError | string | Why the last refresh did not land. The previous copy is kept. |
createdAt | integer | |
updatedAt | integer |
Responses
DELETE/api/rule-sets/{id}Delete a rule set
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
409ErrorStill referenced by a route or DNS rule
POST/api/rule-sets/{id}/refreshRe-download a rule set from its upstream now
A failed refresh keeps the previous copy and records the error: an upstream being briefly down must not remove a list — and the rules naming it — from a whole fleet's routing.
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
PUT/api/outbounds/{id}Update a pool outbound
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Outbound
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
createdAt | integer | |
updatedAt | integer |
Responses
200OutboundOK
DELETE/api/outbounds/{id}Delete an outbound (pool or per-node)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
PUT/api/endpoints/{id}Update a pool endpoint
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body Endpoint
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | wireguard, tailscale, openvpn-server, openconnect-server, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
useNodeCert | boolean | Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this endpoint's TLS (wins over useNodeCert) |
createdAt | integer | |
updatedAt | integer |
Responses
200EndpointOK
DELETE/api/endpoints/{id}Delete an endpoint (pool or per-node)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Responses
200Deleted
pool
GET/api/frontsThe domain-front catalogue
CDN routes (Cloudflare, Fastly) in front of inbounds. A front hangs off the inbound, not a node: the CDN picks its origin by SNI, so one front covers however many nodes serve that inbound.
It renders one entry per inbound, never one per node — node addresses and fronts add, they never multiply. Links are rendered per request, so an edit is live on the next subscription fetch. The one thing a node is told is clientIpHeader: when a save or a delete changes which headers an XHTTP inbound trusts (the header itself, enabling, disabling or deleting a front that has one, attaching or detaching it), that inbound is re-sent to every node serving it, which restarts it there and drops its live connections. Any other edit sends nothing.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given. |
offset | query | integer | Rows to skip before the page. |
q | query | string | Case-insensitive substring search over the resource's text columns. |
Responses
200OK
POST/api/frontsAdd a domain front
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body DomainFrontInput
| Field | Type | Description |
|---|---|---|
labelrequired | string | |
addressrequired | string | |
port | integer | |
sni | string | |
hostHeader | string | |
alpn | JSON | An arbitrary JSON value (a core config fragment, etc.). |
fingerprint | string | |
allowInsecure | boolean | |
clientIpHeader | string | The request header this CDN writes the client address into (CF-Connecting-IP for Cloudflare); see DomainFront. An RFC 9110 header name, anything else is a 400. Like the other text fields, absent or empty on an edit clears it. |
originNodeId | integer | |
enabled | boolean | Absent leaves it unchanged on edit, and defaults to true on create |
sort | integer | |
inboundIds | integer[] | Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound. |
Responses
201DomainFrontCreated
400ErrorInvalid, or an inbound named here cannot sit behind a CDN — REALITY, QUIC (hysteria/hysteria2/tuic), port hopping, or a transport a CDN cannot forward
409ErrorThe catalogue is full, or an inbound already carries the maximum number of fronts
PUT/api/fronts/{id}Edit a domain front
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | integer | |
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body DomainFrontInput
| Field | Type | Description |
|---|---|---|
labelrequired | string | |
addressrequired | string | |
port | integer | |
sni | string | |
hostHeader | string | |
alpn | JSON | An arbitrary JSON value (a core config fragment, etc.). |
fingerprint | string | |
allowInsecure | boolean | |
clientIpHeader | string | The request header this CDN writes the client address into (CF-Connecting-IP for Cloudflare); see DomainFront. An RFC 9110 header name, anything else is a 400. Like the other text fields, absent or empty on an edit clears it. |
originNodeId | integer | |
enabled | boolean | Absent leaves it unchanged on edit, and defaults to true on create |
sort | integer | |
inboundIds | integer[] | Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound. |
Responses
200DomainFrontOK
400ErrorError
404ErrorError
409ErrorError
DELETE/api/fronts/{id}Remove a domain front
security
GET/api/bansThe login ban list (sudo)
Every ban in force, newest first. Two sources: auto — written the moment the login limiter locks an address out, so the lockout survives a restart, and escalating with each repetition inside a week (10 minutes, an hour, a day, a week) — and manual. A ban refuses the routes reachable without a session (login, its second step, the setup submission) with 403 and nothing else. Addresses in the login_allowlist setting are never locked out or banned. Whether an address is the client's or the proxy's is decided by the trusted_proxies setting; without it, behind a proxy, every visitor is one address.
Responses
200OK
POST/api/bansBan an address or a prefix (sudo)
Refused (400) when the prefix contains the address the request itself comes from.
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
Request body
| Field | Type | Description |
|---|---|---|
prefixrequired | string | An IP address or a CIDR |
reason | string | |
hours | integer | Lifetime in hours; 0 or absent = permanent |
Responses
DELETE/api/bans/{id}Lift a ban (sudo)
| Parameter | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Replay-safe retry key; see the Idempotency note in the API description. |
idrequired | path | integer |
Responses
200Lifted
404ErrorError
Schemas
Addon
| Field | Type | Description |
|---|---|---|
idrequired | integer | |
slugrequired | string | The addon id: its token's addon |
namerequired | string | |
version | string | |
publisher | string | |
baseUrlrequired | string | |
entryUrl | string | The link to the addon's own interface; empty for none |
healthPath | string | Polled when set; empty for no check |
signedrequired | boolean | |
signedBy | string | Who signed the manifest: Nexora, development key, or developer key of <the directory listing's developer> — never a developer key's bare name |
signedByKind | "nexora" | "developer" | "development" | "" | The kind of key that signed it, read from the key itself; empty on an addon registered before the panel recorded it, until its manifest is next read |
staterequired | "registered" | "suspended" | "unhealthy" | |
healthAt | integer | When the health path was last asked; 0 = never |
healthFailures | integer | Failed asks in a row; two make the addon unhealthy |
lastError | string | The last failed ask, cleared by the next 200 |
manifestAt | integer | When a signed addon's manifest was last read for an update |
pending | object | A signed addon's newer manifest that asks for more than it holds — only the additions |
tokenId | integer | |
subscriberId | integer | |
certificateId | integer | The panel certificate the addon fetches and serves its public address with; null when it gets its own or serves none |
local | boolean | It runs on the panel's own server, as its install judged it (installed here, or the SSH host or its address is this machine), kept only when it registered at the address its install was made for and until its address moves to another host; when false it is judged by its address, as one from before is |
pinnedCertSha256 | string | The addon's own certificate the main admin trusted (hex SHA-256); empty for none |
registeredAt | integer | |
updatedAt | integer | |
purposes | object | A signed addon's reason for each scope |
tokenrequired | object | |
webhookrequired | object | |
credentials | object | The token and the webhook signing secret, in the response that created them only |
AddonCertificate
| Field | Type | Description |
|---|---|---|
certificaterequired | string | PEM chain |
keyrequired | string | PEM private key |
notAfterrequired | integer | |
sha256required | string | Hex SHA-256 of the leaf's DER; also the ETag |
AddonConsent
| Field | Type | Description |
|---|---|---|
baseUrlrequired | string | |
digestrequired | string | Sent back by the registration, which refuses a manifest that changed since |
slugrequired | string | |
namerequired | string | |
versionrequired | string | |
publisherrequired | string | |
signedByrequired | string | Who signed the manifest: Nexora, development key, or developer key of <the directory listing's developer> |
signedByKindrequired | "nexora" | "developer" | "development" | The kind of key that signed it, read from the key itself, never from its name |
tierrequired | "official" | "verified" | "unofficial" | "" | The addon's tier in the addon directory; empty when the directory does not list it |
scopesrequired | object[] | |
rateLimitrequired | integer | Requests a minute the token may make: the manifest's rateLimit (at most 600), else the panel's default, 120. |
eventsrequired | object[] | |
webhookUrlrequired | string | |
setupUrlrequired | string | |
healthUrlrequired | string | |
entryUrlrequired | string | |
certSha256 | string | The addon's certificate (hex SHA-256 of the leaf) when neither a CA nor the panel vouches for it — one it made itself; approving trusts it. Absent otherwise. |
certNotAfter | integer | When that certificate ends (Unix seconds) |
AddonData
One addon's document on one user or admin.
| Field | Type | Description |
|---|---|---|
userId | integer | Present on a user row |
adminId | integer | Present on an admin row |
addonIdrequired | string | |
datarequired | object | Exactly the object the addon last wrote; {} when never written |
versionrequired | integer | Number of writes so far; 0 when never written. Send it back on PUT for a conditional write. |
updatedAtrequired | integer | Unix seconds of the last write, 0 when never written |
AddonDataRequest
| Field | Type | Description |
|---|---|---|
datarequired | object | The whole document; at most 16 KiB |
version | integer | The version last read. Absent = unconditional write. |
AddonDirectory
| Field | Type | Description |
|---|---|---|
urlrequired | string | Where the index is read from |
sourcerequired | string | Where the index held was read from; differs from url after the address changed and the new one has not passed yet |
defaultrequired | boolean | url is addons.nexora-panel.org's, not a mirror |
checkEnabledrequired | boolean | The update_check setting, which the daily read follows |
siterequired | string | The directory's own address, from the signed index; empty before the first read |
generatedAtrequired | integer | Unix seconds the index held was built |
fetchedAtrequired | integer | Unix seconds of the last read that passed; 0 = never |
checkedAtrequired | integer | Unix seconds of the last attempt |
error | string | Why the last attempt failed |
updatesrequired | integer | How many registered addons the directory lists a newer version of |
addonsrequired | AddonDirectoryEntry[] |
AddonDirectoryEntry
| Field | Type | Description |
|---|---|---|
slugrequired | string | |
tierrequired | "official" | "verified" | "unofficial" | |
reporequired | string | The GitHub repository (owner/name) |
developerrequired | string | |
featuredrequired | boolean | |
namerequired | string | |
versionrequired | string | |
publisherrequired | string | |
description | object | By language: en, fa, ru, zh |
licenserequired | string | |
paidrequired | boolean | |
docsrequired | string | |
requiresPanelrequired | string | The manifest's requires.panel, e.g. >=0.1.0 |
compatiblerequired | boolean | This panel meets requiresPanel |
scopesrequired | object[] | |
eventsrequired | string[] | |
dockerrequired | boolean | The addon installs as a container |
platformsrequired | string[] | Platforms it ships a binary for (linux-amd64, …) |
optionsrequired | integer | How many questions its install asks |
installScriptrequired | boolean | Its repository ships install.sh |
release | object | |
stalerequired | boolean | The directory's latest read of it failed; this is its last good manifest |
error | string | |
signedByrequired | "nexora" | "developer" | "development" | "unknown" | "none" | Who signed its manifest, as this panel sees it |
pagerequired | string | The addon's page on the directory |
registered | object | The addon registered here under this slug, if any |
AddonHost
| Field | Type | Description |
|---|---|---|
idrequired | integer | |
slugrequired | string | |
kindrequired | "ssh" | "local" | ssh: host, port and user name the login; local: the panel's own server |
mayMoveLocalrequired | boolean | An SSH login on the panel's own server, where local installs are possible: an update may move it to a local install |
hostrequired | string | |
portrequired | integer | |
userrequired | string | |
keyInstalledrequired | boolean | |
methodrequired | string | |
versionrequired | string | |
installedAtrequired | integer | |
updatedAtrequired | integer |
AddonInstall
| Field | Type | Description |
|---|---|---|
idrequired | integer | |
slugrequired | string | |
namerequired | string | |
versionrequired | string | |
methodrequired | "script" | "docker" | "compose" | |
baseUrlrequired | string | |
signedrequired | boolean | Registers by the issued claim code; false = added by hand |
createdAtrequired | integer | |
expiresAtrequired | integer | |
checkedAtrequired | integer | |
lastErrorrequired | string | Why the last check found it not ready |
certificateId | integer | The panel certificate chosen for it |
local | boolean | It is to run on the panel's own server; carried to the addon by its registration |
AddonInstallCheck
| Field | Type | Description |
|---|---|---|
readyrequired | boolean | |
reason | string | |
consent | AddonConsent |
AddonInstallCreated
| Field | Type | Description |
|---|---|---|
installrequired | AddonInstall | |
command | string | Run on the addon's host as root; shown once |
secretrequired | boolean | The command carries a secret answer |
registrationrequired | "claim" | "manual" | |
job | AddonJob |
AddonInstallOption
| Field | Type | Description |
|---|---|---|
keyrequired | string | |
typerequired | "string" | "secret" | "password" | "number" | "port" | "bool" | "choice" | "url" | "path" | password: a secret held to the addon sign-in's rule, 10 characters to 72 bytes |
labelrequired | object | |
help | object | |
default | object | A JSON value of the option's type |
requiredrequired | boolean | |
choices | string[] | |
when | object | Shown only while that earlier option has that value |
envrequired | string | The environment variable the answer reaches the addon in |
AddonInstallPlan
| Field | Type | Description |
|---|---|---|
entryrequired | AddonDirectoryEntry | |
optionsrequired | AddonInstallOption[] | |
methodsrequired | "script" | "docker" | "compose"[] | |
panelUrlrequired | string | |
registrationrequired | "claim" | "manual" | |
automatic | boolean | The panel may install it over SSH: official or verified, signed, with install.sh and a release |
automaticReason | string | Why not |
local | boolean | The panel may also install it on its own server with no SSH: automatic, and the panel runs on Linux directly (not in a container) as root |
localReason | string | Why not on its own server, when it may not |
AddonJob
| Field | Type | Description |
|---|---|---|
id | string | |
addon | string | |
action | "install" | "update" | "uninstall" | |
status | "running" | "done" | "error" | |
message | string | |
started | integer | |
finished | integer |
AddonManual
| Field | Type | Description |
|---|---|---|
namerequired | string | |
slug | string | The addon id; derived from the name when empty. Fixed once registered. |
baseUrlrequired | string | |
entryUrl | string | A path under baseUrl or an absolute URL |
healthPath | string | |
token | object | |
webhook | object |
AddonRef
The addon a token or a subscription belongs to; such a grant is changed on the Addons page.
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
signed | boolean |
AddonSSH
How the panel reaches an addon's host. Credentials are used for the connection and never stored.
| Field | Type | Description |
|---|---|---|
sshHost | string | |
sshPort | integer | |
sshUser | string | Default root; another user escalates with sudo |
password | string | |
privateKey | string | |
passphrase | string | |
installKey | boolean | Authorise the panel's key on the host so updating and removing it need no password |
source | "upload" | "host" | upload (default): the panel brings the release binary, checked against SHA256SUMS; host: the install script fetches it |
local | boolean | Install on the panel's own server: the panel runs the install itself, with no SSH; the other fields are unused. On an update, moves an addon installed over SSH on this very machine to a local install |
Admin
| Field | Type | Description |
|---|---|---|
id | integer | |
username | string | |
role | string | The role this account carries, by name: one of the three built-ins, or a custom role. An account cannot be put on a role granting more than the caller holds itself (403). |
volume | integer | Reseller allowance in bytes, 0 = unlimited (ignored for other roles) |
period | ResalePeriod | How often a reseller's volume allowance resets ("" = never). |
periodDay | integer | Day the allowance resets on (0-6 weekly, day-of-month monthly/yearly) |
expiry | integer | Reseller account expiry, unix seconds, 0 = never |
userLimit | integer | Max users the reseller may create, 0 = unlimited |
usersReadOnly | boolean | Reseller: refuse PUT and DELETE on its users. Creating is unaffected. Phrased as a restriction so an absent field restricts nothing. |
plansOnly | boolean | Reseller: creating a user requires a planId from planIds and the plan's limits are applied server-side; an edit cannot change a limit at all. Requires at least one entry in planIds (400 otherwise). |
planIds | integer[] | Plans this reseller may sell (only meaningful with plansOnly). Absent on update leaves the selection alone. |
subDomain | string | Reseller: which of the configured sub_domain entries this reseller's customers are published under (empty = the first). Answers 400 for a domain that is not configured. Every admin route is main-admin-only, which is what keeps a reseller from moving its own customers onto another reseller's domain. An assignment naming a domain later removed from the setting falls back to the first. |
used | integer | Reseller: traffic its users burned in the current period |
users | integer | Reseller: how many users it owns |
lastLogin | integer | |
lastLoginIp | string | Address of the most recent successful login; the next login from elsewhere is recorded as admin.login_new_ip |
totpEnabledAt | integer | Unix seconds two-factor authentication was turned on; 0 = off. The seed itself is never exposed. |
isOwner | boolean | The panel's single owner: cannot be deleted, cannot be moved off the main-admin tier, and is the only account that may hand ownership over (POST /api/admins/{id}/owner). Exactly one account carries it. |
createdAt | integer | |
updatedAt | integer |
AdminRole
The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees.
AdminSession
One login of an admin account. The token itself is never returned or stored — only its hash is.
| Field | Type | Description |
|---|---|---|
id | integer | |
adminId | integer | |
ip | string | Address the login came from, as the panel saw it |
userAgent | string | Browser or client that logged in; capped, control characters stripped |
remember | boolean | A "remember me" login (30-day sliding lifetime instead of 24 hours) |
createdAt | integer | |
lastSeenAt | integer | Last activity, refreshed at most once a minute |
expiresAt | integer | When the session lapses if unused; slides with lastSeenAt |
current | boolean | The session behind the request that produced this listing |
ApiToken
A third-party API token. The plaintext token appears only in the create response; every other read omits it.
| Field | Type | Description |
|---|---|---|
id | integer | |
token | string | Plaintext, returned once by POST /api/tokens |
desc | string | |
role | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
adminId | integer | Operator the token acts as; 0 = a legacy unbound token acting on the whole panel |
owner | string | Username of that operator |
scopes | string[] | Empty = unrestricted within the role |
addon | string | Addon id the token belongs to; empty for a token that is not an addon |
rateLimit | integer | Requests per minute, 0 = panel default |
expiry | integer | Unix seconds, 0 = never |
lastUsedAt | integer | Unix seconds of the last authenticated request, 0 = never used |
lastUsedIp | string | |
createdAt | integer | |
managedBy | AddonRef | The addon a token or a subscription belongs to; such a grant is changed on the Addons page. |
BackupError
| Field | Type | Description |
|---|---|---|
error | string | |
code | "encrypted" | "passphrase" | "format" | "too_large" | "confirm" | "busy" | "no_admins" | "unsupported" |
BackupFile
One archive in the panel host's backup directory.
| Field | Type | Description |
|---|---|---|
name | string | File name; the identifier every other route on this collection takes |
size | integer | |
created_at | integer | Unix seconds, read from the timestamp in the file name — not the modification time, which changes when the directory is copied, and not the manifest, which an encrypted archive does not show. Every route reports the same value for the same file. |
encrypted | boolean | |
kind | "backup" | "pre-restore" |
|
manifest | BackupManifest | The archive's own account of itself, written as its first entry. |
BackupList
| Field | Type | Description |
|---|---|---|
dir | string | Where the archives live on the panel host |
files | BackupFile[] | Newest first |
total | integer | |
bytes | integer | Total size of the directory's archives |
schedule | BackupSchedule |
BackupManifest
The archive's own account of itself, written as its first entry.
| Field | Type | Description |
|---|---|---|
format | integer | Archive layout version; a panel refuses anything newer than it understands |
panel_version | string | The build that wrote it |
driver | "sqlite" | "postgres" | |
hwid | string | The source host's fingerprint. The licence is locked to it, so an archive restored elsewhere comes up unlicensed. |
web_domain | string | |
basepath | string | |
created_at | integer | Unix seconds |
encrypted | boolean | |
contents | object | |
tables | BackupTable[] | |
skipped | object | Table name → why it was left out (transient, excluded, or absent from the source schema) |
BackupPreview
| Field | Type | Description |
|---|---|---|
manifest | BackupManifest | The archive's own account of itself, written as its first entry. |
encrypted | boolean | Whether the archive was passphrase-protected. Only ever seen after a successful decrypt — an archive that could not be opened answers 400 with code |
tables | BackupTable[] | What was actually found, verified against the manifest |
warnings | BackupWarning[] | Most consequential first |
size | integer | The uploaded archive's size in bytes |
BackupRestore
What a restore loaded. staged says whether it is still waiting for the restart (SQLite: the live database is untouched until the file swap) or already committed (PostgreSQL: the restart only reloads what the process holds in memory).
| Field | Type | Description |
|---|---|---|
manifest | BackupManifest | The archive's own account of itself, written as its first entry. |
tables | integer | Tables loaded |
rows | integer | Rows loaded |
snapshot | string | Path on the panel host of the full backup taken of the database being replaced |
staged | boolean | True on SQLite: the replacement is a separate file and the live database is still untouched until the restart. False on PostgreSQL, where the transaction has already committed and the restart only reloads in-memory state. |
kept | string[] | Settings this installation held on to instead of taking the archive's |
skipped_tables | string[] | Tables in the archive this schema no longer has |
dropped_columns | object | Table → columns the archive carried that this schema does not. This is how an archive from another version loads at all. |
restarting | boolean |
BackupSchedule
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
intervalHours | integer | How often to take one; the hourly scheduler decides due-ness from the newest archive on disk |
dir | string | |
keep | integer | How many backups to keep; 0 keeps everything. Pre-restore snapshots are never pruned. |
includeHistory | boolean | |
includeAudit | boolean | |
passphraseSet | boolean | Whether scheduled archives are encrypted. The passphrase itself is never returned. |
nextDue | integer | Unix seconds, 0 when the schedule is off or there is no archive the timer counts — pre-restore snapshots and archives stamped more than an hour ahead of now are not counted, exactly as the scheduler does not count them |
BackupScheduleUpdate
A partial update: every field is optional, and one that is absent (or null) leaves the stored value alone. That is the only safe reading of an all-optional object — a client sending just the field it means to change must not have the schedule switched off and the directory moved underneath it — and it is how the write-only passphrase has always behaved. Sending the whole object, as the panel's own form does, replaces the whole schedule.
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
intervalHours | integer | 0 takes the default (24h): enabled with no interval is a half-filled form, not a request for nothing. Capped at a year. Null or absent leaves the stored value alone — a cleared number box must not silently rewrite the schedule. |
dir | string | Absolute path, or empty for the default beside the database. A relative path is refused: it would resolve against the panel process's working directory. Null or absent leaves the stored directory alone. |
keep | integer | 0 keeps everything, and is stored as 0 — not as the default. Null or absent leaves the stored value alone, which is how a cleared box differs from a deliberate 0. |
includeHistory | boolean | |
includeAudit | boolean | |
passphrase | string | Empty leaves the stored one alone |
clearPassphrase | boolean | Removes the stored passphrase, so scheduled archives stop being encrypted |
BackupTable
| Field | Type | Description |
|---|---|---|
name | string | |
rows | integer |
BackupWarning
One advisory about restoring this archive here. None of them blocks a restore; each is a decision for the operator.
| Field | Type | Description |
|---|---|---|
code | "no_admins" | "hwid_mismatch" | "mtls_changed" | "node_pin_changed" | "newer_panel" | "different_panel" | "no_history" | "no_audit" |
|
detail | string | The specifics the message needs (a version, a fingerprint, a hostname) |
Certificate
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | |
type | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs |
challenge | "" | "http-01" | "tls-alpn-01" | "dns-01" | |
certificate | string | PEM certificate chain (public) |
client | JSON | Client-side TLS template for links/subscriptions: server_name, insecure, alpn, utls.fingerprint, certificate_public_key_sha256 |
publicKeySha256 | string | Hex SHA-256 of the leaf SubjectPublicKeyInfo (the client field certificate_public_key_sha256) |
certSha256 | string | Hex SHA-256 of the leaf DER (hysteria2 pinSHA256 / mihomo fingerprint) |
issuer | string | |
notBefore | integer | |
notAfter | integer | |
status | "active" | "pending" | "error" | |
message | string | |
createdAt | integer | |
updatedAt | integer |
CertRequest
| Field | Type | Description |
|---|---|---|
typerequired | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs (required except for custom, where they derive from the PEM) |
challenge | "http-01" | "tls-alpn-01" | "dns-01" | ACME only |
email | string | |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 only; "" clears it |
dnsToken | string | dns-01 provider credential (multi-field split on '😂 |
certificate | string | PEM certificate chain (custom type only) |
key | string | PEM private key (custom type only) |
client | JSON | Client-side TLS template (see Certificate.client) |
Change
One audit record: a mutating admin or API-token action, the operator that performed it, and the payload it carried.
| Field | Type | Description |
|---|---|---|
id | integer | |
dateTime | integer | Unix seconds |
actor | string | Operator username, or "system" for an unattended action |
key | string | Entity type: user, inbound, outbound, endpoint, node, template, certificate, panel_certificate, admin, token, license, panel, changes |
action | string | create | update | delete, plus per-entity verbs (issue, renew, purge, restart, …) |
obj | object | The action's payload, with secret-looking fields replaced by "[redacted]" |
CloseConnsFilter
Which of the user's connections to drop. Every field optional; none means all of them.
| Field | Type | Description |
|---|---|---|
nodeId | integer | One node; 0 or absent means every connected node |
inbound | string | |
outbound | string |
CloseConnsResult
| Field | Type | Description |
|---|---|---|
closedrequired | integer | Connections dropped |
nodesrequired | integer | Nodes that answered |
unsupported | integer | Nodes too old to know the route |
failed | integer | Nodes that could not be reached in time |
DomainFront
| Field | Type | Description |
|---|---|---|
id | integer | |
label | string | Names the front, and suffixes the entries it renders so two fronts on one inbound are told apart by something other than a trailing -2 |
address | string | The hostname a client dials — the CDN's, not the node's |
port | integer | null means 443. A CDN's port is unrelated to the inbound's listen port, which is what the origin sees |
sni | string | |
hostHeader | string | |
alpn | JSON | An arbitrary JSON value (a core config fragment, etc.). |
fingerprint | string | |
allowInsecure | boolean | |
clientIpHeader | string | The request header the CDN sets with the real client address (CF-Connecting-IP for Cloudflare). When named, the panel sets trusted_x_forwarded_for to it on every XHTTP inbound this front is enabled on whose operator left that field empty, and the node takes the client address from this header's value, when it is an IP address, on requests carrying it, not from X-Forwarded-For (whose first entry a CDN appends to rather than replaces, so it stays the client's claim). The address is as trustworthy as three things: the CDN writing the header itself rather than passing on a client's (Cloudflare does; Fastly passes a client's Fastly-Client-IP on unless configured not to); the node accepting connections from the CDN alone, since a direct client can send the header too; and every front on the inbound naming the same header, since the node believes the first listed header a request carries and a CDN passes on one it does not write. Empty means the inbound keeps its own value |
originNodeId | integer | Which node this front lands on. NOT a fan-out dimension — the front still renders one entry per inbound. It is there so the panel can leave the front out of the file of a user who reaches no such node, since their credentials were never provisioned there. null means the operator's CDN decides and no check runs. |
enabled | boolean | |
sort | integer | Order within the catalogue, which is the order the entries render in |
inbounds | Inbound[] | |
warnings | string[] | Cross-checks that are advisory rather than refusals, because the other half is routinely written afterwards: an ingress that does not route the SNI this front's traffic arrives with would send it to the fallback. The front is saved; these are not errors. |
createdAt | integer | |
updatedAt | integer |
DomainFrontInput
| Field | Type | Description |
|---|---|---|
labelrequired | string | |
addressrequired | string | |
port | integer | |
sni | string | |
hostHeader | string | |
alpn | JSON | An arbitrary JSON value (a core config fragment, etc.). |
fingerprint | string | |
allowInsecure | boolean | |
clientIpHeader | string | The request header this CDN writes the client address into (CF-Connecting-IP for Cloudflare); see DomainFront. An RFC 9110 header name, anything else is a 400. Like the other text fields, absent or empty on an edit clears it. |
originNodeId | integer | |
enabled | boolean | Absent leaves it unchanged on edit, and defaults to true on create |
sort | integer | |
inboundIds | integer[] | Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound. |
EmailPending
| Field | Type | Description |
|---|---|---|
id | string | Names this confirmation in …/confirm |
adminId | integer | |
address | string | |
lang | string | |
events | string[] | |
expiresAt | integer |
EmailRecipient
| Field | Type | Description |
|---|---|---|
id | integer | |
address | string | |
adminId | integer | The account the address belongs to |
username | string | |
lang | "en" | "fa" | "ru" | "zh" | |
addedAt | integer | |
subscriberId | integer | |
events | string[] | |
enabled | boolean | |
failures | integer | |
disabledAt | integer |
EmailRecipientUpdate
| Field | Type | Description |
|---|---|---|
events | string[] | Names from GET /api/events; empty = every event the account may read |
enabled | boolean | |
lang | "en" | "fa" | "ru" | "zh" |
Endpoint
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | wireguard, tailscale, openvpn-server, openconnect-server, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
useNodeCert | boolean | Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this endpoint's TLS (wins over useNodeCert) |
createdAt | integer | |
updatedAt | integer |
Error
| Field | Type | Description |
|---|---|---|
error | string | |
inProgress | boolean | On a 409 only: the first request under this Idempotency-Key is still running. Retry with the same key. |
EventDelivery
| Field | Type | Description |
|---|---|---|
id | integer | The X-Nexora-Delivery value |
outboxId | integer | The event's id (the envelope's |
event | string | |
status | "pending" | "delivered" | "dead" | |
attempt | integer | |
statusCode | integer | The receiver's last answer; 0 when none came back |
error | string | |
createdAt | integer | |
nextAt | integer | When the next attempt is due; 0 when none |
deliveredAt | integer |
EventInfo
One entry of the event catalog. Four families: user.* (an account), node.* (a node), admin.* (an operator account) and panel.* (the install itself — never about an account, so never delivered to a subscriber bound to one). scope is the token scope a reader would need to fetch the objects the event names; payload is the shape of the envelope's data.
| Field | Type | Description |
|---|---|---|
name | string | |
family | "user" | "node" | "admin" | "panel" | |
scope | string | "" when any token may read it |
description | string | |
payload | object[] |
EventSubscriber
| Field | Type | Description |
|---|---|---|
id | integer | |
kind | "manual" | "addon" | |
name | string | |
url | string | |
events | string[] | |
adminId | integer | |
tokenId | integer | The API token the addon subscriber belongs to (registered through it, or named by the main admin); 0 for a manual entry |
enabled | boolean | |
signed | boolean | Whether a secret is set; the migrated legacy webhook has none |
failures | integer | Consecutive failed deliveries |
failingSince | integer | Unix seconds the current failure streak began, 0 when none |
disabledAt | integer | |
createdAt | integer | |
updatedAt | integer | |
secret | string | Present in the create and rotate responses only |
managedBy | AddonRef | The addon a token or a subscription belongs to; such a grant is changed on the Addons page. |
EventSubscriberRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
urlrequired | string | Absolute http(s) URL; no credentials in it |
kind | "manual" | "addon" | Create only; addon needs an API-token request or a tokenId |
tokenId | integer | Create only, kind addon from a session: the addon's API token (one carrying an addon id). An addon calling with its own token leaves it out |
events | string[] | Names from GET /api/events; empty = every event |
adminId | integer | 0 = every event; otherwise only the events about this account |
enabled | boolean | Update only |
secret | string | Create only; generated when empty |
Inbound
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | |
type | string | vless, vmess, trojan, shadowsocks, ... |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
remark | string | |
useNodeCert | boolean | Replace this inbound's TLS cert with the node's managed cert |
certificateId | integer | Panel certificate assigned to this inbound's TLS (wins over useNodeCert) |
clientConfig | JSON | Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template |
frontedOnly | boolean | Drop this inbound's direct entries from a subscription when a domain front rendered for it. Applied per user and only when a front actually rendered for that user, so a front scoped to an origin node they cannot reach never takes the inbound out of their file. |
createdAt | integer | |
updatedAt | integer |
InstallJob
| Field | Type | Description |
|---|---|---|
id | string | |
nodeId | integer | |
status | "running" | "done" | "error" | |
message | string | |
method | string | |
version | string | |
started | integer | |
finished | integer |
InstallRequest
IPBan
| Field | Type | Description |
|---|---|---|
id | integer | |
prefix | string | An IP address or a CIDR |
reason | string | |
source | "auto" | "manual" | |
createdBy | string | The admin, or |
createdAt | integer | |
expiresAt | integer | Unix seconds; 0 = permanent |
JSON
An arbitrary JSON value (a core config fragment, etc.).
ListEnvelope
The /api/v1 list shape. items holds the resource objects for the requested page.
| Field | Type | Description |
|---|---|---|
items | object[] | |
total | integer | Rows matching the filters, before limit/offset |
limit | integer | |
offset | integer |
Me
The calling admin, returned by both login and /api/me. Resellers additionally carry their allowance — the reseller dashboard is built entirely from these fields. When the caller is a third-party API token, token is true, and scopes reports what the session may actually reach, so an integration can introspect its own limits instead of assuming them.
role is the account's role name and base the tier behind it. Every permission decision is made on base and scopes: a custom role narrows a tier and can never climb out of one, so the name alone does not say what an account reaches.
| Field | Type | Description |
|---|---|---|
id | integer | The operator this session acts as; 0 for a legacy unbound API token |
username | string | For an API token: "token:" followed by its description |
role | string | The account's role name. For the three built-in roles this is the tier's own name (sudo/admin/resale), which is what it always was; a custom role carries whatever the operator called it. |
base | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
owner | boolean | Whether this account is the panel's owner. Absent for API-token sessions. |
token | boolean | True when the caller is an API token rather than a login. Absent otherwise. |
totpEnabled | boolean | Whether the account has two-factor authentication on. Absent for API tokens. |
scopes | string[] | What this session may reach: the role's scope set, intersected with the token's own when the caller is a token. Empty = unrestricted (a legacy token with no scopes). |
dashboardTiles | string | Pinned dashboard tiles in order, comma-separated. null = never chosen (defaults apply), "" = nothing pinned. Absent for API-token sessions. |
timezone | string | Panel-wide IANA timezone the UI renders timestamps in (the |
listenPinned | boolean | True when the bind address is pinned by -listen or NEXORA_WEB_LISTEN (the container case). The web_listen_ip / web_listen_port settings are still writable but are ignored at every start, so a client should present them read-only rather than as an effective change. |
volume | integer | Reseller: byte allowance per period, 0 = unlimited |
used | integer | Reseller: traffic its users burned in the current period |
period | ResalePeriod | How often a reseller's volume allowance resets ("" = never). |
periodDay | integer | Reseller: day the allowance resets on (0-6 weekly, day-of-month otherwise) |
periodStart | integer | Reseller: unix start of the current period, 0 = no periodic reset |
expiry | integer | Reseller: account expiry, unix seconds, 0 = never |
userLimit | integer | Reseller: max users it may create, 0 = unlimited |
users | integer | Reseller: how many users it currently owns |
blocked | boolean | Reseller: allowance spent or account expired — its users are held disabled |
MfaPending
The password step of a two-factor login succeeded; finish at /api/login/mfa.
| Field | Type | Description |
|---|---|---|
mfaRequired | true | |
pending | string | Opaque, five minutes, single use |
Node
| Field | Type | Description |
|---|---|---|
id | integer | |
licensed | boolean | Whether the node is inside the licence's node cap: one of the first N nodes by id. A node outside it is listed, is served an empty plan, and answers 402 to every write but delete. Computed per response. |
name | string | |
address | string | |
port | integer | |
status | "connecting" | "connected" | "error" | "disabled" | |
message | string | Why the node is not working; empty while it is connected |
warnings | string[] | Config items the node is not running: those its core skipped on its last successful load, then those the last live sync could not apply (kept until a sync applies them). The node is serving everything else; these are not connection errors. |
remark | string | |
linkAddress | string | The node's public address, or several: a comma-separated list whose entries are each a bare address or "label|address". The first is the primary — what endpoints and app configs use, and what a tunnel dials; every entry after it adds a copy of each client link. Empty falls back to address. Never sent to a node. |
isLocal | boolean | |
routing | JSON | An arbitrary JSON value (a core config fragment, etc.). |
coreVersion | string | |
templateId | integer | |
multiplier | number | Traffic multiplier applied to user usage on this node (0 treated as 1) |
enabled | boolean | Admin on/off intent; toggled via /enabled |
limitBytes | integer | Periodic usage cap in bytes (0 = no limit) |
limitPeriod | "" | "weekly" | "monthly" | "yearly" | Periodic limit window |
limitStartDay | integer | Weekly 0-6 (Sun-Sat); monthly/yearly 1-31 |
limitStartMonth | integer | Yearly anchor month 1-12 |
limitDisabled | boolean | Auto-disabled by the periodic usage limit |
periodUsed | integer | Raw bytes counted against limitBytes so far in the current period; 0 without an active limit |
periodStart | integer | Unix seconds the current limit period began; 0 without an active limit |
online | integer | Distinct users that sent traffic through this node inside the online window (90s). Absent rather than 0 when the usage series is switched off — the difference between "nobody is connected" and "nothing is counting". |
live | object | What the node said about itself on its last heartbeat. Absent for a node that has not answered since the panel started, has stopped answering, or predates the figures — "not reported", never a zero that reads as idle. |
fallbackCertMode | "" | "node" | "panel" | Certificate substituted into TLS configs that end up without one |
fallbackCertId | integer | Panel certificate used when fallbackCertMode=panel |
up | integer | |
down | integer | |
sshHost | string | Host for the automatic installer; empty falls back to address |
sshPort | integer | |
sshUser | string | |
sshKeyId | integer | Panel SSH key used for this host; null = the default key |
sshKeyInstalled | boolean | The panel's key is authorised on this host, so updates need no password |
setup | string | Last-seen deployment: docker, podman, containerd, lxc or linux |
arch | string | Last-seen GOARCH of the running agent |
nodeVersion | string | Last-seen node agent version (coreVersion is the proxy engine's) |
infoSeenAt | integer | When the three fields above were last confirmed |
lastStatusChange | integer | |
createdAt | integer | |
updatedAt | integer |
NodeClientUsage
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | |
userId | integer | |
dateTime | integer | |
up | integer | |
down | integer |
NodeConnections
| Field | Type | Description |
|---|---|---|
totalrequired | integer | Connections open on the node right now |
inbounds | object | Open connections per inbound tag |
usersrequired | object[] |
NodeHealth
One node's stored host readings over a window, with the uptime bar derived from them.
| Field | Type | Description |
|---|---|---|
from | integer | Window start, unix seconds (inclusive) |
to | integer | Window end, unix seconds (exclusive) |
bucket | integer | Resolution the uptime segments were derived at, in seconds: 300 inside the raw window, 3600 where the history has been compacted |
recording | boolean | False when node_health_retention_days is 0 — nothing is being kept, so an empty answer means switched off rather than no history |
samples | object[] | Oldest first. A figure of 0 means the node did not report it; a reading whose disk and memory totals were both zero was never stored at all. |
uptime | object[] | Runs of equal state tiling [from, to) exactly once, oldest first. |
NodeLive
The part of a node's status that is a figure of the moment, kept in the panel's memory between heartbeats.
| Field | Type | Description |
|---|---|---|
connectionsrequired | integer | Connections the core holds open right now, TCP and UDP sessions alike |
restartsrequired | integer | How many times the engine was started again since the node agent's process began |
lastError | string | The most recent engine start that failed, if any since the process began |
lastErrorAt | integer | Unix seconds of lastError |
rejected | object | Connections the node's router handed to each block outbound since its engine started, keyed by the outbound's tag — |
seenAtrequired | integer | Unix seconds the node reported these |
host | object | Disk and memory, sampled every five minutes on a tick of their own (the threshold alerts are judged on it); absent until the first sample. Zero totals mean the node did not say. |
NodeTotals
Traffic per node over a window, heaviest first.
| Field | Type | Description |
|---|---|---|
start | integer | |
end | integer | |
nodes | NodeUsageRow[] |
NodeUsage
Per-user traffic on one node over a window, heaviest first.
| Field | Type | Description |
|---|---|---|
start | integer | Window start, unix seconds (inclusive) |
end | integer | Window end, unix seconds (exclusive) |
users | object[] |
NodeUsageRow
| Field | Type | Description |
|---|---|---|
nodeId | integer | |
name | string | Empty for a node deleted since the traffic was recorded |
up | integer | |
down | integer | |
last | integer | Newest hour bucket with traffic, unix seconds |
Outbound
| Field | Type | Description |
|---|---|---|
id | integer | |
nodeId | integer | null = pool item; set = per-node manual |
tag | string | |
type | string | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
createdAt | integer | |
updatedAt | integer |
OutboundCheckResult
| Field | Type | Description |
|---|---|---|
ok | boolean | |
delay | integer | Latency in milliseconds |
error | string |
PanelCertificate
| Field | Type | Description |
|---|---|---|
id | integer | |
panelWeb | boolean | The panel's own HTTPS certificate (listed only): no addon on another server may fetch it |
name | string | |
type | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs |
challenge | "" | "http-01" | "tls-alpn-01" | "dns-01" | |
email | string | ACME account contact. Echoed, so an edit form can round-trip it: an empty value on update clears it, unlike dnsToken. |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 provider. Echoed, so an edit form can round-trip it; required when the panel itself answers a dns-01 challenge. |
obtainNodeId | integer | Node whose ACME agent performs the challenge (acme only). Null means the panel obtains the certificate itself. |
certificate | string | PEM certificate chain (public) |
client | JSON | Client-side TLS template for links/subscriptions (see Certificate.client) |
publicKeySha256 | string | |
certSha256 | string | |
issuer | string | |
notBefore | integer | |
notAfter | integer | |
status | "active" | "pending" | "error" | |
message | string | |
createdAt | integer | |
updatedAt | integer |
PanelCertRequest
| Field | Type | Description |
|---|---|---|
namerequired | string | |
typerequired | "self_signed" | "acme" | "custom" | |
sans | string[] | Domains and/or IPs (required except for custom, where they derive from the PEM) |
challenge | "http-01" | "tls-alpn-01" | "dns-01" | ACME only |
email | string | |
dnsProvider | "" | "cloudflare" | "alidns" | "acmedns" | dns-01 only; "" clears it |
dnsToken | string | dns-01 provider credential; empty on update keeps the stored one |
obtainNodeId | integer | Who answers the ACME challenge. Null (the default) means the panel itself: dns-01 works for any name, while http-01 and tls-alpn-01 need the name to resolve to the panel and port 80/443 free on it — requesting either for an IP SAN is refused with 400. Set it to a node to have that node's agent answer instead, which is the only way to satisfy an on-host challenge for a name pointing at the node. |
certificate | string | PEM certificate chain (custom type only; empty on update keeps the stored pair) |
key | string | PEM private key (custom type only) |
client | JSON | Client-side TLS template (never reissues the certificate) |
PanelInfo
The panel's own health readout, for the dashboard's Panel tiles.
| Field | Type | Description |
|---|---|---|
version | string | |
go_version | string | |
uptime | integer | Seconds since the panel process started |
arch | string | GOARCH of the panel build (amd64, arm64, …) |
setup | string | How the panel is deployed: the container runtime (docker, podman, …) or the host OS (linux) for a plain install |
num_cpu | integer | |
num_goroutine | integer | |
mem_alloc | integer | |
mem_sys | integer | |
sys_mem_total | integer | |
sys_mem_used | integer | |
load_avg_1 | number | |
disk_total | integer | Size of the root filesystem in bytes; 0 where the platform does not report one |
disk_used | integer | Used bytes of the root filesystem |
db_driver | string | sqlite or postgres |
db_size | integer | On-disk database size in bytes; 0 when the backend does not report one |
db_usage_rows | integer | Rows in node_client_usages |
backup_last_at | integer | Unix seconds of the newest archive the panel's own machinery counts, 0 = never. A directory that quietly stopped filling up is the failure scheduled backups have, and nothing else on the panel would show it. Sudo only — see below. |
backup_count | integer | How many such archives there are. Pre-restore snapshots are excluded (they are copies of a replaced database, not evidence of a backup), as is anything stamped more than an hour ahead of now, which retention and the timer also step over — so this is the set retention actually bounds. Sudo only. |
backup_enabled | boolean | Whether scheduled backups are on (sudo only) |
backup_interval_hours | integer | How often the schedule runs, so a client can say whether the last backup is late rather than assuming a daily one (sudo only). All four fields are 0/false for anyone else — including a token without |
update | object | The release check and the install method (sudo only; absent for everyone else). The banner is drawn from this, so it rides the poll the dashboard already makes. |
PanelService
| Field | Type | Description |
|---|---|---|
key | string | The service's name: telegram, email, backup_s3, backup_sftp, backup_telegram |
enabled | boolean | |
testable | boolean | Whether POST …/test does anything |
fields | object[] | |
lastRunAt | integer | Unix seconds of the last run (a delivery, an upload, a login, a test); 0 = never |
lastOkAt | integer | Unix seconds of the last successful run; 0 = never |
lastError | string | The last failure, cleared by the next success |
PanelServiceUpdate
| Field | Type | Description |
|---|---|---|
enabled | boolean | Left out = unchanged |
config | object | Settings to change; "" clears one |
PanelUpdate
| Field | Type | Description |
|---|---|---|
status | PanelUpdateStatus | What the daily release check last saw, and how this panel was installed. The two halves are separate questions: whether a newer release exists, and whether this install can replace itself with it. |
job | PanelUpdateJob | One upgrade. The record lives in a file beside the binary rather than in the database, because the panel exits in the middle of it: the rollback watchdog and the new process both read it, and the watchdog must not open a database the new build may have migrated. |
log | string[] | The job log, tail-capped. It is a file, so a browser that reconnects after the panel restarted still reads the whole run |
PanelUpdateJob
One upgrade. The record lives in a file beside the binary rather than in the database, because the panel exits in the middle of it: the rollback watchdog and the new process both read it, and the watchdog must not open a database the new build may have migrated.
| Field | Type | Description |
|---|---|---|
id | string | |
from | string | |
to | string | |
phase | "backup" | "download" | "verify" | "swap" | "restarting" | "done" | "error" | "rolled_back" | Anything but done, error and rolled_back means the job is still moving |
message | string | |
error | string | |
backup | string | The archive taken before anything was touched — the way back from a migration, since migrations are forward-only |
previous | string | Where the replaced binary is kept |
actor | string | |
startedAt | integer | |
finishedAt | integer |
PanelUpdateStatus
What the daily release check last saw, and how this panel was installed. The two halves are separate questions: whether a newer release exists, and whether this install can replace itself with it.
| Field | Type | Description |
|---|---|---|
current | string | The version this binary was built as |
latest | string | The newest release tag the check has seen; empty until the first successful check |
notes | string | Link to that release on the public panel repository |
checkedAt | integer | Unix seconds of the last check, successful or not — a failure is stamped too, because the stamp is what paces the retry |
available | boolean | latest is newer than current |
checkEnabled | boolean | The update_check setting; false means the panel asks nothing on its own and the figures above are whatever the last check left |
error | string | Why the last check failed, or absent. An install with nothing outbound shows a reason here rather than logging one every day |
method | "systemd" | "container" | "manual" | How this panel was installed. Only a systemd install can replace itself |
runtime | string | The container runtime, when method is container |
unit | string | The systemd unit this process runs under, read from its own cgroup |
binary | string | The running executable |
reason | string | Why this panel may not update itself, in prose for the operator; absent exactly when it may |
canUpdate | boolean | |
addonUpdates | integer | How many registered addons the addon directory lists a newer version of |
nodeLatest | string | The newest node release tag the check has seen; empty until the first successful check. Learned by the same check as the panel's, on its own record |
nodeNotes | string | Link to that release on the public node repository |
nodeError | string | Why the last node lookup failed, or absent; separate from |
nodesBehind | integer | How many nodes report a version older than nodeLatest; a node that has not reported one is not counted |
nodeCache | object[] | The node binaries this panel holds for installers, one per architecture |
nodeCacheCurrent | boolean | Every architecture the fleet needs is cached at nodeLatest. When false, an install downloads the newest node from GitHub instead of uploading the older cached one |
Plan
The limits a new user starts with. A plan is applied and forgotten: POST /api/users copies its values onto the new account and nothing links the two afterwards, so editing or deleting a plan never reaches an account already sold. Its group is the only trace it leaves, and the handle the users list filters by.
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
desc | string | |
sortOrder | integer | Display order; equal values fall back to the id |
volume | integer | Byte cap the account starts with, 0 = unlimited |
days | integer | Plan length in days, 0 = never expires |
onFirstUse | boolean | true: |
speedLimit | integer | Throughput cap in bytes per second, per direction, 0 = unlimited |
ipLimit | integer | Concurrent source addresses, 0 = unlimited |
deviceLimit | integer | Devices that may hold the subscription (by |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
allTemplates | boolean | Provision on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership, used when allTemplates is false |
group | string | Group label written onto every account this plan creates |
namePrefix | string | Prepended to the username the caller chose; empty = unchanged |
nameSuffix | string | Appended to the username the caller chose; empty = unchanged |
createdAt | integer | |
updatedAt | integer |
PresetCatalogue
| Field | Type | Description |
|---|---|---|
version | integer | The document's shape. 1 today; later sessions add sections rather than changing these. |
ruleSets | object | |
inbounds | PresetInbound[] | The shelf behind “add an inbound from a preset”: a protocol with its transport and TLS decision already made. |
routes | PresetRoute[] | Routing blocks for a template, composed of rule sets the panel already mirrors. |
dns | PresetDns[] | Resolver sets for a template's core, in the typed server form. |
sources | string[] | The operator catalogue files that were read, in the order they were applied. Always present; empty means the panel is serving only what it ships. |
PresetDns
A set of resolvers and which one answers by default, as a typed dns block ready to drop into a template's core. The older address form is not used: a node running the current core refuses a config carrying it.
| Field | Type | Description |
|---|---|---|
key | string | |
label | string | |
labelKey | string | |
description | string | |
descriptionKey | string | |
dns | object |
PresetGroup
| Field | Type | Description |
|---|---|---|
key | string | The group's identity, and what an overlay file matches on to replace it. |
label | string | |
labelKey | string | |
items | PresetItem[] |
PresetInbound
One "make me this kind of inbound". It carries the decisions and never the per-install randomness: no port, no key, no path. The port and the generated secrets come from the interface's own defaults for the type, the transport path is generated (one frozen into the document would be identical on every install, which is a fingerprint), and the REALITY private key and short IDs are minted by the panel when the inbound is saved — which is why an operator never types a key. What it produces is an ordinary inbound row; nothing records which preset made it.
| Field | Type | Description |
|---|---|---|
key | string | Its identity, and what an overlay file matches on to replace it. |
label | string | |
labelKey | string | |
description | string | |
descriptionKey | string | |
type | string | The inbound type. |
tag | string | The base the created inbound's tag is built from; a short random suffix is appended. Absent = the type. |
transport | string | A stream transport (ws, grpc, httpupgrade, …), applied the way the form applies one an operator picked. |
useNodeCert | boolean | Point the inbound at the node's managed certificate. Needed wherever the preset turns TLS on for a protocol that does not require it. |
config | object | Merged over the interface's default config for |
clientConfig | object | Client-side hints merged into generated links. |
PresetItem
| Field | Type | Description |
|---|---|---|
tag | string | The tag the rule set would be created under, which is also half its download URL. |
url | string | Where the panel fetches the list from. |
label | string | Shown as written. An operator's own entry carries this. |
labelKey | string | An i18n key the interface resolves. A built-in entry carries this. |
PresetRoute
A routing block an operator can apply to a template. requires is the load-bearing field: the panel leaves out a rule set it holds no copy of and every rule matching on it — it must, since the core refuses a router whose rule names an undefined rule-set — so a preset applied without its lists is a template that does nothing and reports nothing. Naming the tags lets a client check them first and offer to create the missing ones off the rule-set shelf in this same document, which is why both shelves live in one catalogue.
| Field | Type | Description |
|---|---|---|
key | string | |
label | string | |
labelKey | string | |
description | string | |
descriptionKey | string | |
requires | string[] | Rule-set tags the rules match on. Every one of them is also an entry on the rule-set shelf, so a client can create what is missing. |
route | object | A route object (rules + final). |
compose | boolean | True for a preset whose rules are put in front of the template's rules rather than replacing them. The country presets replace, because rule order is the routing; a self-contained preset (sniff, then reject a sniffed protocol) has to run first, and first is an order. |
outbounds | object[] | Pool outbounds the route names. A client creates any the panel lacks (by tag) and selects them on the template — the block-torrent preset routes to a |
ProbeResult
What the host reports, what the node last reported about itself, and the plan the installer would follow. The cached fields matter because the node is usually offline when this is used; mismatch names the cached fact the host contradicts.
| Field | Type | Description |
|---|---|---|
fingerprint | string | SHA256 fingerprint of the host's SSH key |
hostKeyNew | boolean | True when this connection pinned the key for the first time |
osId | string | |
osName | string | |
kernel | string | |
machine | string | |
arch | string | |
root | boolean | |
systemd | boolean | |
docker | boolean | |
dockerVersion | string | |
compose | boolean | |
curl | boolean | |
tar | boolean | |
pkgManager | string | The host's package manager, or empty if it has none the installer can drive |
installed | "none" | "script" | "docker" | |
installedVersion | string | |
serviceState | string | |
containerState | string | |
image | string | |
listen | string | |
certPresent | boolean | |
cachedSetup | string | |
cachedArch | string | |
cachedVersion | string | |
cachedSeenAt | integer | |
mismatch | "" | "arch" | "method" | "missing" | |
method | "" | "script" | "docker" | |
source | "" | "upload" | "panel" | "github" | "docker" | |
targetVersion | string | |
stagedVersion | string | |
latestVersion | string | The newest node release the panel knows of; empty until the first check |
stagedForArch | boolean | |
keyInstalled | boolean | |
warnings | string[] |
ResalePeriod
How often a reseller's volume allowance resets ("" = never).
Role
A named permission set an operator account carries. A role is two things at once and they are not interchangeable: base is the tier it derives from — what the route guards ask about, and what decides whether the account owns a book of users — and scopes narrows, inside that tier, which routes its sessions reach. A role only ever narrows.
The three built-in rows (builtin: true) are the three tiers. They are listed and their caps are editable, but their name, base and scope set come from the panel's own code and are rewritten on every start, so a panel upgrade that adds a permission reaches them without a migration — and they can neither be edited nor deleted through this API.
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The three built-in names are reserved. |
base | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
builtin | boolean | One of the three fixed roles: not editable, not deletable. |
description | string | |
scopes | string[] | The granted permissions, from the list GET /api/scopes serves. At least one is required — an empty set would be a role that reaches nothing, which nobody means to create. A role cannot grant a scope the caller does not hold itself (403), or the first custom role is a way to become a main admin. |
userLimit | integer | Max users an account on this role may own, 0 = no cap. A ceiling over the per-account allowance, never a grant: the effective cap is the smaller of the two non-zero values, and creating past it answers 402. Only bites where ownership exists — a resale-based role. |
maxDeviceLimit | integer | Ceiling on the per-user device (HWID) limit this role may hand out, 0 = no cap. With a cap set, a user carrying no device limit at all is refused rather than clamped: 0 means unlimited everywhere in this schema. |
maxDuration | integer | Ceiling in seconds on a duration-based plan this role may hand out (the window a user may sit in before its first connection starts the clock), 0 = no cap. Same refusal as maxDeviceLimit for an uncapped value. |
admins | integer | How many accounts carry this role |
createdAt | integer | |
updatedAt | integer |
RouteDialResult
| Field | Type | Description |
|---|---|---|
okrequired | boolean | |
connect_msrequired | integer | Time to connect in milliseconds; 0 when it never connected |
answered | boolean | The destination sent the first bytes |
unconfirmed | boolean | OK through a tunnel or proxy with nothing back to say the destination was reached: no tunnel or proxy reports how its far end's own connect went, and a far end still trying looks the same. Never set for a direct connect, which is the destination accepting. |
error | string |
RouteTestForm
| Field | Type | Description |
|---|---|---|
inboundsrequired | object[] |
RouteTestRequest
| Field | Type | Description |
|---|---|---|
inboundrequired | string | The inbound's tag on the node |
user | string | The user's name; empty for a connection that carries none |
network | "tcp" | "udp" | Default tcp |
source | string | The client's address, an IP with or without a port; optional |
addressrequired | string | The destination as the client asked |
portrequired | integer | |
domain | string | The sniffed domain (TLS SNI, HTTP Host); optional |
protocol | string | The sniffed protocol (tls, http, quic, bittorrent, …); optional |
RouteTestResult
| Field | Type | Description |
|---|---|---|
stepsrequired | RouteTestStep[] | |
actionrequired | string | How the walk ended: route, reject, hijack-dns, bypass, or final (no rule ended it) |
rule_indexrequired | integer | -1 for the final outbound |
rule | string | |
outbound | string | |
outbound_type | string | |
destinationrequired | string | Where the connection goes after any override_address / override_port |
error | string | What the router itself would refuse the connection with |
dial | RouteDialResult |
RouteTestStep
| Field | Type | Description |
|---|---|---|
indexrequired | integer | |
rulerequired | string | The rule in the engine's own words — what a live connection's row shows for it |
actionrequired | string | |
matchedrequired | boolean | |
final | boolean | This rule ended the walk |
skipped | "sniff" | "resolve" | A matched action the dry run could not perform without real traffic |
RuleSet
A remote rule set the panel mirrors. The panel downloads the list from url, keeps a copy, refreshes it every updateIntervalHours, and pushes that copy to each node over its control link — nodes never fetch anything. A node whose first rule-set fetch fails refuses its whole configuration, so nobody's availability must be able to decide whether the fleet runs. A node too old to take the push (before node v0.0.2) is given no rule sets, and no rule naming one.
Templates select rule sets the way they select pool items, and the selection becomes each node's route.rule_set[]. Only the remote kind exists: a local rule-set names a file the panel does not put on the node, and an inline one is a rule list, which route.rules already is.
| Field | Type | Description |
|---|---|---|
id | integer | |
tag | string | The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation. |
url | string | Absolute http(s) upstream the panel downloads from. Never sent to a node. |
updateIntervalHours | integer | How often the panel re-downloads from upstream. A refresh that changed the bytes pushes them to the nodes carrying the list. |
format | "binary" | "source" | Detected from the downloaded bytes, never chosen |
available | boolean | The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations. |
size | integer | |
ruleCount | integer | |
contentHash | string | sha256 of the mirrored bytes; a node already holding them is not pushed them again |
lastFetchedAt | integer | Unix seconds; 0 = never downloaded |
lastAttemptAt | integer | |
lastError | string | Why the last refresh did not land. The previous copy is kept. |
createdAt | integer | |
updatedAt | integer |
RuleSetTestRequest
| Field | Type | Description |
|---|---|---|
tags | string[] | Empty: every rule set the node's route declares |
addressrequired | string | |
port | integer |
RuleSetTestResult
| Field | Type | Description |
|---|---|---|
resultsrequired | object[] |
ServerInfo
| Field | Type | Description |
|---|---|---|
core_version | string | |
node_version | string | |
uptime | integer | |
num_goroutine | integer | |
arch | string | GOARCH of the node build (amd64, arm64, …); empty from nodes older than this field |
setup | string | How the node is deployed: the container runtime (docker, podman, containerd, kubepods, lxc) or the host OS (linux) for a plain install |
num_cpu | integer | |
mem_alloc | integer | |
mem_sys | integer | |
sys_mem_total | integer | |
sys_mem_used | integer | |
load_avg_1 | number | |
load_avg_5 | number | 5-minute load average; 0 from nodes older than the field or platforms that do not report one |
load_avg_15 | number | 15-minute load average; 0 as above |
sys_swap_total | integer | Host swap in bytes; 0 for a host with no swap as well as when unknown |
sys_swap_used | integer | |
disk_total | integer | Size of the node's root filesystem in bytes; 0 when unknown |
disk_used | integer | Used bytes of the root filesystem, counting root-reserved blocks (as df does) |
host_uptime | integer | Seconds since the host booted — the machine's uptime, where |
SessionInfo
One live connection as the node's registry knows it. Live state only — it is written nowhere; per-user destination logging is a separate, opt-in thing.
| Field | Type | Description |
|---|---|---|
idrequired | string | Row identity; unique on its node, gone after a node restart |
inbound | string | |
user | string | |
outbound | string | The outbound tag the connection was routed to |
network | "tcp" | "udp" | |
source | string | Client address, host:port |
destination | string | Where it went — the sniffed domain when there is one |
rule | string | The route rule that matched; empty when the final outbound took it |
createdAtrequired | integer | Unix seconds, when it was routed |
uprequired | integer | Bytes from the client so far |
downrequired | integer | Bytes to the client so far |
Setting
| Field | Type | Description |
|---|---|---|
id | integer | |
key | string | |
value | string |
SetupRequest
| Field | Type | Description |
|---|---|---|
tokenrequired | string | The one-time setup token printed by the installer. |
usernamerequired | string | |
passwordrequired | string | At least 12 characters mixing three of lowercase, uppercase, digits and symbols. |
listenIp | string | An IP literal, or empty to bind every interface. |
listenPortrequired | string | |
basepath | string | URI prefix the panel is served under; empty serves at the root. |
subBasepath | string | Path subscription links are built on; must differ from basepath. |
tlsModerequired | "self_signed" | "none" | self_signed makes the panel issue and renew its own certificate; none is plain HTTP, chosen deliberately. |
certSans | string[] | IP addresses the generated certificate should name, taken verbatim. A hostname here is a 400: names belong in domain/subDomain, which the panel adds automatically and keeps the certificate in step with afterwards. Empty falls back to the address the wizard was reached at plus this machine's publicly routable addresses. |
domain | string | |
subDomain | string | |
timezone | string |
SetupState
What the first-run wizard needs to render itself. Every proposed default is a complete answer, so the form can be accepted as it stands.
| Field | Type | Description |
|---|---|---|
required | boolean | False on a configured panel; the wizard then does nothing. |
version | string | |
defaults | object | |
hostAddresses | string[] | Every address found on this machine, publicly routable ones first — the pool the operator picks certificate addresses from. |
suggestedSans | string[] | The subset worth naming on a certificate unprompted: the publicly routable ones. Inside a container the rest is bridge addressing no client can reach, and behind NAT the address clients use is on no interface here at all — so the wizard also offers the address the browser reached it at, and the operator edits the result. |
SSHCredentials
How the panel authenticates to a node's host. Nothing here is stored: a password lives as long as the connection it opened. With neither password nor privateKey the panel falls back to its own key, which works only where that key was previously authorised (installKey).
| Field | Type | Description |
|---|---|---|
sshHost | string | Defaults to the node's sshHost, then its address |
sshPort | integer | |
sshUser | string | |
password | string | |
privateKey | string | PEM private key |
passphrase | string |
SSOButton
| Field | Type | Description |
|---|---|---|
id | integer | |
kind | string | google, microsoft, github, gitlab, keycloak, authentik, okta, auth0, oidc, oauth2 |
name | string | The button's name; empty = the kind's |
SSOLink
| Field | Type | Description |
|---|---|---|
id | integer | |
adminId | integer | |
username | string | On the main admin's list only |
providerId | integer | |
providerKind | string | |
providerName | string | |
issuer | string | |
label | string | The email or username the provider showed when it was linked; display only |
createdAt | integer | |
lastLoginAt | integer | 0 = never used to sign in |
SSOProvider
| Field | Type | Description |
|---|---|---|
id | integer | |
kind | string | |
name | string | |
enabled | boolean | On the login page |
sortOrder | integer | |
fields | object[] | The kind's settings; a secret is reported only as set |
lastRunAt | integer | |
lastOkAt | integer | |
lastError | string | |
links | integer | Identities linked through it |
SSOProviderWrite
| Field | Type | Description |
|---|---|---|
kind | string | Create only |
name | string | |
enabled | boolean | |
sortOrder | integer | |
config | object |
StatsSeries
One downsampled traffic series. up and down are dense, equal-length and in bytes; point i covers [startTime + i*bucketSpan, startTime + (i+1)*bucketSpan).
| Field | Type | Description |
|---|---|---|
recording | boolean | False when stats_retention_days is 0 — nothing is being recorded, so every series is empty |
startTime | integer | Left edge of the first bucket (unix seconds) |
endTime | integer | Right edge of the last bucket (unix seconds) |
bucketSpan | integer | Seconds covered by one point |
up | integer[] | Bytes from the client, per bucket |
down | integer[] | Bytes to the client, per bucket |
totalUp | integer | |
totalDown | integer |
SubscriptionInfo
The subscription page view model (the subscription page theme guide lists the template field names).
| Field | Type | Description |
|---|---|---|
user | object | |
usage | object | |
expire | object | |
reset | object | |
configs | object[] | |
apps | object[] | |
sub | object | |
panel | object | |
assetBase | string | |
lang | string | |
dir | "ltr" | "rtl" | |
now | integer | |
timezone | string |
TelegramChat
| Field | Type | Description |
|---|---|---|
id | integer | |
chatId | integer | |
title | string | The group's title or the person's name |
adminId | integer | The account the chat belongs to |
username | string | |
lang | "en" | "fa" | "ru" | "zh" | |
pairedAt | integer | |
subscriberId | integer | |
events | string[] | |
enabled | boolean | |
failures | integer | |
disabledAt | integer |
TelegramPairing
| Field | Type | Description |
|---|---|---|
code | string | |
adminId | integer | |
lang | string | |
events | string[] | |
expiresAt | integer | |
link | string | https://t.me/ |
groupLink | string | https://t.me/ |
status | "pending" | "pairing" | "paired" | "expired" | |
chat | TelegramChat | |
error | string | The poll's last failure while still pending |
Template
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
remark | string | |
route | JSON | An arbitrary JSON value (a core config fragment, etc.). |
core | JSON | dns/log/ntp blocks |
inbounds | Inbound[] | |
outbounds | Outbound[] | |
endpoints | Endpoint[] | |
ruleSets | RuleSet[] | |
inboundIds | integer[] | |
outboundIds | integer[] | |
endpointIds | integer[] | |
ruleSetIds | integer[] | |
createdAt | integer | |
updatedAt | integer |
Token
Node-install token (see ApiToken for third-party API tokens).
| Field | Type | Description |
|---|---|---|
id | integer | |
kind | "node_install" | "api" | |
token | string | |
desc | string | |
role | AdminRole | The three tiers a role is based on, and the names of the three built-in roles. sudo = the main admin (full access, including admin management, tokens, and licensing); admin = a regular admin (operational routes only); resale = a reseller, which reaches nothing but the users it owns. A role narrows one of these; nothing widens one. Ownership is a separate mechanism: scopes decide which routes a session reaches, ownership decides which rows it sees. |
scopes | string | |
usedAt | integer | |
expiry | integer | |
createdAt | integer |
TopUsers
Heaviest users across the fleet over a window.
| Field | Type | Description |
|---|---|---|
start | integer | Window start, unix seconds (inclusive) |
end | integer | Window end, unix seconds (exclusive) |
users | object[] |
Tunnel
A link between two nodes: traffic arriving at the relay leaves from an origin. It is its own collection rather than a pool item because it is the only resource that is inherently about a pair of nodes.
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | Unique. The config tag is tunnel- |
tag | string | The tag route rules address on the relay. |
mode | "reverse" | "direct" | reverse — the relay listens and origins dial in, so an origin behind NAT or with every port firewalled still works and users never face it. direct — the relay dials out to origins that listen. |
relayNodeId | integer | Where users connect. |
relayName | string | |
originIds | integer[] | Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to. |
originNames | string[] | |
profiles | TunnelProfile[] | The ways this tunnel is carried. Several profiles are several ways onto the same tunnel, not several tunnels: their links land in one session pool on the node under one peer identity, so a new stream takes whichever profile is least busy and a protocol that gets blocked costs throughput rather than the tunnel — while a user still sticks to the same origin, because what they are pinned to is the peer and every profile shares it. A request may instead use the older flat shape — listenPort with tls and transport beside it — which means exactly one profile and is translated. It edits the first profile rather than replacing the list, so a field it does not mention stays as it was; it cannot describe a second profile. Responses always carry profiles. |
balance | "balance" | "priority" | How a new stream chooses among the profiles that are up. |
workers | integer | Parallel links per origin for a profile that sets none of its own, each with its own congestion window. |
streamWindow | integer | Per-stream receive window in bytes; 0 takes the node default. |
enabled | boolean | |
ownsRelayFinal | boolean | Whether the relay's default outbound (route.final) is this tunnel. The panel derives it per node rather than taking it from a route, because a route lives on a template shared with nodes that do not carry the tag and the core resolves final eagerly at start — a template final naming a tunnel stops the core of every node that is not the relay. Precedence: the relay node's own routing.final wins, then this tunnel, then the template's final. |
finalReason | "disabled" | "noOrigin" | "shared" | "nodeFinal" | Why the tunnel is not the relay's default outbound, absent when it is. disabled/noOrigin — the tunnel produces no half at all. shared — the relay relays more than one tunnel, so which traffic goes down which is a choice the panel does not make; write route rules. nodeFinal — the relay carries its own final, which wins deliberately and is the supported way to keep a tunnel for selected traffic only. |
createdAt | integer | |
updatedAt | integer |
TunnelBulkResult
| Field | Type | Description |
|---|---|---|
op | string | |
matched | integer | How many of the ids named a tunnel that exists. |
affected | integer | How many tunnels actually changed. |
failed | integer | How many were refused by the validation. |
error | string | The first refusal, since they are usually the same one repeated. |
problems | string[] | Up to ten refusals, each naming the tunnel it belongs to. |
TunnelHalfStatus
| Field | Type | Description |
|---|---|---|
nodeId | integer | |
nodeName | string | |
reachable | boolean | |
error | string | |
sessions | object[] | |
peers | object[] |
TunnelProfile
| Field | Type | Description |
|---|---|---|
listenPort | integer | The port this profile binds on whichever side listens. It belongs to the tunnel, not to any inbound, so it must not collide with one — nor with another profile, which would be a bind failure naming an address rather than a tunnel. |
tls | object | This profile's listener TLS block, in an inbound's shape. The dialing side's is derived from it, so the two cannot disagree. Missing key material is completed on save: REALITY gets a key pair, short ids and a handshake target; plain TLS gets a self-signed certificate for the tunnel's derived server name, which the dialing side pins. Every profile is issued for that same name — the certificate identifies the tunnel, not the port it was reached on. |
transport | object | An optional v2ray transport carried by both halves of this profile, so the link reads as a web request rather than a long-lived stream. type must be one of ws, grpc, http, httpupgrade, quic; quic additionally requires TLS, and cannot carry REALITY. |
workers | integer | How many links this profile keeps per origin, and so how much of the load it takes: streams spread over links, so six against two is three times the share. 0 takes the tunnel's own workers. |
TunnelReset
| Field | Type | Description |
|---|---|---|
tag | string | |
nodes | object[] | One entry per node that runs a half, relay first. |
TunnelStatus
| Field | Type | Description |
|---|---|---|
tag | string | |
healthy | boolean | The relay has at least one link attached. |
sessions | integer | |
relay | TunnelHalfStatus | |
origins | TunnelHalfStatus[] |
User
| Field | Type | Description |
|---|---|---|
id | integer | |
name | string | |
enable | boolean | Absent on a create is on; on an edit (a full replace) absent is off. A person's off — this field set to false, or the bulk disable — is never undone by the quota enforcer; only a person turns it back on. |
disabledReason | string | Why the account is off: |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
desc | string | |
contact | object | The customer's own details, as one flat object of string pairs. Six keys are standard — |
subToken | string | |
subId | string | Subscription identifier used in /sub/{id}; defaulted random on create |
remark | string | Label prepended to generated share-link names |
volume | integer | Byte cap, 0 = unlimited |
speedLimit | integer | Throughput cap in bytes per second, per direction and per node, 0 = unlimited |
ipLimit | integer | Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited |
deviceLimit | integer | Distinct devices that may hold the subscription, counted by the |
expiry | integer | Unix seconds, 0 = never. With |
duration | integer | Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed |
activatedAt | integer | Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it. |
up | integer | |
down | integer | |
onlineAt | integer | Unix ts of last traffic; online if within ~90s |
subFetchedAt | integer | Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs. |
subSeenAt | integer | Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never |
subClient | string | User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text. |
subFetchIp | string | Address the body was last fetched from |
lastInbound | string | Tag of the inbound this user's most recent connection arrived on, as the node reported it — the one-word answer to which protocol the user actually uses. Empty until the first drain that carried it. |
autoReset | boolean | Switch the periodic traffic reset on. What kind of cycle it is depends on |
resetDays | integer | Rolling cycle length in days, read only when |
resetPeriod | "" | "weekly" | "monthly" | "yearly" | Empty = the rolling cycle measured in |
resetStartDay | integer | Anchor for a calendar cycle: weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. A day past the end of a month is clamped to its last day (the 31st is the 28th in February, the 30th of Esfand is the 29th in a common year) and returns to the anchor the month after. |
resetStartMonth | integer | Anchor month, read for the yearly cycle only. |
resetCalendar | "" | "jalali" | Empty = Gregorian. |
nextReset | integer | The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not. |
totalUp | integer | |
totalDown | integer | |
group | string | Free text label for bulk operations |
adminId | integer | Reseller (role resale) that owns this user; 0 = owned by the panel. Resellers may only see and manage their own users, and always create users for themselves. |
allTemplates | boolean | Provision the user on every template (ignores templateIds) |
templateIds | integer[] | Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour |
subUrl | string | This account's absolute subscription URL, computed per response and never stored. It is returned because a wildcard sub_domain mints a hostname per subscriber, derived from the account, which no client of this API can compute — build links from this rather than from the sub_domain setting. Omitted where a response does not carry it. |
licensed | boolean | Whether the row is inside the licence cap: one of the first N users by id, N being the licensed count. A row outside it is listed and can be deleted, is served on no node, and answers 402 to every other write. Computed per response; the cap moves whenever a row is deleted or a licence installed. |
planId | integer | Create only: apply this plan's limits over the rest of the body. Ignored on update — see PUT /api/users/{id}. Nothing links the user to the plan afterwards. |
createdAt | integer | |
updatedAt | integer |
UserAddresses
| Field | Type | Description |
|---|---|---|
limit | integer | The user's ipLimit, 0 = unlimited |
addresses | object[] |
UserConnections
| Field | Type | Description |
|---|---|---|
connectionsrequired | object[] |
UserDevices
| Field | Type | Description |
|---|---|---|
limitrequired | integer | The user's deviceLimit, 0 = unlimited |
devicesrequired | object[] |
UserNodeUsage
One user's traffic split across the nodes they used.
| Field | Type | Description |
|---|---|---|
start | integer | |
end | integer | |
nodes | NodeUsageRow[] |
UserPatch
The fields to change, by their names on User. Any of: name, enable, config, desc, group, contact, adminId, subId, remark, volume, expiry, duration, activatedAt, nextReset, speedLimit, ipLimit, deviceLimit, autoReset, resetDays, resetPeriod, resetStartDay, resetStartMonth, resetCalendar, allTemplates, templateIds — plus version. config and contact are replaced as a whole when sent.
| Field | Type | Description |
|---|---|---|
version | integer | The |
name | string | |
enable | boolean | |
config | JSON | An arbitrary JSON value (a core config fragment, etc.). |
desc | string | |
group | string | |
contact | object | |
adminId | integer | |
subId | string | |
remark | string | |
volume | integer | |
expiry | integer | |
duration | integer | |
activatedAt | integer | |
nextReset | integer | |
speedLimit | integer | |
ipLimit | integer | |
deviceLimit | integer | |
autoReset | boolean | |
resetDays | integer | |
resetPeriod | string | |
resetStartDay | integer | |
resetStartMonth | integer | |
resetCalendar | string | |
allTemplates | boolean | |
templateIds | integer[] |
UserSessions
| Field | Type | Description |
|---|---|---|
nodeIdrequired | integer | |
noderequired | string | |
totalrequired | integer | The user's open connections on this node, before any row filter |
inbounds | object | The user's connections per inbound tag on this node |
outbounds | object | The user's connections per outbound tag on this node |
sessionsrequired | SessionInfo[] | |
truncated | boolean | The rows stopped at the node's cap; the counts are still whole |
supportedrequired | boolean | False for a node too old to list rows; it still counts them |
