Skip to content

مرجع 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 بفرستید، تا تکرار بعد از timeout همان جواب اول را برگرداند و چیزی دو بار ساخته نشود.
  • رویداد به جای پرس‌وجوی پیاپی. برای اینکه بفهمید کی چیزی تغییر می‌کند، با یک وبهوک در رویدادها مشترک شوید.
bash
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
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
usernamerequiredstring
passwordrequiredstring
rememberboolean

Ask for the long-lived session (30 days of inactivity instead of 24 hours). Both slide with use.

Responses

  • 200

    Logged in; nexora_session cookie 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 is MfaPending instead, no cookie is set, and the login finishes at /api/login/mfa with a code from the authenticator or a recovery code.

  • 401Error

    Error

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
pendingrequiredstring
coderequiredstring

A TOTP code (spaces ignored) or a recovery code (case and dash ignored)

Responses

  • 200Me

    Logged in; nexora_session cookie set

  • 401Error

    Error

  • 429Error

    Error

POST/api/logoutLog out (clears the session cookie)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Logged out

GET/api/meCurrent admin session

Responses

PUT/api/me/passwordChange the current admin's password (any role)

Verifies the current password, then revokes the admin's other sessions.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
currentPasswordrequiredstring
newPasswordrequiredstring

Responses

  • 200

    Changed

  • 401Error

    Error

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
coderequiredstring

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
coderequiredstring

Responses

POST/api/me/2fa/recoveryReplace the recovery codes

A new set of ten, invalidating the old ones. Takes a TOTP code.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
coderequiredstring

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
coderequiredstring

Responses

  • 200

    Confirmed

  • 401Error

    Error

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Revoked

  • 400Error

    Error

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

idrequiredpathinteger

Responses

  • 200

    Revoked

  • 404Error

    Error

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
valuerequiredstring

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

  • 200

    OK

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

FieldTypeDescription
providerrequiredinteger

A provider id from GET /api/login/oidc

redirectUrirequiredstring
rememberboolean

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

ParameterInTypeDescription
codequerystring
statequerystring
errorquerystring

Responses

  • 302

    Back into the panel

GET/api/me/oidcThe caller's own single sign-on links (session only)

Responses

  • 200

    OK

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

FieldTypeDescription
providerrequiredinteger
redirectUrirequiredstring
passwordrequiredstring

The account's current password

Responses

admins

GET/api/rolesList roles (sudo only)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Role

FieldTypeDescription
idinteger
namestring

Unique. The three built-in names are reserved. admins.role stores this value, so a rename carries its holders with it.

baseAdminRole

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.

builtinboolean

One of the three fixed roles: not editable, not deletable.

descriptionstring
scopesstring[]

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.

userLimitinteger

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.

maxDeviceLimitinteger

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.

maxDurationinteger

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.

adminsinteger

How many accounts carry this role

createdAtinteger
updatedAtinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Role

FieldTypeDescription
idinteger
namestring

Unique. The three built-in names are reserved. admins.role stores this value, so a rename carries its holders with it.

baseAdminRole

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.

builtinboolean

One of the three fixed roles: not editable, not deletable.

descriptionstring
scopesstring[]

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.

userLimitinteger

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.

maxDeviceLimitinteger

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.

maxDurationinteger

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.

adminsinteger

How many accounts carry this role

createdAtinteger
updatedAtinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

GET/api/adminsList admins (sudo only)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

POST/api/adminsCreate an admin (sudo only)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
usernamerequiredstring
passwordrequiredstring
roleAdminRole

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.

volumeinteger

Reseller allowance in bytes, 0 = unlimited

periodResalePeriod

How often a reseller's volume allowance resets ("" = never).

periodDayinteger
expiryinteger
userLimitinteger

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
usernamerequiredstring
rolerequiredAdminRole

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.

volumeinteger

Reseller allowance in bytes, 0 = unlimited

periodResalePeriod

How often a reseller's volume allowance resets ("" = never).

periodDayinteger
expiryinteger
userLimitinteger

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
passwordrequiredstring

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

idrequiredpathinteger

Responses

  • 200

    Revoked

  • 404Error

    Error

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

  • 200

    OK

GET/api/tokensList third-party API tokens (sudo only)

Only a hash is stored, so token is never present in listings.

ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
descstring
roleAdminRole

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.

adminIdinteger

Operator the token acts as; defaults to the caller

scopesstring[]

See GET /api/scopes; empty = unrestricted within the role

addonstring

Addon id (a slug: a-z, 0-9, _ and -, up to 32 characters). It is what a kind: addon webhook is bound to; a token without one is not an addon's.

rateLimitinteger

Requests per minute, 0 = panel default

expiryinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

actorquerystring

Exact operator username

keyquerystring

Exact entity type (user, node, admin, …)

actionquerystring

Exact action (create, update, delete, …)

fromqueryinteger

Unix seconds, inclusive lower bound

toqueryinteger

Unix seconds, inclusive upper bound

Responses

DELETE/api/changesPurge audit records (sudo only)

The purge is itself recorded, so an emptied log still says who emptied it.

ParameterInTypeDescription
beforequeryinteger

Unix seconds; omit to delete the whole log

Responses

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

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.

ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

statusquerystring

Comma-separated subset of active, disabled, pending, expired, limited (any of them). A user has exactly one status, decided in that order: disabled beats pending (a plan measured from the first connection that has not had one) beats expired beats limited (volume used up).

onlinequeryboolean

true: traffic within the last 90 s; false: none

groupquerystring

Exact group label

admin_idqueryinteger

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_idqueryinteger

Users the template provisions: its explicit members plus every all-templates user

node_idqueryinteger

Users the node serves, through its template; 404 for an unknown node

inbound_idqueryinteger

Users reaching the inbound through any template that carries it

protocolquerystring

Users reaching any inbound of this type (vless, hysteria2, …)

expires_afterqueryinteger

Unix seconds, inclusive lower bound on expiry; users that never expire are excluded

expires_beforequeryinteger

Unix seconds, inclusive upper bound on expiry; users that never expire are excluded

expiryquery"never"

never: only users with no expiry

pendingqueryboolean

Whether the plan has started. A plan measured from the first connection carries a provisional expiry until somebody connects, so pending=false is what makes an expiry bound safe to act on. Deliberately not the pending status, which additionally requires the account to be enabled.

used_minqueryinteger

Inclusive lower bound on up + down, in bytes

used_maxqueryinteger

Inclusive upper bound on up + down, in bytes

auto_resetqueryboolean
sub_fetchedqueryboolean

Whether the subscription body has ever been handed to a client (subFetchedAt > 0). false is the account nobody has pointed a client at yet — distinct from subSeenAt, which the page and its polling also set.

contact_telegram_idquerystring

Exact (case-insensitive) match on the account's contact.telegram_id. contact_email, contact_phone, contact_bale_id, contact_soroush_id and contact_rubika_id are the same for the other standard keys; the free-text q matches all six as substrings.

contact_emailquerystring

Exact (case-insensitive) match on the account's contact.email.

contact_phonequerystring

Exact (case-insensitive) match on the account's contact.phone. Matched as stored, so a number kept as +98 912 … is not found by 0912….

contact_bale_idquerystring

Exact (case-insensitive) match on the account's contact.bale_id, the chat id a Bale bot links.

contact_soroush_idquerystring

Exact (case-insensitive) match on the account's contact.soroush_id, the chat id a Soroush Plus bot links.

contact_rubika_idquerystring

Exact (case-insensitive) match on the account's contact.rubika_id, the chat id a Rubika bot links.

sortquery"id" | "name" | "usage" | "remaining" | "expiry" | "online_at" | "sub_fetched_at" | "sub_seen_at" | "created_at" | "updated_at"

usage is up + down; remaining is volume - up - down with an unlimited volume counting as infinite; expiry, online_at, sub_fetched_at and sub_seen_at put users with none last in either direction. The id is always the final tiebreaker, so a sorted walk pages stably.

orderquery"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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body User

FieldTypeDescription
idinteger
namestring
enableboolean

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.

disabledReasonstring

Why the account is off: manual (a person), or the enforcer's own reason — expiry, volume, resale-volume, resale-expiry — which it lifts when the account is back within its limits. Empty while the account is on, and on accounts switched off before the field existed.

configJSON

An arbitrary JSON value (a core config fragment, etc.).

descstring
contactobject

The customer's own details, as one flat object of string pairs. Six keys are standard — telegram_id, bale_id and soroush_id (a bot's numeric chat id or an @username), rubika_id (a Rubika bot's chat id), email and phone — and are the ones the panel itself reads: they are filtered on (contact_<key>), matched by the free-text q, exported as their own CSV columns and written by nexora-migrate. Any other pair is the operator's own: stored and returned untouched, and interpreted by nothing. Values may be sent as JSON numbers or booleans and come back as text; an empty value removes the key, and a document left with no pairs is stored as absent. At most 24 pairs, 48 characters per key, 256 per value and 2 KiB in all; anything else is a 400, as is a standard key whose value is not what it claims to be. An addon reads this and keeps its own state in its own database — a whole-object PUT on a user is last-writer-wins, and nothing arbitrates it.

subTokenstring
subIdstring

Subscription identifier used in /sub/{id}; defaulted random on create

remarkstring

Label prepended to generated share-link names

volumeinteger

Byte cap, 0 = unlimited

speedLimitinteger

Throughput cap in bytes per second, per direction and per node, 0 = unlimited

ipLimitinteger

Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited

deviceLimitinteger

Distinct devices that may hold the subscription, counted by the x-hwid a compatible client sends when it fetches; a device past the cap is refused the configs (403). Clients that send no id are served unless the device_limit_strict setting is on. 0 = unlimited.

expiryinteger

Unix seconds, 0 = never. With duration set and activatedAt still 0 this is provisional and moves to the real date when the plan starts.

durationinteger

Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed expiry date. Setting it (or changing it on an existing user) re-arms the plan: activatedAt returns to 0 and the countdown waits for the next connection.

activatedAtinteger

Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it.

upinteger
downinteger
onlineAtinteger

Unix ts of last traffic; online if within ~90s

subFetchedAtinteger

Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs.

subSeenAtinteger

Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never

subClientstring

User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text.

subFetchIpstring

Address the body was last fetched from

lastInboundstring

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.

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

nextResetinteger

The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not.

totalUpinteger
totalDowninteger
groupstring

Free text label for bulk operations

adminIdinteger

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.

allTemplatesboolean

Provision the user on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour

subUrlstring

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.

licensedboolean

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.

planIdinteger

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.

createdAtinteger
updatedAtinteger

Responses

  • 200User

    A user

  • 400Error

    Error

  • 402Error

    Error

  • 403Error

    Error

  • 409Error

    The Idempotency-Key was used for a different request, or its first request is still running (inProgress: true, Retry-After).

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

  • 200

    OK

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

ParameterInTypeDescription
oprequiredpath"enable" | "disable" | "reset-traffic" | "reset-devices" | "delete" | "add-days" | "add-months" | "add-traffic" | "limits"
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

allquery"true"

Run on every user the caller owns. Required when no ids and no filter are sent, so an omitted parameter can never mean everything.

qquerystring

Case-insensitive substring search over the resource's text columns.

statusquerystring
onlinequeryboolean
groupquerystring
admin_idqueryinteger
template_idqueryinteger
node_idqueryinteger
inbound_idqueryinteger
protocolquerystring
expires_afterqueryinteger
expires_beforequeryinteger
expiryquery"never"
pendingqueryboolean
used_minqueryinteger
used_maxqueryinteger
auto_resetqueryboolean
sub_fetchedqueryboolean

Whether the subscription body has ever been handed to a client (subFetchedAt > 0). false is the account nobody has pointed a client at yet — distinct from subSeenAt, which the page and its polling also set.

contact_telegram_idquerystring

Exact (case-insensitive) match on the account's contact.telegram_id. contact_email, contact_phone, contact_bale_id, contact_soroush_id and contact_rubika_id are the same for the other standard keys; the free-text q matches all six as substrings.

contact_emailquerystring

Exact (case-insensitive) match on the account's contact.email.

contact_phonequerystring

Exact (case-insensitive) match on the account's contact.phone. Matched as stored, so a number kept as +98 912 … is not found by 0912….

contact_bale_idquerystring

Exact (case-insensitive) match on the account's contact.bale_id, the chat id a Bale bot links.

contact_soroush_idquerystring

Exact (case-insensitive) match on the account's contact.soroush_id, the chat id a Soroush Plus bot links.

contact_rubika_idquerystring

Exact (case-insensitive) match on the account's contact.rubika_id, the chat id a Rubika bot links.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

limitqueryinteger

Parsed and range-checked (a negative or non-integer value is a 400) but not applied: this route acts on the whole selection, and limit does not cap it. Narrow the filter, or send ids.

offsetqueryinteger

Parsed and range-checked but not applied; see limit.

sortquery"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.

orderquery"asc" | "desc"

Accepted and validated; see sort.

Request body

FieldTypeDescription
idsinteger[]

The users to act on. When absent, the query parameters select them.

monthsinteger

add-months: whole calendar months to add, signed, at most 120. Renewal as an operator sells it — the same day of the month, clamped to a short month's last day (31 January plus one month is 28 February, not 3 March) and keeping the time of day. It moves the expiry only: a plan measured from the first connection is a length in seconds and a calendar month is not a number of seconds, so its duration is left alone and add-days stays the operation for moving a plan's length. An account with no expiry, or one whose plan has not started (its date is provisional and activation overwrites it), is blocked.

fromNowboolean

add-days and add-months: count a dated account's time from the later of now and its expiry, rather than from the expiry — an account that ran out ten days ago given thirty days has thirty, not twenty. An account whose plan has not started keeps adding to its provisional date. Positive amounts only. For one account, POST /api/users/renew/{id} does this and raises user.renewed.

calendar"" | "jalali"

add-months: the calendar the months are counted in. Empty = Gregorian. Named by the request rather than read off the account, because which month was sold is something the operator knows and the row does not.

daysinteger

add-days: days to add, signed. A plan measured from the first connection moves both its length and its date; a fixed-date account moves its date; one that never expires is blocked. Nothing is ever reduced to 0, which would mean never expires.

bytesinteger

add-traffic: bytes to add to the volume cap, signed. An account with no cap is blocked — adding to unlimited would take the unlimited away.

volumeinteger

limits: byte cap, 0 = unlimited. Absent = unchanged.

expiryinteger

limits: unix seconds. Makes the account fixed-date: the plan length and activation are cleared with it, because while a length is set the panel recomputes the date from the first connection and would throw this away.

speedLimitinteger

limits: bytes/second per direction, 0 = unlimited. Absent = unchanged.

ipLimitinteger

limits: concurrent addresses, 0 = unlimited. Absent = unchanged.

deviceLimitinteger

limits: devices that may hold the subscription, 0 = unlimited. Absent = unchanged.

autoResetboolean

limits: periodic reset. Switching it on requires a cycle shape in the same request (400 otherwise) — resetDays >= 1 or a non-empty resetPeriod, because a cycle with no shape can never come round and the stored shape of every selected account cannot be seen from here. Switching it on opens the next appointment (nothing else does); switching it off clears it.

resetDaysinteger

limits: the rolling cycle's length in days. Absent = unchanged. 0 clears the whole cycle — autoReset and the pending appointment with it — unless the same request names a calendar resetPeriod, where the rolling length is the half being cleared rather than the cycle.

resetPeriod"" | "weekly" | "monthly" | "yearly"

limits: put the selection on a calendar cycle, or back on the rolling one. Clearing it to "" requires resetDays in the same request, for the same reason switching autoReset on does.

resetStartDayinteger

limits: the calendar cycle's anchor — weekly 0-6 (Sunday..Saturday), monthly and yearly the day of the month. Only beside resetPeriod (400 alone): the period is set as a whole, and an anchor or calendar left out beside it takes the fresh-row value (day 1, month 1, Gregorian) rather than keeping what each row stored.

resetStartMonthinteger

limits: the anchor month, read for the yearly cycle only. Only beside resetPeriod (400 alone).

resetCalendar"" | "jalali"

limits: the calendar a monthly or yearly boundary is counted in. Empty = Gregorian. Only beside resetPeriod (400 alone).

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
daysinteger
monthsinteger
calendar"" | "jalali"

With months only. Empty = Gregorian.

trafficinteger

Bytes added to the volume cap.

resetUsageboolean

Responses

  • 200User

    A user

  • 400Error

    Error

  • 402Error

    Error

  • 403Error

    Error

  • 404Error

    Error

  • 409Error

    Nothing of that kind to renew (the account never expires, its traffic is unlimited, or months on a plan that has not started), or the account kept changing while it was being renewed.

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
formatquery"csv" | "zip"
idsquerystring

Comma-separated user ids. When absent, the filters below select the rows.

allquery"true"

Export every user the caller owns. Required when no ids and no filter are sent, so an omitted parameter can never mean everything — the same gate the bulk operations hold, and this route hands over subscription tokens.

qquerystring

Case-insensitive substring search over the resource's text columns.

statusquerystring
onlinequeryboolean
groupquerystring
admin_idqueryinteger
template_idqueryinteger
node_idqueryinteger
inbound_idqueryinteger
protocolquerystring
expires_afterqueryinteger
expires_beforequeryinteger
expiryquery"never"
pendingqueryboolean
used_minqueryinteger
used_maxqueryinteger
auto_resetqueryboolean
sub_fetchedqueryboolean

Whether the subscription body has ever been handed to a client (subFetchedAt > 0). false is the account nobody has pointed a client at yet — distinct from subSeenAt, which the page and its polling also set.

contact_telegram_idquerystring

Exact (case-insensitive) match on the account's contact.telegram_id. contact_email, contact_phone, contact_bale_id, contact_soroush_id and contact_rubika_id are the same for the other standard keys; the free-text q matches all six as substrings.

contact_emailquerystring

Exact (case-insensitive) match on the account's contact.email.

contact_phonequerystring

Exact (case-insensitive) match on the account's contact.phone. Matched as stored, so a number kept as +98 912 … is not found by 0912….

contact_bale_idquerystring

Exact (case-insensitive) match on the account's contact.bale_id, the chat id a Bale bot links.

contact_soroush_idquerystring

Exact (case-insensitive) match on the account's contact.soroush_id, the chat id a Soroush Plus bot links.

contact_rubika_idquerystring

Exact (case-insensitive) match on the account's contact.rubika_id, the chat id a Rubika bot links.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

limitqueryinteger

Parsed and range-checked (a negative or non-integer value is a 400) but not applied: this route acts on the whole selection, and limit does not cap it. Narrow the filter, or send ids.

offsetqueryinteger

Parsed and range-checked but not applied; see limit.

sortquery"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.

orderquery"asc" | "desc"

Accepted and validated; see sort.

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.

ParameterInTypeDescription
idrequiredpathinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body User

FieldTypeDescription
idinteger
namestring
enableboolean

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.

disabledReasonstring

Why the account is off: manual (a person), or the enforcer's own reason — expiry, volume, resale-volume, resale-expiry — which it lifts when the account is back within its limits. Empty while the account is on, and on accounts switched off before the field existed.

configJSON

An arbitrary JSON value (a core config fragment, etc.).

descstring
contactobject

The customer's own details, as one flat object of string pairs. Six keys are standard — telegram_id, bale_id and soroush_id (a bot's numeric chat id or an @username), rubika_id (a Rubika bot's chat id), email and phone — and are the ones the panel itself reads: they are filtered on (contact_<key>), matched by the free-text q, exported as their own CSV columns and written by nexora-migrate. Any other pair is the operator's own: stored and returned untouched, and interpreted by nothing. Values may be sent as JSON numbers or booleans and come back as text; an empty value removes the key, and a document left with no pairs is stored as absent. At most 24 pairs, 48 characters per key, 256 per value and 2 KiB in all; anything else is a 400, as is a standard key whose value is not what it claims to be. An addon reads this and keeps its own state in its own database — a whole-object PUT on a user is last-writer-wins, and nothing arbitrates it.

subTokenstring
subIdstring

Subscription identifier used in /sub/{id}; defaulted random on create

remarkstring

Label prepended to generated share-link names

volumeinteger

Byte cap, 0 = unlimited

speedLimitinteger

Throughput cap in bytes per second, per direction and per node, 0 = unlimited

ipLimitinteger

Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited

deviceLimitinteger

Distinct devices that may hold the subscription, counted by the x-hwid a compatible client sends when it fetches; a device past the cap is refused the configs (403). Clients that send no id are served unless the device_limit_strict setting is on. 0 = unlimited.

expiryinteger

Unix seconds, 0 = never. With duration set and activatedAt still 0 this is provisional and moves to the real date when the plan starts.

durationinteger

Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed expiry date. Setting it (or changing it on an existing user) re-arms the plan: activatedAt returns to 0 and the countdown waits for the next connection.

activatedAtinteger

Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it.

upinteger
downinteger
onlineAtinteger

Unix ts of last traffic; online if within ~90s

subFetchedAtinteger

Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs.

subSeenAtinteger

Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never

subClientstring

User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text.

subFetchIpstring

Address the body was last fetched from

lastInboundstring

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.

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

nextResetinteger

The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not.

totalUpinteger
totalDowninteger
groupstring

Free text label for bulk operations

adminIdinteger

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.

allTemplatesboolean

Provision the user on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour

subUrlstring

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.

licensedboolean

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.

planIdinteger

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.

createdAtinteger
updatedAtinteger

Responses

  • 200User

    A user

  • 400Error

    Error

  • 402Error

    The user is outside the licence cap (licensed: false): only the first N users by id are served, N being the licensed count. Delete rows to make room, or activate a licence. Delete itself is never refused for this reason.

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body UserPatch

FieldTypeDescription
versioninteger

The updatedAt the caller read; the edit is refused with 409 when the account has moved on.

namestring
enableboolean
configJSON

An arbitrary JSON value (a core config fragment, etc.).

descstring
groupstring
contactobject
adminIdinteger
subIdstring
remarkstring
volumeinteger
expiryinteger
durationinteger
activatedAtinteger
nextResetinteger
speedLimitinteger
ipLimitinteger
deviceLimitinteger
autoResetboolean
resetDaysinteger
resetPeriodstring
resetStartDayinteger
resetStartMonthinteger
resetCalendarstring
allTemplatesboolean
templateIdsinteger[]

Responses

  • 200User

    A user

  • 400Error

    Error

  • 402Error

    The user is outside the licence cap, or the reseller named by adminId is at its role's user cap.

  • 403Error

    A reseller marked usersReadOnly, or the reseller named by adminId is at its own user cap or has expired.

  • 404Error

    Error

  • 409

    The account changed since version. The body carries the current one.

DELETE/api/users/{id}Delete a user
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

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.

ParameterInTypeDescription
idrequiredpathinteger
hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

Window end (unix seconds); must be given with start

limitqueryinteger

Rows to return, heaviest first. Values above the maximum are clamped, not refused

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
nodeIdrequiredpathinteger
inboundquerystring

Only rows that arrived on this inbound tag

outboundquerystring

Only rows routed to this outbound tag

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body CloseConnsFilter

FieldTypeDescription
nodeIdinteger

One node; 0 or absent means every connected node

inboundstring
outboundstring

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
hwidrequiredpathstring

Responses

GET/api/users/{id}/app-configsDedicated-app client configs (OpenVPN .ovpn / OpenConnect info / WireGuard template) across the user's nodes
ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/plansCreate a plan
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Plan

FieldTypeDescription
idinteger
namestring
descstring
sortOrderinteger

Display order; equal values fall back to the id

volumeinteger

Byte cap the account starts with, 0 = unlimited

daysinteger

Plan length in days, 0 = never expires

onFirstUseboolean

true: days becomes the user's duration, so the clock starts at the first connection. false: a fixed expiry, counted from the moment the user is created.

speedLimitinteger

Throughput cap in bytes per second, per direction, 0 = unlimited

ipLimitinteger

Concurrent source addresses, 0 = unlimited

deviceLimitinteger

Devices that may hold the subscription (by x-hwid), 0 = unlimited

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

allTemplatesboolean

Provision on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership, used when allTemplates is false

groupstring

Group label written onto every account this plan creates

namePrefixstring

Prepended to the username the caller chose; empty = unchanged

nameSuffixstring

Appended to the username the caller chose; empty = unchanged

createdAtinteger
updatedAtinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Plan

FieldTypeDescription
idinteger
namestring
descstring
sortOrderinteger

Display order; equal values fall back to the id

volumeinteger

Byte cap the account starts with, 0 = unlimited

daysinteger

Plan length in days, 0 = never expires

onFirstUseboolean

true: days becomes the user's duration, so the clock starts at the first connection. false: a fixed expiry, counted from the moment the user is created.

speedLimitinteger

Throughput cap in bytes per second, per direction, 0 = unlimited

ipLimitinteger

Concurrent source addresses, 0 = unlimited

deviceLimitinteger

Devices that may hold the subscription (by x-hwid), 0 = unlimited

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

allTemplatesboolean

Provision on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership, used when allTemplates is false

groupstring

Group label written onto every account this plan creates

namePrefixstring

Prepended to the username the caller chose; empty = unchanged

nameSuffixstring

Appended to the username the caller chose; empty = unchanged

createdAtinteger
updatedAtinteger

Responses

DELETE/api/plans/{id}Delete a plan

Withdraws the plan from sale. Accounts already created from it keep every limit they were given.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

inbounds

GET/api/inboundsList inbounds
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/inboundsCreate an inbound
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Inbound

FieldTypeDescription
idinteger
tagstring
typestring

vless, vmess, trojan, shadowsocks, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

remarkstring
useNodeCertboolean

Replace this inbound's TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this inbound's TLS (wins over useNodeCert)

clientConfigJSON

Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template

frontedOnlyboolean

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.

createdAtinteger
updatedAtinteger

Responses

PUT/api/inbounds/{id}Update an inbound
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Inbound

FieldTypeDescription
idinteger
tagstring
typestring

vless, vmess, trojan, shadowsocks, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

remarkstring
useNodeCertboolean

Replace this inbound's TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this inbound's TLS (wins over useNodeCert)

clientConfigJSON

Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template

frontedOnlyboolean

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.

createdAtinteger
updatedAtinteger

Responses

DELETE/api/inbounds/{id}Delete an inbound
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
frontIdsinteger[]

Responses

  • 200

    The fronts now enabled on this inbound

  • 400Error

    Error

  • 404Error

    Error

  • 409Error

    More than the per-inbound maximum

certificates

GET/api/certificatesList panel certificates (shared TLS certs assignable to inbounds/endpoints, usable as node fallbacks)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/certificatesDefine a panel certificate and issue it (self-signed locally, ACME via the designated node)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body PanelCertRequest

FieldTypeDescription
namerequiredstring
typerequired"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs (required except for custom, where they derive from the PEM)

challenge"http-01" | "tls-alpn-01" | "dns-01"

ACME only

emailstring
dnsProvider"" | "cloudflare" | "alidns" | "acmedns"

dns-01 only; "" clears it

dnsTokenstring

dns-01 provider credential; empty on update keeps the stored one

obtainNodeIdinteger

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.

certificatestring

PEM certificate chain (custom type only; empty on update keeps the stored pair)

keystring

PEM private key (custom type only)

clientJSON

Client-side TLS template (never reissues the certificate)

Responses

PUT/api/certificates/{id}Update a panel certificate (a pure rename keeps the PEM; parameter changes reissue it and resync using nodes)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body PanelCertRequest

FieldTypeDescription
namerequiredstring
typerequired"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs (required except for custom, where they derive from the PEM)

challenge"http-01" | "tls-alpn-01" | "dns-01"

ACME only

emailstring
dnsProvider"" | "cloudflare" | "alidns" | "acmedns"

dns-01 only; "" clears it

dnsTokenstring

dns-01 provider credential; empty on update keeps the stored one

obtainNodeIdinteger

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.

certificatestring

PEM certificate chain (custom type only; empty on update keeps the stored pair)

keystring

PEM private key (custom type only)

clientJSON

Client-side TLS template (never reissues the certificate)

Responses

DELETE/api/certificates/{id}Delete a panel certificate, clearing assignments/fallbacks and resyncing the nodes that used it
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    OK

  • 409Error

    The 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
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

templates

GET/api/templatesList templates
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/templatesCreate a template
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Template

FieldTypeDescription
idinteger
namestring
remarkstring
routeJSON

An arbitrary JSON value (a core config fragment, etc.).

coreJSON

dns/log/ntp blocks

inboundsInbound[]
outboundsOutbound[]
endpointsEndpoint[]
ruleSetsRuleSet[]
inboundIdsinteger[]
outboundIdsinteger[]
endpointIdsinteger[]
ruleSetIdsinteger[]
createdAtinteger
updatedAtinteger

Responses

PUT/api/templates/{id}Update a template
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Template

FieldTypeDescription
idinteger
namestring
remarkstring
routeJSON

An arbitrary JSON value (a core config fragment, etc.).

coreJSON

dns/log/ntp blocks

inboundsInbound[]
outboundsOutbound[]
endpointsEndpoint[]
ruleSetsRuleSet[]
inboundIdsinteger[]
outboundIdsinteger[]
endpointIdsinteger[]
ruleSetIdsinteger[]
createdAtinteger
updatedAtinteger

Responses

DELETE/api/templates/{id}Delete a template
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

nodes

GET/api/nodesList nodes
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/nodesCreate a node
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Node

FieldTypeDescription
idinteger
licensedboolean

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.

namestring
addressstring
portinteger
status"connecting" | "connected" | "error" | "disabled"
messagestring

Why the node is not working; empty while it is connected

warningsstring[]

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.

remarkstring
linkAddressstring

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.

isLocalboolean
routingJSON

An arbitrary JSON value (a core config fragment, etc.).

coreVersionstring
templateIdinteger
multipliernumber

Traffic multiplier applied to user usage on this node (0 treated as 1)

enabledboolean

Admin on/off intent; toggled via /enabled

limitBytesinteger

Periodic usage cap in bytes (0 = no limit)

limitPeriod"" | "weekly" | "monthly" | "yearly"

Periodic limit window

limitStartDayinteger

Weekly 0-6 (Sun-Sat); monthly/yearly 1-31

limitStartMonthinteger

Yearly anchor month 1-12

limitDisabledboolean

Auto-disabled by the periodic usage limit

periodUsedinteger

Raw bytes counted against limitBytes so far in the current period; 0 without an active limit

periodStartinteger

Unix seconds the current limit period began; 0 without an active limit

onlineinteger

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

liveobject

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

fallbackCertIdinteger

Panel certificate used when fallbackCertMode=panel

upinteger
downinteger
sshHoststring

Host for the automatic installer; empty falls back to address

sshPortinteger
sshUserstring
sshKeyIdinteger

Panel SSH key used for this host; null = the default key

sshKeyInstalledboolean

The panel's key is authorised on this host, so updates need no password

setupstring

Last-seen deployment: docker, podman, containerd, lxc or linux

archstring

Last-seen GOARCH of the running agent

nodeVersionstring

Last-seen node agent version (coreVersion is the proxy engine's)

infoSeenAtinteger

When the three fields above were last confirmed

lastStatusChangeinteger
createdAtinteger
updatedAtinteger

Responses

PUT/api/nodes/{id}Update a node
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Node

FieldTypeDescription
idinteger
licensedboolean

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.

namestring
addressstring
portinteger
status"connecting" | "connected" | "error" | "disabled"
messagestring

Why the node is not working; empty while it is connected

warningsstring[]

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.

remarkstring
linkAddressstring

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.

isLocalboolean
routingJSON

An arbitrary JSON value (a core config fragment, etc.).

coreVersionstring
templateIdinteger
multipliernumber

Traffic multiplier applied to user usage on this node (0 treated as 1)

enabledboolean

Admin on/off intent; toggled via /enabled

limitBytesinteger

Periodic usage cap in bytes (0 = no limit)

limitPeriod"" | "weekly" | "monthly" | "yearly"

Periodic limit window

limitStartDayinteger

Weekly 0-6 (Sun-Sat); monthly/yearly 1-31

limitStartMonthinteger

Yearly anchor month 1-12

limitDisabledboolean

Auto-disabled by the periodic usage limit

periodUsedinteger

Raw bytes counted against limitBytes so far in the current period; 0 without an active limit

periodStartinteger

Unix seconds the current limit period began; 0 without an active limit

onlineinteger

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

liveobject

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

fallbackCertIdinteger

Panel certificate used when fallbackCertMode=panel

upinteger
downinteger
sshHoststring

Host for the automatic installer; empty falls back to address

sshPortinteger
sshUserstring
sshKeyIdinteger

Panel SSH key used for this host; null = the default key

sshKeyInstalledboolean

The panel's key is authorised on this host, so updates need no password

setupstring

Last-seen deployment: docker, podman, containerd, lxc or linux

archstring

Last-seen GOARCH of the running agent

nodeVersionstring

Last-seen node agent version (coreVersion is the proxy engine's)

infoSeenAtinteger

When the three fields above were last confirmed

lastStatusChangeinteger
createdAtinteger
updatedAtinteger

Responses

  • 200Node

    A node

  • 402Error

    The node is outside the licence cap (licensed: false): only the first N nodes by id are served. The same answer comes from /enabled, /routing and the per-node outbound and endpoint creates; delete is never refused for this reason.

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

POST/api/nodes/{id}/syncPush the full config to the node
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    OK

  • 409

    The node is switched off

POST/api/nodes/{id}/reconnectRe-establish the mTLS connection
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    OK

  • 409

    The node is switched off

GET/api/nodes/install/versionsNode binaries this panel has staged, and their versions

Responses

  • 200

    OK

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

ParameterInTypeDescription
idrequiredpathinteger

Request body SSHCredentials

FieldTypeDescription
sshHoststring

Defaults to the node's sshHost, then its address

sshPortinteger
sshUserstring
passwordstring
privateKeystring

PEM private key

passphrasestring

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Request body InstallRequest

Responses

GET/api/nodes/install/jobs/{job}Poll an install job's state and output
ParameterInTypeDescription
jobrequiredpathstring
cursorqueryinteger

Lines already seen; 0 for the whole log

Responses

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.

ParameterInTypeDescription
jobrequiredpathstring

Responses

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.

ParameterInTypeDescription
jobrequiredpathstring

Responses

  • 101

    Switching Protocols

  • 404Error

    Error

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.

ParameterInTypeDescription
idrequiredpathinteger

Request body

FieldTypeDescription
addressrequiredstring
portinteger
sshHoststring
sshPortinteger
sshUserstring

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

ParameterInTypeDescription
idrequiredpathinteger

Request body SSHCredentials

FieldTypeDescription
sshHoststring

Defaults to the node's sshHost, then its address

sshPortinteger
sshUserstring
passwordstring
privateKeystring

PEM private key

passphrasestring

Responses

POST/api/nodes/{id}/repinRe-pin the node server certificate
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    OK

POST/api/nodes/{id}/enabledEnable/disable a node
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
enabledrequiredboolean

Responses

  • 200

    OK

PUT/api/nodes/{id}/templateAssign an outbound/route template to a node
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
templateIdinteger

Responses

  • 200

    OK

PUT/api/nodes/{id}/routingSet the node's inline routing (overrides template route)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
routingJSON

An arbitrary JSON value (a core config fragment, etc.).

Responses

  • 200

    OK

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.

ParameterInTypeDescription
idrequiredpathinteger
hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

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

  • 200

    OK

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

ParameterInTypeDescription
idrequiredpathinteger
hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

Window end (unix seconds); must be given with start

Responses

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.

ParameterInTypeDescription
hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

Window end (unix seconds); must be given with start

limitqueryinteger

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.

ParameterInTypeDescription
hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

Window end (unix seconds); must be given with start

limitqueryinteger

Rows to return, heaviest first. Values above the maximum are clamped, not refused

Responses

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.

ParameterInTypeDescription
resourcerequiredquery"user" | "inbound" | "outbound" | "endpoint" | "node"

What the tag names

tagrequiredquerystring

The user name, the inbound/outbound/endpoint tag, or — for resource=node — the node's name. An unknown node name answers 404.

nodeIdqueryinteger

Narrow to one node; 0 or absent sums every node the tag runs on

hoursqueryinteger

Preset window, counted back from now. Ignored when start/end are given

startqueryinteger

Window start (unix seconds); must be given with end

endqueryinteger

Window end (unix seconds); must be given with start

Responses

GET/api/nodes/{id}/logsTail the node engine logs
ParameterInTypeDescription
idrequiredpathinteger
sincequeryinteger

Cursor from the previous call

Responses

  • 200

    OK

GET/api/nodes/{id}/infoNode host + runtime metrics (cpu/mem/uptime)
ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

Responses

POST/api/nodes/{id}/log-levelSet the node engine log level live (no restart)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
levelrequired"trace" | "debug" | "info" | "warn" | "error" | "fatal" | "panic"

Responses

  • 200

    OK

  • 409

    The node is switched off

POST/api/nodes/{id}/check-outboundURL-test an outbound/endpoint by tag (latency check)
ParameterInTypeDescription
idrequiredpathinteger
tagrequiredquerystring

Outbound or endpoint tag

linkquerystring

Test URL (default: generate_204)

Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body RouteTestRequest

FieldTypeDescription
inboundrequiredstring

The inbound's tag on the node

userstring

The user's name; empty for a connection that carries none

network"tcp" | "udp"

Default tcp

sourcestring

The client's address, an IP with or without a port; optional

addressrequiredstring

The destination as the client asked

portrequiredinteger
domainstring

The sniffed domain (TLS SNI, HTTP Host); optional

protocolstring

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body RouteTestRequest

FieldTypeDescription
inboundrequiredstring

The inbound's tag on the node

userstring

The user's name; empty for a connection that carries none

network"tcp" | "udp"

Default tcp

sourcestring

The client's address, an IP with or without a port; optional

addressrequiredstring

The destination as the client asked

portrequiredinteger
domainstring

The sniffed domain (TLS SNI, HTTP Host); optional

protocolstring

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body RuleSetTestRequest

FieldTypeDescription
tagsstring[]

Empty: every rule set the node's route declares

addressrequiredstring
portinteger

Responses

GET/api/nodes/{id}/certificateThe node's active managed TLS certificate (null when none)
ParameterInTypeDescription
idrequiredpathinteger

Responses

POST/api/nodes/{id}/certificateIssue (self-signed) or obtain (ACME) the node's certificate
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body CertRequest

FieldTypeDescription
typerequired"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs (required except for custom, where they derive from the PEM)

challenge"http-01" | "tls-alpn-01" | "dns-01"

ACME only

emailstring
dnsProvider"" | "cloudflare" | "alidns" | "acmedns"

dns-01 only; "" clears it

dnsTokenstring

dns-01 provider credential (multi-field split on '😂

certificatestring

PEM certificate chain (custom type only)

keystring

PEM private key (custom type only)

clientJSON

Client-side TLS template (see Certificate.client)

Responses

DELETE/api/nodes/{id}/certificateRemove the node's managed certificate (inbounds fall back to inline)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    OK

POST/api/nodes/{id}/certificate/renewReissue the node's certificate with its stored parameters
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

PUT/api/nodes/{id}/certificate/clientUpdate the node certificate's client-side TLS template (no reissue, no resync)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
clientJSON

An arbitrary JSON value (a core config fragment, etc.).

Responses

node-mappings

GET/api/nodes/{id}/outboundsA node's extra outbounds
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    OK

POST/api/nodes/{id}/outboundsAdd an outbound to a node
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Outbound

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring
configJSON

An arbitrary JSON value (a core config fragment, etc.).

createdAtinteger
updatedAtinteger

Responses

GET/api/nodes/{id}/endpointsA node's endpoints (wireguard/tailscale/warp)
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    OK

POST/api/nodes/{id}/endpointsAdd an endpoint to a node (live, no restart)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Endpoint

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring

wireguard, tailscale, openvpn-server, openconnect-server, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

useNodeCertboolean

Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this endpoint's TLS (wins over useNodeCert)

createdAtinteger
updatedAtinteger

Responses

tunnels

GET/api/tunnelsList tunnels
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Tunnel

FieldTypeDescription
idinteger
namestring

Unique. The config tag is tunnel-, where the slug lowercases the name, turns spaces and punctuation into hyphens and drops everything else; a name left with nothing usable (one written in a non-Latin script, say) falls back to the row id. Two names that slug the same are refused. The name cannot be changed after creation: it is the tag both halves carry and the namespace their tokens are derived in.

tagstring

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.

relayNodeIdinteger

Where users connect.

relayNamestring
originIdsinteger[]

Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to.

originNamesstring[]
profilesTunnelProfile[]

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. balance spreads over every profile that has a link, taking the session with the fewest open streams — so profiles share the load in proportion to their worker counts, and one that gets blocked simply stops being chosen. priority keeps to the first profile that has any link at all and uses the next only when it has none, which is a standby path rather than extra capacity; the order is the order of the profiles list. Neither mode decides which origin a user reaches: that is rendezvous hashing on the peer name, so a blocked protocol costs throughput and never a user's exit IP.

workersinteger

Parallel links per origin for a profile that sets none of its own, each with its own congestion window.

streamWindowinteger

Per-stream receive window in bytes; 0 takes the node default.

enabledboolean
ownsRelayFinalboolean

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.

createdAtinteger
updatedAtinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Tunnel

FieldTypeDescription
idinteger
namestring

Unique. The config tag is tunnel-, where the slug lowercases the name, turns spaces and punctuation into hyphens and drops everything else; a name left with nothing usable (one written in a non-Latin script, say) falls back to the row id. Two names that slug the same are refused. The name cannot be changed after creation: it is the tag both halves carry and the namespace their tokens are derived in.

tagstring

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.

relayNodeIdinteger

Where users connect.

relayNamestring
originIdsinteger[]

Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to.

originNamesstring[]
profilesTunnelProfile[]

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. balance spreads over every profile that has a link, taking the session with the fewest open streams — so profiles share the load in proportion to their worker counts, and one that gets blocked simply stops being chosen. priority keeps to the first profile that has any link at all and uses the next only when it has none, which is a standby path rather than extra capacity; the order is the order of the profiles list. Neither mode decides which origin a user reaches: that is rendezvous hashing on the peer name, so a blocked protocol costs throughput and never a user's exit IP.

workersinteger

Parallel links per origin for a profile that sets none of its own, each with its own congestion window.

streamWindowinteger

Per-stream receive window in bytes; 0 takes the node default.

enabledboolean
ownsRelayFinalboolean

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.

createdAtinteger
updatedAtinteger

Responses

DELETE/api/tunnels/{id}Delete a tunnel

Removes both halves from both nodes' configuration on the next sync.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

  • 404Error

    Error

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200TunnelReset

    OK

  • 404Error

    Error

  • 409Error

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
namerequiredstring

The copy's name, which becomes its tag and the namespace its credentials derive in.

relayNodeIdinteger

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
oprequired"enable" | "disable" | "delete" | "edit"

edit writes whichever of originIds, workers, streamWindow and balance the body carries, and at least one is required. originIds replaces the whole origin set and must name at least one node: a tunnel with no origin has no second half. A row already in the state asked for counts towards matched and not affected, and is not pushed to its nodes again — the origin set is compared as a set, so the order it arrives in says nothing.

idsrequiredinteger[]

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.

originIdsinteger[]
workersinteger
streamWindowinteger
balance"balance" | "priority"

Responses

settings

GET/api/settingsList settings (key/value)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

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.

ParameterInTypeDescription
keyrequiredpathstring
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
valuerequiredstring

Responses

GET/api/panel/infoPanel host + runtime metrics (the panel's own counterpart of /api/nodes/{id}/info)

Responses

  • 200PanelInfo

    OK

  • 403Error

    Error

  • 500Error

    The backup readout could not be read (sudo only); reporting it as "off, never backed up" would be the one answer an operator reads as deliberate.

GET/api/panel/updateThe release check, the install method and the last (or running) upgrade (sudo only)

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
historyqueryboolean

Include the traffic time series and hourly rollups (stats, node_usages, node_client_usages). These are the bulk of a busy panel's database.

auditqueryboolean

Include the audit log (changes).

X-Backup-Passphraseheaderstring

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

  • 200

    The archive

  • 403Error

    Error

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.

ParameterInTypeDescription
X-Backup-Passphraseheaderstring

Required for an encrypted archive; omitting it answers 400 with code encrypted.

Responses

  • 200BackupPreview

    OK

  • 400BackupError

    Not a readable archive. The body carries a stable code alongside error: encrypted (a passphrase is needed), passphrase (wrong passphrase, or a damaged file — AEAD cannot tell them apart), or format (not a backup, truncated, or a row count that disagrees with the manifest).

  • 403Error

    Error

  • 413BackupError

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

ParameterInTypeDescription
confirmrequiredquery"replace-database"

Must be the literal replace-database. A restore replaces every row the install has, and must not be reachable by retrying the wrong command.

keep_web_settingsqueryboolean

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_licensequeryboolean

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

Required for an encrypted archive.

Responses

  • 200BackupRestore

    Loaded — staged or already applied

  • 400BackupError

    Refused, 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, or format.

  • 403Error

    Error

  • 409BackupError

    A restore is already in progress (code busy)

  • 413BackupError

    Larger than 1 GiB (code too_large)

  • 500Error

    Error

  • 501BackupError

    This 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

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.

ParameterInTypeDescription
historyqueryboolean

Include the traffic time series and hourly rollups.

auditqueryboolean

Include the audit log.

X-Backup-Passphraseheaderstring

Encrypts this archive. Omitted, the schedule's own passphrase is used, so the directory is never half encrypted.

Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body BackupScheduleUpdate

FieldTypeDescription
enabledboolean
intervalHoursinteger

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.

dirstring

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.

keepinteger

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.

includeHistoryboolean
includeAuditboolean
passphrasestring

Empty leaves the stored one alone

clearPassphraseboolean

Removes the stored passphrase, so scheduled archives stop being encrypted

Responses

  • 200BackupSchedule

    OK

  • 400Error

    An unreadable body, a negative intervalHours or keep, a relative dir, an interval above a year, or passphrase sent together with clearPassphrase — which of the two was meant is not something to guess at.

  • 403Error

    Error

  • 500Error

    Error

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.

ParameterInTypeDescription
namerequiredpathstring

The archive's file name, exactly as the listing gives it. Any other name is refused — it never addressed a file.

Responses

DELETE/api/backups/{name}Delete one stored archive (sudo only)
ParameterInTypeDescription
namerequiredpathstring

The archive's file name, exactly as the listing gives it. Any other name is refused — it never addressed a file.

Responses

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.

ParameterInTypeDescription
namerequiredpathstring
X-Backup-Passphraseheaderstring

Responses

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.

ParameterInTypeDescription
namerequiredpathstring
confirmrequiredquery"replace-database"
keep_web_settingsqueryboolean
keep_licensequeryboolean
X-Backup-Passphraseheaderstring

Responses

  • 200BackupRestore

    Staged or applied; the panel is restarting

  • 400BackupError

    Refused: confirm, no_admins, encrypted, passphrase or format

  • 403Error

    Error

  • 404Error

    Error

  • 409BackupError

    A restore is already in progress (code busy)

  • 500Error

    Error

  • 501BackupError

    This 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}.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    The key that was stored

  • 502Error

    Error

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

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    The key that was stored

  • 502Error

    Error

GET/api/sub-themesSubscription page themes installed on disk

Responses

  • 200

    OK

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

  • 200

    OK

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, ".")), carry 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

  • 200

    OK

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body EventSubscriberRequest

FieldTypeDescription
namerequiredstring
urlrequiredstring

Absolute http(s) URL; no credentials in it

kind"manual" | "addon"

Create only; addon needs an API-token request or a tokenId

tokenIdinteger

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

eventsstring[]

Names from GET /api/events; empty = every event

adminIdinteger

0 = every event; otherwise only the events about this account

enabledboolean

Update only

secretstring

Create only; generated when empty

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body EventSubscriberRequest

FieldTypeDescription
namerequiredstring
urlrequiredstring

Absolute http(s) URL; no credentials in it

kind"manual" | "addon"

Create only; addon needs an API-token request or a tokenId

tokenIdinteger

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

eventsstring[]

Names from GET /api/events; empty = every event

adminIdinteger

0 = every event; otherwise only the events about this account

enabledboolean

Update only

secretstring

Create only; generated when empty

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

GET/api/webhooks/{id}/deliveriesA subscriber's delivery log, newest first (sudo)

Bounded by event_retention_days (default 7).

ParameterInTypeDescription
idrequiredpathinteger
limitqueryinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
didrequiredpathinteger

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

  • 200

    OK

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.

ParameterInTypeDescription
keyrequiredpathstring
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body PanelServiceUpdate

FieldTypeDescription
enabledboolean

Left out = unchanged

configobject

Settings to change; "" clears one

Responses

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.

ParameterInTypeDescription
namerequiredquerystring

The file's name, without a path.

Responses

  • 200

    Sent

  • 400Error

    Error

  • 409Error

    backup_telegram is off, or its chat or the Telegram service cannot be used now; the reason is in the body.

  • 411Error

    No Content-Length.

  • 413Error

    Over 50 MB.

  • 429Error

    Six files this hour already; Retry-After says when the next may go.

  • 502Error

    Telegram did not take the file.

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.

ParameterInTypeDescription
keyrequiredpathstring

Responses

  • 200

    The test ran; ok says how it went

  • 400Error

    Error

  • 404Error

    Error

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

  • 200

    OK

GET/api/me/telegram/eventsThe events the caller's chat can be given (session only)
ParameterInTypeDescription
langquery"en" | "fa" | "ru" | "zh"

Responses

  • 200

    As 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

FieldTypeDescription
lang"en" | "fa" | "ru" | "zh"
eventsstring[]

Responses

GET/api/me/telegram/pair/{code}Where the caller's own pairing code stands (session only)
ParameterInTypeDescription
coderequiredpathstring

Responses

PUT/api/me/telegram/chats/{id}Narrow, switch or re-language one of the caller's chats (session only)
ParameterInTypeDescription
idrequiredpathinteger

Request body

FieldTypeDescription
eventsstring[]
enabledboolean
lang"en" | "fa" | "ru" | "zh"

Responses

DELETE/api/me/telegram/chats/{id}Unpair one of the caller's chats (session only)
ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
limitqueryinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
didrequiredpathinteger

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
adminIdinteger
lang"en" | "fa" | "ru" | "zh"
eventsstring[]

The chat's filter from the start; empty = every event the account may read, including ones a later release adds

Responses

GET/api/services/telegram/pair/{code}Where a pairing code stands (sudo)
ParameterInTypeDescription
coderequiredpathstring

Responses

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.

ParameterInTypeDescription
langquery"en" | "fa" | "ru" | "zh"
adminIdqueryinteger

Responses

GET/api/services/telegram/chatsThe paired Telegram chats (sudo)

Responses

  • 200

    OK

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
eventsstring[]

Names from GET /api/events; empty = every event the account may read

enabledboolean
lang"en" | "fa" | "ru" | "zh"

Responses

DELETE/api/services/telegram/chats/{id}Unpair a chat (sudo)

Removes the chat with its subscriber and delivery log.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
limitqueryinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
didrequiredpathinteger

Responses

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.

ParameterInTypeDescription
langquery"en" | "fa" | "ru" | "zh"
adminIdqueryinteger

Responses

  • 200

    As GET /api/services/telegram/events

  • 400Error

    Error

GET/api/services/email/recipientsThe confirmed email addresses (sudo)

Responses

  • 200

    OK

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
adminIdinteger
addressrequiredstring
lang"en" | "fa" | "ru" | "zh"
eventsstring[]

The address's filter from the start; empty = every event the account may read, including ones a later release adds

Responses

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

FieldTypeDescription
idrequiredstring

From the pending confirmation

coderequiredstring

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body EmailRecipientUpdate

FieldTypeDescription
eventsstring[]

Names from GET /api/events; empty = every event the account may read

enabledboolean
lang"en" | "fa" | "ru" | "zh"

Responses

DELETE/api/services/email/recipients/{id}Remove an address (sudo)

Removes the address with its subscriber and delivery log.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
limitqueryinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
didrequiredpathinteger

Responses

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

  • 200

    OK

GET/api/me/email/eventsThe events the caller's address can be given (session only)
ParameterInTypeDescription
langquery"en" | "fa" | "ru" | "zh"

Responses

  • 200

    As 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

FieldTypeDescription
addressrequiredstring
lang"en" | "fa" | "ru" | "zh"
eventsstring[]

Responses

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

FieldTypeDescription
idrequiredstring
coderequiredstring

Responses

PUT/api/me/email/recipients/{id}Narrow, switch or re-language one of the caller's addresses (session only)
ParameterInTypeDescription
idrequiredpathinteger

Request body EmailRecipientUpdate

FieldTypeDescription
eventsstring[]

Names from GET /api/events; empty = every event the account may read

enabledboolean
lang"en" | "fa" | "ru" | "zh"

Responses

DELETE/api/me/email/recipients/{id}Remove one of the caller's addresses (session only)
ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
limitqueryinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
didrequiredpathinteger

Responses

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

GET/api/services/oidc/providersThe sign-in providers (a main admin's login; an API token is 403)

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body SSOProviderWrite

FieldTypeDescription
kindstring

Create only

namestring
enabledboolean
sortOrderinteger
configobject

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body SSOProviderWrite

FieldTypeDescription
kindstring

Create only

namestring
enabledboolean
sortOrderinteger
configobject

Responses

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)
ParameterInTypeDescription
idrequiredpathinteger

Responses

POST/api/services/oidc/providers/{id}/testCheck a provider with its saved settings (a main admin's login; an API token is 403)

OpenID Connect — its metadata and keys; OAuth 2.0 — that its authorization address answers. Recorded as the provider's last run.

ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    The check ran; ok says how it went

  • 403Error

    Error

  • 404Error

    Error

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body AddonManual

FieldTypeDescription
namerequiredstring
slugstring

The addon id; derived from the name when empty. Fixed once registered.

baseUrlrequiredstring
entryUrlstring

A path under baseUrl or an absolute URL

healthPathstring
tokenobject
webhookobject

Responses

  • 200Addon

    Registered; credentials is in this response only

  • 400Error

    Error

  • 409Error

    Error

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

FieldTypeDescription
urlrequiredstring

Responses

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

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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

ParameterInTypeDescription
slugrequiredpathstring

Responses

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

  • 200

    OK

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
slugrequiredstring
method"script" | "docker" | "compose"

Empty = the first the plan offers

baseUrlrequiredstring

Where the panel will reach the addon once it runs (plain http only on a private address)

panelUrlstring

Where the addon reaches the panel; empty = the panel as this request reached it

answersobject

By option key, a JSON value of the option's type

sshAddonSSH

How the panel reaches an addon's host. Credentials are used for the connection and never stored.

certificateIdinteger

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)
ParameterInTypeDescription
jobrequiredpathstring
cursorqueryinteger

Lines already read; the answer's cursor is the next one

Responses

POST/api/addons/jobs/{job}/cancelStop a running addon job (sudo; no token) — what it already did on the host stays done
ParameterInTypeDescription
jobrequiredpathstring

Responses

  • 200

    Cancelling

  • 404Error

    Error

GET/api/addons/hostsHosts the panel installed an addon on over SSH (sudo; no token)

Responses

  • 200

    OK

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.

ParameterInTypeDescription
slugrequiredpathstring
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body AddonSSH

FieldTypeDescription
sshHoststring
sshPortinteger
sshUserstring

Default root; another user escalates with sudo

passwordstring
privateKeystring
passphrasestring
installKeyboolean

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

localboolean

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

ParameterInTypeDescription
slugrequiredpathstring
Idempotency-Keyheaderstring

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
ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 204

    Cancelled

  • 404Error

    Error

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
digestrequiredstring
certSha256string

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
urlrequiredstring
claimCoderequiredstring
digestrequiredstring
certSha256string

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body AddonManual

FieldTypeDescription
namerequiredstring
slugstring

The addon id; derived from the name when empty. Fixed once registered.

baseUrlrequiredstring
entryUrlstring

A path under baseUrl or an absolute URL

healthPathstring
tokenobject
webhookobject

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Removed

  • 404Error

    Error

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
entryUrlrequiredstring

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
certificateIdrequiredinteger

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.

ParameterInTypeDescription
idrequiredpathinteger

Responses

  • 200

    What it serves

  • 404Error

    Error

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
sha256requiredstring

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.

ParameterInTypeDescription
If-None-Matchheaderstring

Responses

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

FieldTypeDescription
slugrequiredstring
claimCoderequiredstring

Responses

POST/api/addons/{id}/suspendSuspend an addon (sudo; no token) — a main admin holding every permission

Its token stops authenticating and its subscription stops receiving (events raised meanwhile are not queued for it); nothing is deleted and its health path is not asked. Suspending a suspended addon is a no-op.

ParameterInTypeDescription
idrequiredpathinteger

Responses

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.

ParameterInTypeDescription
idrequiredpathinteger

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.

ParameterInTypeDescription
idrequiredpathinteger

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
digestrequiredstring

Responses

install

GET/api/mtls/certificateThe Panel mTLS client certificate (PEM)

Responses

  • 200

    OK

POST/api/tokens/node-installCreate a single-use node-install token (sudo only)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
descstring
expiryinteger

Responses

GET/install-node.shThe node installer script

Responses

  • 200

    Shell script

GET/install/caFetch the Panel client CA PEM (guarded by a single-use token)
ParameterInTypeDescription
tokenrequiredquerystring

Responses

GET/install/node-binaryDownload the node binary for an arch
ParameterInTypeDescription
archquery"amd64" | "arm64"

Responses

  • 200

    Binary

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.

ParameterInTypeDescription
tokenrequiredpathstring

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.

ParameterInTypeDescription
tokenrequiredpathstring

Responses

  • 200

    HTML page

  • 404Error

    Error

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.

ParameterInTypeDescription
tokenrequiredpathstring

Responses

GET/sub/{token}/_assets/{path}A static file of the active subscription theme (public, token in path)
ParameterInTypeDescription
tokenrequiredpathstring
pathrequiredpathstring

Responses

  • 200

    The file

  • 404Error

    Error

tools

POST/api/tools/ech-keypairGenerate an ECH keypair (server "ECH KEYS" PEM + client "ECH CONFIGS" PEM) for a public name
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
publicNamerequiredstring

The outer SNI clients present

Responses

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
templatestring

Empty previews the fixed format that predates the setting

Responses

POST/api/tools/reality-keypairGenerate a REALITY x25519 keypair and short id (or derive the public key of a supplied private key)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
privateKeystring

Responses

POST/api/tools/vless-encryptionMint a VLESS Encryption server string (the inbound's `decryption`), or derive the client string of one
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

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

secondsinteger

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.

decryptionstring

An existing server string, to derive its client string from

Responses

meta

GET/api/openapi.jsonThis OpenAPI document (JSON)

Responses

  • 200

    OpenAPI 3 document

GET/api/openapi.yamlThis OpenAPI document (YAML)

Responses

  • 200

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

ParameterInTypeDescription
tquerystring

The one-time setup token (nexora-panel setup-token reprints it). Required while setup is pending; without it this route answers 404 like every other.

Responses

  • 200SetupState

    Setup state

  • 404

    Setup is pending and the request carried no valid t token — 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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body SetupRequest

FieldTypeDescription
tokenrequiredstring

The one-time setup token printed by the installer.

usernamerequiredstring
passwordrequiredstring

At least 12 characters mixing three of lowercase, uppercase, digits and symbols.

listenIpstring

An IP literal, or empty to bind every interface.

listenPortrequiredstring
basepathstring

URI prefix the panel is served under; empty serves at the root.

subBasepathstring

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.

certSansstring[]

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.

domainstring
subDomainstring
timezonestring

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.

ParameterInTypeDescription
trequiredquerystring

The setup token the installer printed.

X-Backup-Passphraseheaderstring

Responses

  • 200BackupPreview

    OK

  • 400BackupError

    Not a readable archive

  • 403Error

    Error

  • 404

    Missing or wrong t while 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.)

  • 409

    Setup is complete; this route is closed and the authenticated one takes over

  • 413BackupError

    Larger than 1 GiB (code too_large)

  • 500Error

    Error

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.

ParameterInTypeDescription
trequiredquerystring

The setup token the installer printed.

confirmrequiredquery"replace-database"
X-Backup-Passphraseheaderstring

Responses

  • 200BackupRestore

    Restored; the panel is restarting

  • 400BackupError

    Refused: confirm, no_admins, encrypted, passphrase or format

  • 403Error

    Error

  • 404

    Missing or wrong t while 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.)

  • 409BackupError

    Setup is complete (plain Error), or a restore is already in progress (code busy, a BackupError)

  • 413BackupError

    Larger than 1 GiB (code too_large)

  • 500Error

    Error

  • 501BackupError

    This database or runtime cannot be restored in place (code unsupported)

pools

GET/api/outboundsList pool outbounds (selectable by templates)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/outboundsCreate a pool outbound
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Outbound

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring
configJSON

An arbitrary JSON value (a core config fragment, etc.).

createdAtinteger
updatedAtinteger

Responses

GET/api/endpointsList pool endpoints (selectable by templates)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

POST/api/endpointsCreate a pool endpoint
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Endpoint

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring

wireguard, tailscale, openvpn-server, openconnect-server, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

useNodeCertboolean

Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this endpoint's TLS (wins over useNodeCert)

createdAtinteger
updatedAtinteger

Responses

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

GET/api/rule-setsList rule sets (remote lists the panel mirrors, selectable by templates)
ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

updated_sincequeryinteger

Only rows with updated_at >= this unix timestamp, for incremental sync. Ignored by resources without an updated_at column (settings, tokens).

Responses

  • 200

    OK

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.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body RuleSet

FieldTypeDescription
idinteger
tagstring

The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation.

urlstring

Absolute http(s) upstream the panel downloads from. Never sent to a node.

updateIntervalHoursinteger

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

availableboolean

The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations.

sizeinteger
ruleCountinteger
contentHashstring

sha256 of the mirrored bytes; a node already holding them is not pushed them again

lastFetchedAtinteger

Unix seconds; 0 = never downloaded

lastAttemptAtinteger
lastErrorstring

Why the last refresh did not land. The previous copy is kept.

createdAtinteger
updatedAtinteger

Responses

  • 200RuleSet

    OK

  • 409Error

    Tag already taken

  • 502Error

    The upstream could not be downloaded or is not a rule set

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.

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body RuleSet

FieldTypeDescription
idinteger
tagstring

The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation.

urlstring

Absolute http(s) upstream the panel downloads from. Never sent to a node.

updateIntervalHoursinteger

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

availableboolean

The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations.

sizeinteger
ruleCountinteger
contentHashstring

sha256 of the mirrored bytes; a node already holding them is not pushed them again

lastFetchedAtinteger

Unix seconds; 0 = never downloaded

lastAttemptAtinteger
lastErrorstring

Why the last refresh did not land. The previous copy is kept.

createdAtinteger
updatedAtinteger

Responses

  • 200RuleSet

    OK

  • 409Error

    Tag already taken or fixed

  • 502Error

    A changed upstream could not be downloaded

DELETE/api/rule-sets/{id}Delete a rule set
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

  • 409Error

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

ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

PUT/api/outbounds/{id}Update a pool outbound
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Outbound

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring
configJSON

An arbitrary JSON value (a core config fragment, etc.).

createdAtinteger
updatedAtinteger

Responses

DELETE/api/outbounds/{id}Delete an outbound (pool or per-node)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

PUT/api/endpoints/{id}Update a pool endpoint
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body Endpoint

FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring

wireguard, tailscale, openvpn-server, openconnect-server, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

useNodeCertboolean

Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this endpoint's TLS (wins over useNodeCert)

createdAtinteger
updatedAtinteger

Responses

DELETE/api/endpoints/{id}Delete an endpoint (pool or per-node)
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Responses

  • 200

    Deleted

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.

ParameterInTypeDescription
limitqueryinteger

Page size. Defaults to 100 under /api/v1 (max 1000); unlimited under /api unless given.

offsetqueryinteger

Rows to skip before the page.

qquerystring

Case-insensitive substring search over the resource's text columns.

Responses

  • 200

    OK

POST/api/frontsAdd a domain front
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body DomainFrontInput

FieldTypeDescription
labelrequiredstring
addressrequiredstring
portinteger
snistring
hostHeaderstring
alpnJSON

An arbitrary JSON value (a core config fragment, etc.).

fingerprintstring
allowInsecureboolean
clientIpHeaderstring

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.

originNodeIdinteger
enabledboolean

Absent leaves it unchanged on edit, and defaults to true on create

sortinteger
inboundIdsinteger[]

Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound.

Responses

  • 201DomainFront

    Created

  • 400Error

    Invalid, or an inbound named here cannot sit behind a CDN — REALITY, QUIC (hysteria/hysteria2/tuic), port hopping, or a transport a CDN cannot forward

  • 409Error

    The catalogue is full, or an inbound already carries the maximum number of fronts

PUT/api/fronts/{id}Edit a domain front
ParameterInTypeDescription
idrequiredpathinteger
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body DomainFrontInput

FieldTypeDescription
labelrequiredstring
addressrequiredstring
portinteger
snistring
hostHeaderstring
alpnJSON

An arbitrary JSON value (a core config fragment, etc.).

fingerprintstring
allowInsecureboolean
clientIpHeaderstring

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.

originNodeIdinteger
enabledboolean

Absent leaves it unchanged on edit, and defaults to true on create

sortinteger
inboundIdsinteger[]

Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound.

Responses

DELETE/api/fronts/{id}Remove a domain front
ParameterInTypeDescription
idrequiredpathinteger

Responses

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

  • 200

    OK

POST/api/bansBan an address or a prefix (sudo)

Refused (400) when the prefix contains the address the request itself comes from.

ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

Request body

FieldTypeDescription
prefixrequiredstring

An IP address or a CIDR

reasonstring
hoursinteger

Lifetime in hours; 0 or absent = permanent

Responses

DELETE/api/bans/{id}Lift a ban (sudo)
ParameterInTypeDescription
Idempotency-Keyheaderstring

Replay-safe retry key; see the Idempotency note in the API description.

idrequiredpathinteger

Responses

Schemas

Addon
FieldTypeDescription
idrequiredinteger
slugrequiredstring

The addon id: its token's addon

namerequiredstring
versionstring
publisherstring
baseUrlrequiredstring
entryUrlstring

The link to the addon's own interface; empty for none

healthPathstring

Polled when set; empty for no check

signedrequiredboolean
signedBystring

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"
healthAtinteger

When the health path was last asked; 0 = never

healthFailuresinteger

Failed asks in a row; two make the addon unhealthy

lastErrorstring

The last failed ask, cleared by the next 200

manifestAtinteger

When a signed addon's manifest was last read for an update

pendingobject

A signed addon's newer manifest that asks for more than it holds — only the additions

tokenIdinteger
subscriberIdinteger
certificateIdinteger

The panel certificate the addon fetches and serves its public address with; null when it gets its own or serves none

localboolean

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

pinnedCertSha256string

The addon's own certificate the main admin trusted (hex SHA-256); empty for none

registeredAtinteger
updatedAtinteger
purposesobject

A signed addon's reason for each scope

tokenrequiredobject
webhookrequiredobject
credentialsobject

The token and the webhook signing secret, in the response that created them only

AddonCertificate
FieldTypeDescription
certificaterequiredstring

PEM chain

keyrequiredstring

PEM private key

notAfterrequiredinteger
sha256requiredstring

Hex SHA-256 of the leaf's DER; also the ETag

AddonConsent
FieldTypeDescription
baseUrlrequiredstring
digestrequiredstring

Sent back by the registration, which refuses a manifest that changed since

slugrequiredstring
namerequiredstring
versionrequiredstring
publisherrequiredstring
signedByrequiredstring

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

scopesrequiredobject[]
rateLimitrequiredinteger

Requests a minute the token may make: the manifest's rateLimit (at most 600), else the panel's default, 120.

eventsrequiredobject[]
webhookUrlrequiredstring
setupUrlrequiredstring
healthUrlrequiredstring
entryUrlrequiredstring
certSha256string

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.

certNotAfterinteger

When that certificate ends (Unix seconds)

AddonData

One addon's document on one user or admin.

FieldTypeDescription
userIdinteger

Present on a user row

adminIdinteger

Present on an admin row

addonIdrequiredstring
datarequiredobject

Exactly the object the addon last wrote; {} when never written

versionrequiredinteger

Number of writes so far; 0 when never written. Send it back on PUT for a conditional write.

updatedAtrequiredinteger

Unix seconds of the last write, 0 when never written

AddonDataRequest
FieldTypeDescription
datarequiredobject

The whole document; at most 16 KiB

versioninteger

The version last read. Absent = unconditional write.

AddonDirectory
FieldTypeDescription
urlrequiredstring

Where the index is read from

sourcerequiredstring

Where the index held was read from; differs from url after the address changed and the new one has not passed yet

defaultrequiredboolean

url is addons.nexora-panel.org's, not a mirror

checkEnabledrequiredboolean

The update_check setting, which the daily read follows

siterequiredstring

The directory's own address, from the signed index; empty before the first read

generatedAtrequiredinteger

Unix seconds the index held was built

fetchedAtrequiredinteger

Unix seconds of the last read that passed; 0 = never

checkedAtrequiredinteger

Unix seconds of the last attempt

errorstring

Why the last attempt failed

updatesrequiredinteger

How many registered addons the directory lists a newer version of

addonsrequiredAddonDirectoryEntry[]
AddonDirectoryEntry
FieldTypeDescription
slugrequiredstring
tierrequired"official" | "verified" | "unofficial"
reporequiredstring

The GitHub repository (owner/name)

developerrequiredstring
featuredrequiredboolean
namerequiredstring
versionrequiredstring
publisherrequiredstring
descriptionobject

By language: en, fa, ru, zh

licenserequiredstring
paidrequiredboolean
docsrequiredstring
requiresPanelrequiredstring

The manifest's requires.panel, e.g. >=0.1.0

compatiblerequiredboolean

This panel meets requiresPanel

scopesrequiredobject[]
eventsrequiredstring[]
dockerrequiredboolean

The addon installs as a container

platformsrequiredstring[]

Platforms it ships a binary for (linux-amd64, …)

optionsrequiredinteger

How many questions its install asks

installScriptrequiredboolean

Its repository ships install.sh

releaseobject
stalerequiredboolean

The directory's latest read of it failed; this is its last good manifest

errorstring
signedByrequired"nexora" | "developer" | "development" | "unknown" | "none"

Who signed its manifest, as this panel sees it

pagerequiredstring

The addon's page on the directory

registeredobject

The addon registered here under this slug, if any

AddonHost
FieldTypeDescription
idrequiredinteger
slugrequiredstring
kindrequired"ssh" | "local"

ssh: host, port and user name the login; local: the panel's own server

mayMoveLocalrequiredboolean

An SSH login on the panel's own server, where local installs are possible: an update may move it to a local install

hostrequiredstring
portrequiredinteger
userrequiredstring
keyInstalledrequiredboolean
methodrequiredstring
versionrequiredstring
installedAtrequiredinteger
updatedAtrequiredinteger
AddonInstall
FieldTypeDescription
idrequiredinteger
slugrequiredstring
namerequiredstring
versionrequiredstring
methodrequired"script" | "docker" | "compose"
baseUrlrequiredstring
signedrequiredboolean

Registers by the issued claim code; false = added by hand

createdAtrequiredinteger
expiresAtrequiredinteger
checkedAtrequiredinteger
lastErrorrequiredstring

Why the last check found it not ready

certificateIdinteger

The panel certificate chosen for it

localboolean

It is to run on the panel's own server; carried to the addon by its registration

AddonInstallCheck
FieldTypeDescription
readyrequiredboolean
reasonstring
consentAddonConsent
AddonInstallCreated
FieldTypeDescription
installrequiredAddonInstall
commandstring

Run on the addon's host as root; shown once

secretrequiredboolean

The command carries a secret answer

registrationrequired"claim" | "manual"
jobAddonJob
AddonInstallOption
FieldTypeDescription
keyrequiredstring
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

labelrequiredobject
helpobject
defaultobject

A JSON value of the option's type

requiredrequiredboolean
choicesstring[]
whenobject

Shown only while that earlier option has that value

envrequiredstring

The environment variable the answer reaches the addon in

AddonInstallPlan
FieldTypeDescription
entryrequiredAddonDirectoryEntry
optionsrequiredAddonInstallOption[]
methodsrequired"script" | "docker" | "compose"[]
panelUrlrequiredstring
registrationrequired"claim" | "manual"
automaticboolean

The panel may install it over SSH: official or verified, signed, with install.sh and a release

automaticReasonstring

Why not

localboolean

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

localReasonstring

Why not on its own server, when it may not

AddonJob
FieldTypeDescription
idstring
addonstring
action"install" | "update" | "uninstall"
status"running" | "done" | "error"
messagestring
startedinteger
finishedinteger
AddonManual
FieldTypeDescription
namerequiredstring
slugstring

The addon id; derived from the name when empty. Fixed once registered.

baseUrlrequiredstring
entryUrlstring

A path under baseUrl or an absolute URL

healthPathstring
tokenobject
webhookobject
AddonRef

The addon a token or a subscription belongs to; such a grant is changed on the Addons page.

FieldTypeDescription
idinteger
namestring
signedboolean
AddonSSH

How the panel reaches an addon's host. Credentials are used for the connection and never stored.

FieldTypeDescription
sshHoststring
sshPortinteger
sshUserstring

Default root; another user escalates with sudo

passwordstring
privateKeystring
passphrasestring
installKeyboolean

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

localboolean

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
FieldTypeDescription
idinteger
usernamestring
rolestring

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

volumeinteger

Reseller allowance in bytes, 0 = unlimited (ignored for other roles)

periodResalePeriod

How often a reseller's volume allowance resets ("" = never).

periodDayinteger

Day the allowance resets on (0-6 weekly, day-of-month monthly/yearly)

expiryinteger

Reseller account expiry, unix seconds, 0 = never

userLimitinteger

Max users the reseller may create, 0 = unlimited

usersReadOnlyboolean

Reseller: refuse PUT and DELETE on its users. Creating is unaffected. Phrased as a restriction so an absent field restricts nothing.

plansOnlyboolean

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

planIdsinteger[]

Plans this reseller may sell (only meaningful with plansOnly). Absent on update leaves the selection alone.

subDomainstring

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.

usedinteger

Reseller: traffic its users burned in the current period

usersinteger

Reseller: how many users it owns

lastLogininteger
lastLoginIpstring

Address of the most recent successful login; the next login from elsewhere is recorded as admin.login_new_ip

totpEnabledAtinteger

Unix seconds two-factor authentication was turned on; 0 = off. The seed itself is never exposed.

isOwnerboolean

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.

createdAtinteger
updatedAtinteger
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.

FieldTypeDescription
idinteger
adminIdinteger
ipstring

Address the login came from, as the panel saw it

userAgentstring

Browser or client that logged in; capped, control characters stripped

rememberboolean

A "remember me" login (30-day sliding lifetime instead of 24 hours)

createdAtinteger
lastSeenAtinteger

Last activity, refreshed at most once a minute

expiresAtinteger

When the session lapses if unused; slides with lastSeenAt

currentboolean

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.

FieldTypeDescription
idinteger
tokenstring

Plaintext, returned once by POST /api/tokens

descstring
roleAdminRole

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.

adminIdinteger

Operator the token acts as; 0 = a legacy unbound token acting on the whole panel

ownerstring

Username of that operator

scopesstring[]

Empty = unrestricted within the role

addonstring

Addon id the token belongs to; empty for a token that is not an addon

rateLimitinteger

Requests per minute, 0 = panel default

expiryinteger

Unix seconds, 0 = never

lastUsedAtinteger

Unix seconds of the last authenticated request, 0 = never used

lastUsedIpstring
createdAtinteger
managedByAddonRef

The addon a token or a subscription belongs to; such a grant is changed on the Addons page.

BackupError
FieldTypeDescription
errorstring
code"encrypted" | "passphrase" | "format" | "too_large" | "confirm" | "busy" | "no_admins" | "unsupported"
BackupFile

One archive in the panel host's backup directory.

FieldTypeDescription
namestring

File name; the identifier every other route on this collection takes

sizeinteger
created_atinteger

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.

encryptedboolean
kind"backup" | "pre-restore"

backup is a scheduled or manually taken backup. pre-restore is the safety copy taken of a database that was about to be replaced — retention never deletes one, and it does not count as evidence that the panel has been backed up.

manifestBackupManifest

The archive's own account of itself, written as its first entry.

BackupList
FieldTypeDescription
dirstring

Where the archives live on the panel host

filesBackupFile[]

Newest first

totalinteger
bytesinteger

Total size of the directory's archives

scheduleBackupSchedule
BackupManifest

The archive's own account of itself, written as its first entry.

FieldTypeDescription
formatinteger

Archive layout version; a panel refuses anything newer than it understands

panel_versionstring

The build that wrote it

driver"sqlite" | "postgres"
hwidstring

The source host's fingerprint. The licence is locked to it, so an archive restored elsewhere comes up unlicensed.

web_domainstring
basepathstring
created_atinteger

Unix seconds

encryptedboolean
contentsobject
tablesBackupTable[]
skippedobject

Table name → why it was left out (transient, excluded, or absent from the source schema)

BackupPreview
FieldTypeDescription
manifestBackupManifest

The archive's own account of itself, written as its first entry.

encryptedboolean

Whether the archive was passphrase-protected. Only ever seen after a successful decrypt — an archive that could not be opened answers 400 with code encrypted or passphrase instead, which is the signal a UI keys a passphrase prompt off.

tablesBackupTable[]

What was actually found, verified against the manifest

warningsBackupWarning[]

Most consequential first

sizeinteger

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

FieldTypeDescription
manifestBackupManifest

The archive's own account of itself, written as its first entry.

tablesinteger

Tables loaded

rowsinteger

Rows loaded

snapshotstring

Path on the panel host of the full backup taken of the database being replaced

stagedboolean

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.

keptstring[]

Settings this installation held on to instead of taking the archive's

skipped_tablesstring[]

Tables in the archive this schema no longer has

dropped_columnsobject

Table → columns the archive carried that this schema does not. This is how an archive from another version loads at all.

restartingboolean
BackupSchedule
FieldTypeDescription
enabledboolean
intervalHoursinteger

How often to take one; the hourly scheduler decides due-ness from the newest archive on disk

dirstring
keepinteger

How many backups to keep; 0 keeps everything. Pre-restore snapshots are never pruned.

includeHistoryboolean
includeAuditboolean
passphraseSetboolean

Whether scheduled archives are encrypted. The passphrase itself is never returned.

nextDueinteger

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.

FieldTypeDescription
enabledboolean
intervalHoursinteger

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.

dirstring

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.

keepinteger

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.

includeHistoryboolean
includeAuditboolean
passphrasestring

Empty leaves the stored one alone

clearPassphraseboolean

Removes the stored passphrase, so scheduled archives stop being encrypted

BackupTable
FieldTypeDescription
namestring
rowsinteger
BackupWarning

One advisory about restoring this archive here. None of them blocks a restore; each is a decision for the operator.

FieldTypeDescription
code"no_admins" | "hwid_mismatch" | "mtls_changed" | "node_pin_changed" | "newer_panel" | "different_panel" | "no_history" | "no_audit"

no_admins — the archive holds no main-admin (sudo) account. This is the one advisory a restore also enforces (400 no_admins), and the preview applies the same test so the two cannot disagree. hwid_mismatch — a different host fingerprint: the licence travelling inside will not validate here. mtls_changed — the archive holds a different panel mTLS client certificate, which is the CA every node was installed to trust: restoring it makes every node refuse this panel until each is reinstalled. node_pin_changed — the archive's pinned server certificate for one or more nodes (named in detail) differs from the live one, so those nodes' control APIs would be refused until they are repinned. newer_panel — written by a newer build than this one. different_panel — a different web domain, the visible sign of an archive from another install. no_history / no_audit — the optional groups were excluded, so a restore empties the usage graphs / the audit log.

detailstring

The specifics the message needs (a version, a fingerprint, a hostname)

Certificate
FieldTypeDescription
idinteger
nodeIdinteger
type"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs

challenge"" | "http-01" | "tls-alpn-01" | "dns-01"
certificatestring

PEM certificate chain (public)

clientJSON

Client-side TLS template for links/subscriptions: server_name, insecure, alpn, utls.fingerprint, certificate_public_key_sha256

publicKeySha256string

Hex SHA-256 of the leaf SubjectPublicKeyInfo (the client field certificate_public_key_sha256)

certSha256string

Hex SHA-256 of the leaf DER (hysteria2 pinSHA256 / mihomo fingerprint)

issuerstring
notBeforeinteger
notAfterinteger
status"active" | "pending" | "error"
messagestring
createdAtinteger
updatedAtinteger
CertRequest
FieldTypeDescription
typerequired"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs (required except for custom, where they derive from the PEM)

challenge"http-01" | "tls-alpn-01" | "dns-01"

ACME only

emailstring
dnsProvider"" | "cloudflare" | "alidns" | "acmedns"

dns-01 only; "" clears it

dnsTokenstring

dns-01 provider credential (multi-field split on '😂

certificatestring

PEM certificate chain (custom type only)

keystring

PEM private key (custom type only)

clientJSON

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.

FieldTypeDescription
idinteger
dateTimeinteger

Unix seconds

actorstring

Operator username, or "system" for an unattended action

keystring

Entity type: user, inbound, outbound, endpoint, node, template, certificate, panel_certificate, admin, token, license, panel, changes

actionstring

create | update | delete, plus per-entity verbs (issue, renew, purge, restart, …)

objobject

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.

FieldTypeDescription
nodeIdinteger

One node; 0 or absent means every connected node

inboundstring
outboundstring
CloseConnsResult
FieldTypeDescription
closedrequiredinteger

Connections dropped

nodesrequiredinteger

Nodes that answered

unsupportedinteger

Nodes too old to know the route

failedinteger

Nodes that could not be reached in time

DomainFront
FieldTypeDescription
idinteger
labelstring

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

addressstring

The hostname a client dials — the CDN's, not the node's

portinteger

null means 443. A CDN's port is unrelated to the inbound's listen port, which is what the origin sees

snistring
hostHeaderstring
alpnJSON

An arbitrary JSON value (a core config fragment, etc.).

fingerprintstring
allowInsecureboolean
clientIpHeaderstring

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

originNodeIdinteger

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.

enabledboolean
sortinteger

Order within the catalogue, which is the order the entries render in

inboundsInbound[]
warningsstring[]

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.

createdAtinteger
updatedAtinteger
DomainFrontInput
FieldTypeDescription
labelrequiredstring
addressrequiredstring
portinteger
snistring
hostHeaderstring
alpnJSON

An arbitrary JSON value (a core config fragment, etc.).

fingerprintstring
allowInsecureboolean
clientIpHeaderstring

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.

originNodeIdinteger
enabledboolean

Absent leaves it unchanged on edit, and defaults to true on create

sortinteger
inboundIdsinteger[]

Replaces which inbounds this front is enabled on. Absent leaves the membership alone on an edit; an explicit empty list detaches every inbound.

EmailPending
FieldTypeDescription
idstring

Names this confirmation in …/confirm

adminIdinteger
addressstring
langstring
eventsstring[]
expiresAtinteger
EmailRecipient
FieldTypeDescription
idinteger
addressstring
adminIdinteger

The account the address belongs to

usernamestring
lang"en" | "fa" | "ru" | "zh"
addedAtinteger
subscriberIdinteger
eventsstring[]
enabledboolean
failuresinteger
disabledAtinteger
EmailRecipientUpdate
FieldTypeDescription
eventsstring[]

Names from GET /api/events; empty = every event the account may read

enabledboolean
lang"en" | "fa" | "ru" | "zh"
Endpoint
FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring

wireguard, tailscale, openvpn-server, openconnect-server, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

useNodeCertboolean

Replace a server endpoint's (openvpn/openconnect-server) TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this endpoint's TLS (wins over useNodeCert)

createdAtinteger
updatedAtinteger
Error
FieldTypeDescription
errorstring
inProgressboolean

On a 409 only: the first request under this Idempotency-Key is still running. Retry with the same key.

EventDelivery
FieldTypeDescription
idinteger

The X-Nexora-Delivery value

outboxIdinteger

The event's id (the envelope's id)

eventstring
status"pending" | "delivered" | "dead"
attemptinteger
statusCodeinteger

The receiver's last answer; 0 when none came back

errorstring
createdAtinteger
nextAtinteger

When the next attempt is due; 0 when none

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

FieldTypeDescription
namestring
family"user" | "node" | "admin" | "panel"
scopestring

"" when any token may read it

descriptionstring
payloadobject[]
EventSubscriber
FieldTypeDescription
idinteger
kind"manual" | "addon"
namestring
urlstring
eventsstring[]
adminIdinteger
tokenIdinteger

The API token the addon subscriber belongs to (registered through it, or named by the main admin); 0 for a manual entry

enabledboolean
signedboolean

Whether a secret is set; the migrated legacy webhook has none

failuresinteger

Consecutive failed deliveries

failingSinceinteger

Unix seconds the current failure streak began, 0 when none

disabledAtinteger
createdAtinteger
updatedAtinteger
secretstring

Present in the create and rotate responses only

managedByAddonRef

The addon a token or a subscription belongs to; such a grant is changed on the Addons page.

EventSubscriberRequest
FieldTypeDescription
namerequiredstring
urlrequiredstring

Absolute http(s) URL; no credentials in it

kind"manual" | "addon"

Create only; addon needs an API-token request or a tokenId

tokenIdinteger

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

eventsstring[]

Names from GET /api/events; empty = every event

adminIdinteger

0 = every event; otherwise only the events about this account

enabledboolean

Update only

secretstring

Create only; generated when empty

Inbound
FieldTypeDescription
idinteger
tagstring
typestring

vless, vmess, trojan, shadowsocks, ...

configJSON

An arbitrary JSON value (a core config fragment, etc.).

remarkstring
useNodeCertboolean

Replace this inbound's TLS cert with the node's managed cert

certificateIdinteger

Panel certificate assigned to this inbound's TLS (wins over useNodeCert)

clientConfigJSON

Client-side overrides used only for link/subscription generation (never sent to nodes): server, server_port, and a tls client template

frontedOnlyboolean

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.

createdAtinteger
updatedAtinteger
InstallJob
FieldTypeDescription
idstring
nodeIdinteger
status"running" | "done" | "error"
messagestring
methodstring
versionstring
startedinteger
finishedinteger
InstallRequest
IPBan
FieldTypeDescription
idinteger
prefixstring

An IP address or a CIDR

reasonstring
source"auto" | "manual"
createdBystring

The admin, or system for an automatic ban

createdAtinteger
expiresAtinteger

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.

FieldTypeDescription
itemsobject[]
totalinteger

Rows matching the filters, before limit/offset

limitinteger
offsetinteger
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.

FieldTypeDescription
idinteger

The operator this session acts as; 0 for a legacy unbound API token

usernamestring

For an API token: "token:" followed by its description

rolestring

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.

baseAdminRole

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.

ownerboolean

Whether this account is the panel's owner. Absent for API-token sessions.

tokenboolean

True when the caller is an API token rather than a login. Absent otherwise.

totpEnabledboolean

Whether the account has two-factor authentication on. Absent for API tokens.

scopesstring[]

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

dashboardTilesstring

Pinned dashboard tiles in order, comma-separated. null = never chosen (defaults apply), "" = nothing pinned. Absent for API-token sessions.

timezonestring

Panel-wide IANA timezone the UI renders timestamps in (the timezone setting). Empty = the panel host's own zone.

listenPinnedboolean

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.

volumeinteger

Reseller: byte allowance per period, 0 = unlimited

usedinteger

Reseller: traffic its users burned in the current period

periodResalePeriod

How often a reseller's volume allowance resets ("" = never).

periodDayinteger

Reseller: day the allowance resets on (0-6 weekly, day-of-month otherwise)

periodStartinteger

Reseller: unix start of the current period, 0 = no periodic reset

expiryinteger

Reseller: account expiry, unix seconds, 0 = never

userLimitinteger

Reseller: max users it may create, 0 = unlimited

usersinteger

Reseller: how many users it currently owns

blockedboolean

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.

FieldTypeDescription
mfaRequiredtrue
pendingstring

Opaque, five minutes, single use

Node
FieldTypeDescription
idinteger
licensedboolean

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.

namestring
addressstring
portinteger
status"connecting" | "connected" | "error" | "disabled"
messagestring

Why the node is not working; empty while it is connected

warningsstring[]

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.

remarkstring
linkAddressstring

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.

isLocalboolean
routingJSON

An arbitrary JSON value (a core config fragment, etc.).

coreVersionstring
templateIdinteger
multipliernumber

Traffic multiplier applied to user usage on this node (0 treated as 1)

enabledboolean

Admin on/off intent; toggled via /enabled

limitBytesinteger

Periodic usage cap in bytes (0 = no limit)

limitPeriod"" | "weekly" | "monthly" | "yearly"

Periodic limit window

limitStartDayinteger

Weekly 0-6 (Sun-Sat); monthly/yearly 1-31

limitStartMonthinteger

Yearly anchor month 1-12

limitDisabledboolean

Auto-disabled by the periodic usage limit

periodUsedinteger

Raw bytes counted against limitBytes so far in the current period; 0 without an active limit

periodStartinteger

Unix seconds the current limit period began; 0 without an active limit

onlineinteger

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

liveobject

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

fallbackCertIdinteger

Panel certificate used when fallbackCertMode=panel

upinteger
downinteger
sshHoststring

Host for the automatic installer; empty falls back to address

sshPortinteger
sshUserstring
sshKeyIdinteger

Panel SSH key used for this host; null = the default key

sshKeyInstalledboolean

The panel's key is authorised on this host, so updates need no password

setupstring

Last-seen deployment: docker, podman, containerd, lxc or linux

archstring

Last-seen GOARCH of the running agent

nodeVersionstring

Last-seen node agent version (coreVersion is the proxy engine's)

infoSeenAtinteger

When the three fields above were last confirmed

lastStatusChangeinteger
createdAtinteger
updatedAtinteger
NodeClientUsage
FieldTypeDescription
idinteger
nodeIdinteger
userIdinteger
dateTimeinteger
upinteger
downinteger
NodeConnections
FieldTypeDescription
totalrequiredinteger

Connections open on the node right now

inboundsobject

Open connections per inbound tag

usersrequiredobject[]
NodeHealth

One node's stored host readings over a window, with the uptime bar derived from them.

FieldTypeDescription
frominteger

Window start, unix seconds (inclusive)

tointeger

Window end, unix seconds (exclusive)

bucketinteger

Resolution the uptime segments were derived at, in seconds: 300 inside the raw window, 3600 where the history has been compacted

recordingboolean

False when node_health_retention_days is 0 — nothing is being kept, so an empty answer means switched off rather than no history

samplesobject[]

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.

uptimeobject[]

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.

FieldTypeDescription
connectionsrequiredinteger

Connections the core holds open right now, TCP and UDP sessions alike

restartsrequiredinteger

How many times the engine was started again since the node agent's process began

lastErrorstring

The most recent engine start that failed, if any since the process began

lastErrorAtinteger

Unix seconds of lastError

rejectedobject

Connections the node's router handed to each block outbound since its engine started, keyed by the outbound's tag — block-torrent for the torrent preset. Absent when the node has rejected nothing; a tag is only ever present with a count above zero.

seenAtrequiredinteger

Unix seconds the node reported these

hostobject

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.

FieldTypeDescription
startinteger
endinteger
nodesNodeUsageRow[]
NodeUsage

Per-user traffic on one node over a window, heaviest first.

FieldTypeDescription
startinteger

Window start, unix seconds (inclusive)

endinteger

Window end, unix seconds (exclusive)

usersobject[]
NodeUsageRow
FieldTypeDescription
nodeIdinteger
namestring

Empty for a node deleted since the traffic was recorded

upinteger
downinteger
lastinteger

Newest hour bucket with traffic, unix seconds

Outbound
FieldTypeDescription
idinteger
nodeIdinteger

null = pool item; set = per-node manual

tagstring
typestring
configJSON

An arbitrary JSON value (a core config fragment, etc.).

createdAtinteger
updatedAtinteger
OutboundCheckResult
FieldTypeDescription
okboolean
delayinteger

Latency in milliseconds

errorstring
PanelCertificate
FieldTypeDescription
idinteger
panelWebboolean

The panel's own HTTPS certificate (listed only): no addon on another server may fetch it

namestring
type"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs

challenge"" | "http-01" | "tls-alpn-01" | "dns-01"
emailstring

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.

obtainNodeIdinteger

Node whose ACME agent performs the challenge (acme only). Null means the panel obtains the certificate itself.

certificatestring

PEM certificate chain (public)

clientJSON

Client-side TLS template for links/subscriptions (see Certificate.client)

publicKeySha256string
certSha256string
issuerstring
notBeforeinteger
notAfterinteger
status"active" | "pending" | "error"
messagestring
createdAtinteger
updatedAtinteger
PanelCertRequest
FieldTypeDescription
namerequiredstring
typerequired"self_signed" | "acme" | "custom"
sansstring[]

Domains and/or IPs (required except for custom, where they derive from the PEM)

challenge"http-01" | "tls-alpn-01" | "dns-01"

ACME only

emailstring
dnsProvider"" | "cloudflare" | "alidns" | "acmedns"

dns-01 only; "" clears it

dnsTokenstring

dns-01 provider credential; empty on update keeps the stored one

obtainNodeIdinteger

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.

certificatestring

PEM certificate chain (custom type only; empty on update keeps the stored pair)

keystring

PEM private key (custom type only)

clientJSON

Client-side TLS template (never reissues the certificate)

PanelInfo

The panel's own health readout, for the dashboard's Panel tiles.

FieldTypeDescription
versionstring
go_versionstring
uptimeinteger

Seconds since the panel process started

archstring

GOARCH of the panel build (amd64, arm64, …)

setupstring

How the panel is deployed: the container runtime (docker, podman, …) or the host OS (linux) for a plain install

num_cpuinteger
num_goroutineinteger
mem_allocinteger
mem_sysinteger
sys_mem_totalinteger
sys_mem_usedinteger
load_avg_1number
disk_totalinteger

Size of the root filesystem in bytes; 0 where the platform does not report one

disk_usedinteger

Used bytes of the root filesystem

db_driverstring

sqlite or postgres

db_sizeinteger

On-disk database size in bytes; 0 when the backend does not report one

db_usage_rowsinteger

Rows in node_client_usages

backup_last_atinteger

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_countinteger

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_enabledboolean

Whether scheduled backups are on (sudo only)

backup_interval_hoursinteger

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 backup:read, since every backup route demands that scope — so a client that cannot see them must not read them as "never backed up".

updateobject

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
FieldTypeDescription
keystring

The service's name: telegram, email, backup_s3, backup_sftp, backup_telegram

enabledboolean
testableboolean

Whether POST …/test does anything

fieldsobject[]
lastRunAtinteger

Unix seconds of the last run (a delivery, an upload, a login, a test); 0 = never

lastOkAtinteger

Unix seconds of the last successful run; 0 = never

lastErrorstring

The last failure, cleared by the next success

PanelServiceUpdate
FieldTypeDescription
enabledboolean

Left out = unchanged

configobject

Settings to change; "" clears one

PanelUpdate
FieldTypeDescription
statusPanelUpdateStatus

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.

jobPanelUpdateJob

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.

logstring[]

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.

FieldTypeDescription
idstring
fromstring
tostring
phase"backup" | "download" | "verify" | "swap" | "restarting" | "done" | "error" | "rolled_back"

Anything but done, error and rolled_back means the job is still moving

messagestring
errorstring
backupstring

The archive taken before anything was touched — the way back from a migration, since migrations are forward-only

previousstring

Where the replaced binary is kept

actorstring
startedAtinteger
finishedAtinteger
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.

FieldTypeDescription
currentstring

The version this binary was built as

lateststring

The newest release tag the check has seen; empty until the first successful check

notesstring

Link to that release on the public panel repository

checkedAtinteger

Unix seconds of the last check, successful or not — a failure is stamped too, because the stamp is what paces the retry

availableboolean

latest is newer than current

checkEnabledboolean

The update_check setting; false means the panel asks nothing on its own and the figures above are whatever the last check left

errorstring

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

runtimestring

The container runtime, when method is container

unitstring

The systemd unit this process runs under, read from its own cgroup

binarystring

The running executable

reasonstring

Why this panel may not update itself, in prose for the operator; absent exactly when it may

canUpdateboolean
addonUpdatesinteger

How many registered addons the addon directory lists a newer version of

nodeLateststring

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

nodeNotesstring

Link to that release on the public node repository

nodeErrorstring

Why the last node lookup failed, or absent; separate from error so one stream failing blanks nothing the other learned

nodesBehindinteger

How many nodes report a version older than nodeLatest; a node that has not reported one is not counted

nodeCacheobject[]

The node binaries this panel holds for installers, one per architecture

nodeCacheCurrentboolean

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.

FieldTypeDescription
idinteger
namestring
descstring
sortOrderinteger

Display order; equal values fall back to the id

volumeinteger

Byte cap the account starts with, 0 = unlimited

daysinteger

Plan length in days, 0 = never expires

onFirstUseboolean

true: days becomes the user's duration, so the clock starts at the first connection. false: a fixed expiry, counted from the moment the user is created.

speedLimitinteger

Throughput cap in bytes per second, per direction, 0 = unlimited

ipLimitinteger

Concurrent source addresses, 0 = unlimited

deviceLimitinteger

Devices that may hold the subscription (by x-hwid), 0 = unlimited

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

allTemplatesboolean

Provision on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership, used when allTemplates is false

groupstring

Group label written onto every account this plan creates

namePrefixstring

Prepended to the username the caller chose; empty = unchanged

nameSuffixstring

Appended to the username the caller chose; empty = unchanged

createdAtinteger
updatedAtinteger
PresetCatalogue
FieldTypeDescription
versioninteger

The document's shape. 1 today; later sessions add sections rather than changing these.

ruleSetsobject
inboundsPresetInbound[]

The shelf behind “add an inbound from a preset”: a protocol with its transport and TLS decision already made.

routesPresetRoute[]

Routing blocks for a template, composed of rule sets the panel already mirrors.

dnsPresetDns[]

Resolver sets for a template's core, in the typed server form.

sourcesstring[]

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.

FieldTypeDescription
keystring
labelstring
labelKeystring
descriptionstring
descriptionKeystring
dnsobject
PresetGroup
FieldTypeDescription
keystring

The group's identity, and what an overlay file matches on to replace it.

labelstring
labelKeystring
itemsPresetItem[]
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.

FieldTypeDescription
keystring

Its identity, and what an overlay file matches on to replace it.

labelstring
labelKeystring
descriptionstring
descriptionKeystring
typestring

The inbound type.

tagstring

The base the created inbound's tag is built from; a short random suffix is appended. Absent = the type.

transportstring

A stream transport (ws, grpc, httpupgrade, …), applied the way the form applies one an operator picked.

useNodeCertboolean

Point the inbound at the node's managed certificate. Needed wherever the preset turns TLS on for a protocol that does not require it.

configobject

Merged over the interface's default config for type.

clientConfigobject

Client-side hints merged into generated links.

PresetItem
FieldTypeDescription
tagstring

The tag the rule set would be created under, which is also half its download URL.

urlstring

Where the panel fetches the list from.

labelstring

Shown as written. An operator's own entry carries this.

labelKeystring

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.

FieldTypeDescription
keystring
labelstring
labelKeystring
descriptionstring
descriptionKeystring
requiresstring[]

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.

routeobject

A route object (rules + final). direct is the only outbound every node has.

composeboolean

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.

outboundsobject[]

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 block outbound because a rejection through an outbound is the only kind the node counts.

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.

FieldTypeDescription
fingerprintstring

SHA256 fingerprint of the host's SSH key

hostKeyNewboolean

True when this connection pinned the key for the first time

osIdstring
osNamestring
kernelstring
machinestring
archstring
rootboolean
systemdboolean
dockerboolean
dockerVersionstring
composeboolean
curlboolean
tarboolean
pkgManagerstring

The host's package manager, or empty if it has none the installer can drive

installed"none" | "script" | "docker"
installedVersionstring
serviceStatestring
containerStatestring
imagestring
listenstring
certPresentboolean
cachedSetupstring
cachedArchstring
cachedVersionstring
cachedSeenAtinteger
mismatch"" | "arch" | "method" | "missing"
method"" | "script" | "docker"
source"" | "upload" | "panel" | "github" | "docker"
targetVersionstring
stagedVersionstring
latestVersionstring

The newest node release the panel knows of; empty until the first check

stagedForArchboolean
keyInstalledboolean
warningsstring[]
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.

FieldTypeDescription
idinteger
namestring

Unique. The three built-in names are reserved. admins.role stores this value, so a rename carries its holders with it.

baseAdminRole

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.

builtinboolean

One of the three fixed roles: not editable, not deletable.

descriptionstring
scopesstring[]

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.

userLimitinteger

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.

maxDeviceLimitinteger

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.

maxDurationinteger

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.

adminsinteger

How many accounts carry this role

createdAtinteger
updatedAtinteger
RouteDialResult
FieldTypeDescription
okrequiredboolean
connect_msrequiredinteger

Time to connect in milliseconds; 0 when it never connected

answeredboolean

The destination sent the first bytes

unconfirmedboolean

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.

errorstring
RouteTestForm
FieldTypeDescription
inboundsrequiredobject[]
RouteTestRequest
FieldTypeDescription
inboundrequiredstring

The inbound's tag on the node

userstring

The user's name; empty for a connection that carries none

network"tcp" | "udp"

Default tcp

sourcestring

The client's address, an IP with or without a port; optional

addressrequiredstring

The destination as the client asked

portrequiredinteger
domainstring

The sniffed domain (TLS SNI, HTTP Host); optional

protocolstring

The sniffed protocol (tls, http, quic, bittorrent, …); optional

RouteTestResult
FieldTypeDescription
stepsrequiredRouteTestStep[]
actionrequiredstring

How the walk ended: route, reject, hijack-dns, bypass, or final (no rule ended it)

rule_indexrequiredinteger

-1 for the final outbound

rulestring
outboundstring
outbound_typestring
destinationrequiredstring

Where the connection goes after any override_address / override_port

errorstring

What the router itself would refuse the connection with

dialRouteDialResult
RouteTestStep
FieldTypeDescription
indexrequiredinteger
rulerequiredstring

The rule in the engine's own words — what a live connection's row shows for it

actionrequiredstring
matchedrequiredboolean
finalboolean

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.

FieldTypeDescription
idinteger
tagstring

The name a rule matches on. Letters, digits, '-', '_' and '.' only, because it also names the file on each node. Fixed after creation.

urlstring

Absolute http(s) upstream the panel downloads from. Never sent to a node.

updateIntervalHoursinteger

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

availableboolean

The panel holds a copy. An unavailable rule set, and every rule naming it, is left out of node configurations.

sizeinteger
ruleCountinteger
contentHashstring

sha256 of the mirrored bytes; a node already holding them is not pushed them again

lastFetchedAtinteger

Unix seconds; 0 = never downloaded

lastAttemptAtinteger
lastErrorstring

Why the last refresh did not land. The previous copy is kept.

createdAtinteger
updatedAtinteger
RuleSetTestRequest
FieldTypeDescription
tagsstring[]

Empty: every rule set the node's route declares

addressrequiredstring
portinteger
RuleSetTestResult
FieldTypeDescription
resultsrequiredobject[]
ServerInfo
FieldTypeDescription
core_versionstring
node_versionstring
uptimeinteger
num_goroutineinteger
archstring

GOARCH of the node build (amd64, arm64, …); empty from nodes older than this field

setupstring

How the node is deployed: the container runtime (docker, podman, containerd, kubepods, lxc) or the host OS (linux) for a plain install

num_cpuinteger
mem_allocinteger
mem_sysinteger
sys_mem_totalinteger
sys_mem_usedinteger
load_avg_1number
load_avg_5number

5-minute load average; 0 from nodes older than the field or platforms that do not report one

load_avg_15number

15-minute load average; 0 as above

sys_swap_totalinteger

Host swap in bytes; 0 for a host with no swap as well as when unknown

sys_swap_usedinteger
disk_totalinteger

Size of the node's root filesystem in bytes; 0 when unknown

disk_usedinteger

Used bytes of the root filesystem, counting root-reserved blocks (as df does)

host_uptimeinteger

Seconds since the host booted — the machine's uptime, where uptime is the engine's

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.

FieldTypeDescription
idrequiredstring

Row identity; unique on its node, gone after a node restart

inboundstring
userstring
outboundstring

The outbound tag the connection was routed to

network"tcp" | "udp"
sourcestring

Client address, host:port

destinationstring

Where it went — the sniffed domain when there is one

rulestring

The route rule that matched; empty when the final outbound took it

createdAtrequiredinteger

Unix seconds, when it was routed

uprequiredinteger

Bytes from the client so far

downrequiredinteger

Bytes to the client so far

Setting
FieldTypeDescription
idinteger
keystring
valuestring
SetupRequest
FieldTypeDescription
tokenrequiredstring

The one-time setup token printed by the installer.

usernamerequiredstring
passwordrequiredstring

At least 12 characters mixing three of lowercase, uppercase, digits and symbols.

listenIpstring

An IP literal, or empty to bind every interface.

listenPortrequiredstring
basepathstring

URI prefix the panel is served under; empty serves at the root.

subBasepathstring

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.

certSansstring[]

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.

domainstring
subDomainstring
timezonestring
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.

FieldTypeDescription
requiredboolean

False on a configured panel; the wizard then does nothing.

versionstring
defaultsobject
hostAddressesstring[]

Every address found on this machine, publicly routable ones first — the pool the operator picks certificate addresses from.

suggestedSansstring[]

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

FieldTypeDescription
sshHoststring

Defaults to the node's sshHost, then its address

sshPortinteger
sshUserstring
passwordstring
privateKeystring

PEM private key

passphrasestring
SSOButton
FieldTypeDescription
idinteger
kindstring

google, microsoft, github, gitlab, keycloak, authentik, okta, auth0, oidc, oauth2

namestring

The button's name; empty = the kind's

SSOProvider
FieldTypeDescription
idinteger
kindstring
namestring
enabledboolean

On the login page

sortOrderinteger
fieldsobject[]

The kind's settings; a secret is reported only as set

lastRunAtinteger
lastOkAtinteger
lastErrorstring
linksinteger

Identities linked through it

SSOProviderWrite
FieldTypeDescription
kindstring

Create only

namestring
enabledboolean
sortOrderinteger
configobject
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).

FieldTypeDescription
recordingboolean

False when stats_retention_days is 0 — nothing is being recorded, so every series is empty

startTimeinteger

Left edge of the first bucket (unix seconds)

endTimeinteger

Right edge of the last bucket (unix seconds)

bucketSpaninteger

Seconds covered by one point

upinteger[]

Bytes from the client, per bucket

downinteger[]

Bytes to the client, per bucket

totalUpinteger
totalDowninteger
SubscriptionInfo

The subscription page view model (the subscription page theme guide lists the template field names).

FieldTypeDescription
userobject
usageobject
expireobject
resetobject
configsobject[]
appsobject[]
subobject
panelobject
assetBasestring
langstring
dir"ltr" | "rtl"
nowinteger
timezonestring
TelegramChat
FieldTypeDescription
idinteger
chatIdinteger
titlestring

The group's title or the person's name

adminIdinteger

The account the chat belongs to

usernamestring
lang"en" | "fa" | "ru" | "zh"
pairedAtinteger
subscriberIdinteger
eventsstring[]
enabledboolean
failuresinteger
disabledAtinteger
TelegramPairing
FieldTypeDescription
codestring
adminIdinteger
langstring
eventsstring[]
expiresAtinteger
linkstring

https://t.me/?start=

groupLinkstring

https://t.me/?startgroup=: Telegram adds the bot to a group the person picks and sends the code there as /start@

status"pending" | "pairing" | "paired" | "expired"
chatTelegramChat
errorstring

The poll's last failure while still pending

Template
FieldTypeDescription
idinteger
namestring
remarkstring
routeJSON

An arbitrary JSON value (a core config fragment, etc.).

coreJSON

dns/log/ntp blocks

inboundsInbound[]
outboundsOutbound[]
endpointsEndpoint[]
ruleSetsRuleSet[]
inboundIdsinteger[]
outboundIdsinteger[]
endpointIdsinteger[]
ruleSetIdsinteger[]
createdAtinteger
updatedAtinteger
Token

Node-install token (see ApiToken for third-party API tokens).

FieldTypeDescription
idinteger
kind"node_install" | "api"
tokenstring
descstring
roleAdminRole

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.

scopesstring
usedAtinteger
expiryinteger
createdAtinteger
TopUsers

Heaviest users across the fleet over a window.

FieldTypeDescription
startinteger

Window start, unix seconds (inclusive)

endinteger

Window end, unix seconds (exclusive)

usersobject[]
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.

FieldTypeDescription
idinteger
namestring

Unique. The config tag is tunnel-, where the slug lowercases the name, turns spaces and punctuation into hyphens and drops everything else; a name left with nothing usable (one written in a non-Latin script, say) falls back to the row id. Two names that slug the same are refused. The name cannot be changed after creation: it is the tag both halves carry and the namespace their tokens are derived in.

tagstring

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.

relayNodeIdinteger

Where users connect.

relayNamestring
originIdsinteger[]

Where traffic leaves for the internet. Several share one tag, and a user sticks to whichever one they hash to.

originNamesstring[]
profilesTunnelProfile[]

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. balance spreads over every profile that has a link, taking the session with the fewest open streams — so profiles share the load in proportion to their worker counts, and one that gets blocked simply stops being chosen. priority keeps to the first profile that has any link at all and uses the next only when it has none, which is a standby path rather than extra capacity; the order is the order of the profiles list. Neither mode decides which origin a user reaches: that is rendezvous hashing on the peer name, so a blocked protocol costs throughput and never a user's exit IP.

workersinteger

Parallel links per origin for a profile that sets none of its own, each with its own congestion window.

streamWindowinteger

Per-stream receive window in bytes; 0 takes the node default.

enabledboolean
ownsRelayFinalboolean

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.

createdAtinteger
updatedAtinteger
TunnelBulkResult
FieldTypeDescription
opstring
matchedinteger

How many of the ids named a tunnel that exists.

affectedinteger

How many tunnels actually changed.

failedinteger

How many were refused by the validation.

errorstring

The first refusal, since they are usually the same one repeated.

problemsstring[]

Up to ten refusals, each naming the tunnel it belongs to.

TunnelHalfStatus
FieldTypeDescription
nodeIdinteger
nodeNamestring
reachableboolean
errorstring
sessionsobject[]
peersobject[]
TunnelProfile
FieldTypeDescription
listenPortinteger

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.

tlsobject

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.

transportobject

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.

workersinteger

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
FieldTypeDescription
tagstring
nodesobject[]

One entry per node that runs a half, relay first.

TunnelStatus
FieldTypeDescription
tagstring
healthyboolean

The relay has at least one link attached.

sessionsinteger
relayTunnelHalfStatus
originsTunnelHalfStatus[]
User
FieldTypeDescription
idinteger
namestring
enableboolean

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.

disabledReasonstring

Why the account is off: manual (a person), or the enforcer's own reason — expiry, volume, resale-volume, resale-expiry — which it lifts when the account is back within its limits. Empty while the account is on, and on accounts switched off before the field existed.

configJSON

An arbitrary JSON value (a core config fragment, etc.).

descstring
contactobject

The customer's own details, as one flat object of string pairs. Six keys are standard — telegram_id, bale_id and soroush_id (a bot's numeric chat id or an @username), rubika_id (a Rubika bot's chat id), email and phone — and are the ones the panel itself reads: they are filtered on (contact_<key>), matched by the free-text q, exported as their own CSV columns and written by nexora-migrate. Any other pair is the operator's own: stored and returned untouched, and interpreted by nothing. Values may be sent as JSON numbers or booleans and come back as text; an empty value removes the key, and a document left with no pairs is stored as absent. At most 24 pairs, 48 characters per key, 256 per value and 2 KiB in all; anything else is a 400, as is a standard key whose value is not what it claims to be. An addon reads this and keeps its own state in its own database — a whole-object PUT on a user is last-writer-wins, and nothing arbitrates it.

subTokenstring
subIdstring

Subscription identifier used in /sub/{id}; defaulted random on create

remarkstring

Label prepended to generated share-link names

volumeinteger

Byte cap, 0 = unlimited

speedLimitinteger

Throughput cap in bytes per second, per direction and per node, 0 = unlimited

ipLimitinteger

Distinct source addresses the user may hold at once across every node (IPv6 counted by /64), 0 = unlimited

deviceLimitinteger

Distinct devices that may hold the subscription, counted by the x-hwid a compatible client sends when it fetches; a device past the cap is refused the configs (403). Clients that send no id are served unless the device_limit_strict setting is on. 0 = unlimited.

expiryinteger

Unix seconds, 0 = never. With duration set and activatedAt still 0 this is provisional and moves to the real date when the plan starts.

durationinteger

Plan length in seconds, counted from the user's first connection instead of from creation. 0 = a fixed expiry date. Setting it (or changing it on an existing user) re-arms the plan: activatedAt returns to 0 and the countdown waits for the next connection.

activatedAtinteger

Unix seconds of the first traffic that started the plan; 0 while it has not started. Read-only: the panel sets it.

upinteger
downinteger
onlineAtinteger

Unix ts of last traffic; online if within ~90s

subFetchedAtinteger

Unix seconds the subscription body was last handed to a client; 0 = never. The only signal that a client holds the configs.

subSeenAtinteger

Unix seconds of the last request of any kind against the subscription URL — the page and its polling included; 0 = never

subClientstring

User-Agent of the last client to take the body (truncated, control characters stripped). Untrusted text.

subFetchIpstring

Address the body was last fetched from

lastInboundstring

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.

autoResetboolean

Switch the periodic traffic reset on. What kind of cycle it is depends on resetPeriod.

resetDaysinteger

Rolling cycle length in days, read only when resetPeriod is empty.

resetPeriod"" | "weekly" | "monthly" | "yearly"

Empty = the rolling cycle measured in resetDays. Set = a calendar cycle anchored to resetStartDay (and resetStartMonth, yearly only), which ignores resetDays. "Monthly" is a calendar month: a rolling 30 days walks about five days a year until a customer's reset lands mid-billing-month.

resetStartDayinteger

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.

resetStartMonthinteger

Anchor month, read for the yearly cycle only.

resetCalendar"" | "jalali"

Empty = Gregorian. jalali puts monthly and yearly boundaries on the Persian calendar the panel already displays for fa. Ignored for the weekly cycle, which is the same seven days in both, and cleared when the cycle is rolling.

nextResetinteger

The pending appointment: the next boundary, or the rolling cycle's next due date. The panel writes it; a caller does not.

totalUpinteger
totalDowninteger
groupstring

Free text label for bulk operations

adminIdinteger

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.

allTemplatesboolean

Provision the user on every template (ignores templateIds)

templateIdsinteger[]

Explicit template membership (used when allTemplates is false); absent on create keeps legacy all-templates behaviour

subUrlstring

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.

licensedboolean

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.

planIdinteger

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.

createdAtinteger
updatedAtinteger
UserAddresses
FieldTypeDescription
limitinteger

The user's ipLimit, 0 = unlimited

addressesobject[]
UserConnections
FieldTypeDescription
connectionsrequiredobject[]
UserDevices
FieldTypeDescription
limitrequiredinteger

The user's deviceLimit, 0 = unlimited

devicesrequiredobject[]
UserNodeUsage

One user's traffic split across the nodes they used.

FieldTypeDescription
startinteger
endinteger
nodesNodeUsageRow[]
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.

FieldTypeDescription
versioninteger

The updatedAt the caller read; the edit is refused with 409 when the account has moved on.

namestring
enableboolean
configJSON

An arbitrary JSON value (a core config fragment, etc.).

descstring
groupstring
contactobject
adminIdinteger
subIdstring
remarkstring
volumeinteger
expiryinteger
durationinteger
activatedAtinteger
nextResetinteger
speedLimitinteger
ipLimitinteger
deviceLimitinteger
autoResetboolean
resetDaysinteger
resetPeriodstring
resetStartDayinteger
resetStartMonthinteger
resetCalendarstring
allTemplatesboolean
templateIdsinteger[]
UserSessions
FieldTypeDescription
nodeIdrequiredinteger
noderequiredstring
totalrequiredinteger

The user's open connections on this node, before any row filter

inboundsobject

The user's connections per inbound tag on this node

outboundsobject

The user's connections per outbound tag on this node

sessionsrequiredSessionInfo[]
truncatedboolean

The rows stopped at the node's cap; the counts are still whole

supportedrequiredboolean

False for a node too old to list rows; it still counts them

متن و تصویرها با مجوز CC BY 4.0.