The external REST API lets a surrounding system (a billing service, a Telegram shop, a provisioning script) manage the panel over HTTP with an API key. It calls the same internal logic the admin panel does, so the two never drift.
Open the panel → Settings → API. Creating your first key turns the surface on and generates a stable, unguessable base URL:
https://<your-host>/<api_path>/v1
The <api_path> segment is separate from the hidden panel path, so rotating the
panel secret never breaks integrations. You can rotate or disable the API path
from the same page (rotating changes the base URL; keys keep working under the new
one).
The API publishes its own OpenAPI 3.0 spec, generated from the server code (the schemas are reflected from the actual Go types, so they never drift):
GET $BASE/openapi.json → the OpenAPI 3.0 document
GET $BASE/docs → Swagger UI (try endpoints in the browser)
Both are served without a key (the base URL itself is the secret). Open …/docs,
click Authorize, paste a key, and call any endpoint live. Point Postman /
openapi-generator / any client generator at …/openapi.json to scaffold a
typed client. (The Swagger UI shell loads from a CDN; the spec it renders is
fully local.)
Every request must carry a key, created in Settings → API. The raw key is shown once at creation — store it immediately; only its prefix is kept afterwards. Send it as a bearer token:
Authorization: Bearer rp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
(X-API-Key: <key> is also accepted.) Revoked keys stop working immediately.
A missing or invalid key returns 401. The surface is per-IP rate-limited.
Success:
{ "data": { ... } }Error:
{ "error": { "code": "bad_request", "message": "name is required" } }Common codes: bad_request (400), unauthorized (401), not_found (404),
unsupported_media_type (415), internal (500).
Input the panel refuses carries two more fields — the reason it refused, and the parameters of that reason:
{ "error": {
"code": "bad_request",
"key": "err.planHasUsers",
"message": "the plan is assigned to 12 users — move them to another plan first",
"args": { "count": 12 }
} }code stays the coarse class to branch on for retries; key is the specific,
stable reason to branch on for behaviour — never match on message, which is
free to be reworded. message is always English (the panel translates the same
codes in the browser, in whichever language the admin chose); args lets a client
render its own wording without parsing prose. Both are absent when the failure has
no code behind it (a malformed body, an unknown path).
A field the endpoint does not define is an error, not something to ignore:
{ "error": {
"code": "bad_request",
"message": "unknown field \"groups\" — see GET /v1/openapi.json for the fields this endpoint accepts"
} }Accepting the body and quietly dropping the field is worse than refusing it: a
caller that sends {"groups": [2,4]} to an endpoint expecting group_ids gets a
success back and no membership change, and finds out whenever they next read the
value. So check the spelling against the spec — it is generated from the code, so
it cannot list a field the panel doesn't read.
Only fields the endpoint genuinely requires are marked required there. Everything
else may be omitted and keeps its documented default; you never have to send a full
object to change one thing.
There is no version or If-Match on writes: two callers changing the same object
resolve last-write-wins, and neither is told. This matters most where a write carries
fields it did not intend to change — a node edit re-sends the whole node, so a routing
change written from here can overwrite a name someone set in the panel a second
earlier.
In practice it bites rarely, because the endpoints that patch (PATCH /v1/settings,
PATCH /v1/users/{id}) apply only the fields the body carries, so two callers touching
different fields do not collide. If you automate edits to the same object from more
than one place, read it back after writing rather than assuming your value stuck.
Every list endpoint takes ?limit and ?offset and answers with a meta block:
{ "data": [ … ], "meta": { "total": 1043, "offset": 0, "limit": 100 } }limit defaults to 100 and is capped at 1000; limit=0 (or negative) means
"everything from offset", which is a deliberate ask rather than the default — an
unbounded default is how an integration works fine for a year and then times out on a
panel that grew. total counts the rows after filtering and before the window, so a
caller can page without guessing.
The journals (/v1/events, /v1/admin-audit) page differently, by cursor
(?before=<oldest id held>): their rows shift as new ones arrive, and an offset would
skip or repeat entries.
Base URL below is written as $BASE (e.g. https://vpn.example.com/ab12cd34/v1).
GET $BASE/health → { "data": { "status": "ok" } }
GET $BASE/healthz is the one endpoint that needs no key — point an uptime monitor
or a load balancer at it. It answers 503 (not 200) when Xray isn't running: the
panel may be fine, but the node is carrying no VPN traffic, which is what you want
to be paged about.
GET $BASE/healthz
200 → { "data": { "status": "ok", "xray": "running", "xray_started_at": 1752230400 } }
503 → { "data": { "status": "degraded", "xray": "down", "xray_started_at": 0 } }
It lives under the API path rather than at the server root on purpose: an
unauthenticated /healthz on the root would answer JSON to any scanner and give the
panel away, defeating the decoy. The API path is stable across secret rotation, so a
monitor pointed here keeps working.
| Method | Path | Description |
|---|---|---|
GET |
/v1/users |
List users (filter + paginate). |
POST |
/v1/users |
Create a user. |
POST |
/v1/users/bulk |
Apply one action to many users at once. |
GET |
/v1/users/{id} |
Get one user. |
PATCH |
/v1/users/{id} |
Update name / limits / expiry / device limit / speed limit / enabled. |
DELETE |
/v1/users/{id} |
Delete a user. |
POST |
/v1/users/{id}/reset |
Reset the user's traffic counters. |
POST |
/v1/users/{id}/reset-period |
Set auto-reset period. |
POST |
/v1/users/{id}/rotate-sub |
Issue a new subscription URL (old link dies). |
POST |
/v1/users/{id}/plan |
Apply a tariff plan to the user. |
POST |
/v1/users/{id}/plan/cancel |
Cancel a paid subscription now. |
GET |
/v1/users/{id}/connections |
List the user's recent source IPs / devices. |
GET |
/v1/users/{id}/devices |
List the installs bound by HWID, with the cap they count against. |
POST |
/v1/users/{id}/devices/unbind |
Release one bound device (or all), freeing the slot. |
GET |
/v1/users/{id}/events |
The user's own journal (paged). |
GET |
/v1/users/{id}/abuse |
The user's blocklist matches (limit, default 20). |
Create — name is required; everything else is optional and applied to the fresh
account in the same call:
{ "name": "alice", "data_limit": 0, "expire_at": 0,
"device_limit": 3, "speed_limit": 20000, "plan_id": 2, "group_ids": [4] }Devices are the installs bound by the x-hwid subscription header (see the panel's
Settings → Subscriptions). The list carries the cap alongside the roster, so one call
answers "is this user full":
{ "data": { "devices": [ { "hwid": "…", "os": "iOS", "os_version": "26.5.2",
"model": "iPhone 15 Pro Max", "app": "Happ/1.0", "ip": "203.0.113.7",
"first_seen": 1786500000, "last_seen": 1786568000 } ],
"limit": 3, "enabled": true } }Unbinding takes either one id or the lot, and answers with how many slots were freed:
{ "hwid": "…" } → { "data": { "removed": 1 } }
{ "all": true } → { "data": { "removed": 3 } }A device that was never bound is a 404, not a silent success. The freed slot is
immediately available: the next fetch from a new install is admitted.
speed_limit is a per-user bandwidth cap in kbit/s (0 = unlimited), applied in both
directions. It is enforced by the host kernel on the addresses the user is connected from,
not by Xray — so everyone behind one NAT address shares a cap, and with Hysteria2 the cap
manifests as packet loss rather than a smooth slowdown. A tariff plan carries its own
speed_limit and overwrites the user's when it is applied, exactly as it does for the
traffic and device limits.
plan_id rewrites data_limit and expire_at with the plan's own — a plan is the
limits — while an explicit device_limit is applied after it and wins. If one of the
extras fails, the account is NOT rolled back (credentials may already be in someone's
hands): the call returns the error and the user stays as far as it got. Responds 201
with the user as it actually ended up.
Cancel a subscription — moves the user to the free plan immediately (losing the
remaining paid time), or ends their access when no free plan is configured. Distinct
from applying the free plan by hand: this is recorded as plan.cancelled, which is
the event a billing integration reacts to.
List — query params: status (active / disabled / expired / limited
/ device_limited), search (substring of the name), limit, offset
(limit<=0 = all from offset). The response adds a meta block:
{ "data": [ ... ], "meta": { "total": 42, "offset": 0, "limit": 20 } }Bulk — body:
{ "ids": [1, 2, 3], "action": "extend", "days": 30 }action is one of enable, disable, delete, reset, extend (days is
required only for extend). Response: { "data": { "affected": 3 } }.
Reset period — body: { "period": "monthly" } (none / daily / weekly
/ monthly / yearly).
Create — body:
{ "name": "alice", "data_limit": 0, "expire_at": 0 }data_limit is bytes (0 = unlimited); expire_at is a Unix timestamp
(0 = never). The response data is the full user object, including sub_url, the
built-in lanes' vless / reality / hysteria2 share links, and links — every
lane the user has on this server, custom inbounds included, each with the name the
client will display.
Patch — send only the fields you want to change:
{ "name": "alice2", "data_limit": 107374182400, "expire_at": 1767225600, "device_limit": 3, "enabled": true }Apply plan — body:
{ "plan_id": 2, "extend_from_current": false }| Method | Path | Description |
|---|---|---|
GET |
/v1/billing/providers |
List the enabled payment methods (what a client can pay with). |
GET |
/v1/billing/plans?include_disabled=true |
List tariff plans. |
POST |
/v1/billing/plans |
Create (no id) or update (id set) a plan. |
DELETE |
/v1/billing/plans/{id} |
Delete a plan (refused while users are on it). |
POST |
/v1/billing/plans/{id}/migrate |
Move every user on this plan to another one. |
GET |
/v1/billing/orders?status=pending |
List payment orders (status optional). |
POST |
/v1/billing/orders |
Open an order for a user+plan. |
GET |
/v1/billing/orders/{id} |
Get one order (poll a payment's status). |
POST |
/v1/billing/orders/{id}/confirm |
Mark an order paid (activates the plan). |
POST |
/v1/billing/orders/{id}/cancel |
Cancel an order. |
GET |
/v1/billing/settings |
Billing configuration. |
POST |
/v1/billing/settings |
Replace it (whole object). |
GET |
/v1/billing/stats |
Revenue totals, per-provider split, pending backlog. |
GET |
/v1/payments |
Every payment provider with its settings form. |
POST |
/v1/payments |
Configure one provider. |
A plan may name the access groups it grants ("group_ids": [3]): whoever is put on
the plan — by a paid order, by POST /v1/users/{id}/plan, or at registration — joins
those groups and leaves them when the plan changes. Memberships assigned directly
through POST /v1/users/{id}/groups are kept; only what the plan granted is taken
back. An empty list means the plan says nothing about access.
Create order — body { "user_id": 5, "plan_id": 2 }. The response carries the
order and, when a payment provider is configured, a hosted pay_url to send the
user to:
{ "data": { "order": { ... }, "pay_url": "https://..." } }A manual order returns an empty pay_url and waits for /confirm. Creating an order
is not idempotent by key, but it does not stack duplicates either: a still-pending
order for the same user, plan and provider is reused instead of a second one being
opened, so a retried call returns the order that already exists.
Billing settings — the whole object, replaced on write (there is no partial update: "no free plan" is a real state and must be distinguishable from "unspecified"):
{ "enabled": true, "free_plan_id": 1, "trial_plan_id": 2, "payment_note": "card 1234" }Designating a plan as the free or trial one also makes it free and re-applies it to everyone already on it — the same rule the panel enforces.
Migrate — body { "to_plan_id": 3 }, response { "data": { "migrated": 12 } }.
Applies the target plan's limits, period and access groups to every user on the source
plan. This is the only way to empty a plan before deleting it.
Providers — GET /v1/payments returns each provider in the registry with its
settings form: the fields it takes, their current values, which secrets are set (never
their values) and the webhook_url to paste into the provider's dashboard. The field
list is per provider and generated from the registry, so this payload is deliberately
free-form. POST /v1/payments takes { "key": "yookassa", "enabled": true, "config": {...} }; a secret left empty keeps its stored value.
GET /v1/billing/providers is dynamic — it returns whatever payment methods you've
enabled in the panel (cards, SBP, crypto, …), each with a key. That key is what you
pass as provider when opening an order; omit it for a manual order.
Manage the server fleet. The local panel server is node 0; the rest are remote
nodes that hold an outbound long-poll to the panel. Users and limits sync to every
enabled node automatically, and each server can be edited independently.
| Method | Path | Description |
|---|---|---|
GET |
/v1/nodes |
List servers (the local panel is node 0) with status and today's traffic. |
POST |
/v1/nodes |
Register a node — returns the one-line install command (join token shown once). |
GET |
/v1/nodes/{id} |
Get one node. |
PATCH |
/v1/nodes/{id} |
Edit name / host / protocol / routing / DNS overrides and WARP-Opera egress. |
DELETE |
/v1/nodes/{id} |
Delete a node (it stops serving users). |
POST |
/v1/nodes/{id}/enabled |
Enable or disable a node. |
POST |
/v1/nodes/{id}/regen-join |
Issue a fresh install command (revokes the node's current token). |
POST |
/v1/nodes/{id}/update |
Ask a node to self-update to the latest release. |
POST |
/v1/nodes/update-all |
Ask every connected node to self-update (sequentially). |
POST |
/v1/nodes/{id}/proxy |
Configure that server's system proxy (id 0 = the master). |
GET |
/v1/nodes/{id}/health |
One server's self-diagnostics. |
GET |
/v1/nodes/{id}/logs |
A node's recent log lines. |
Register a node — body { "name": "NL #1", "host": "nl1.example.com" }. The
response carries the node id, the one-time join token and the ready-to-run install
command for a fresh Ubuntu server:
{
"data": {
"id": 3,
"join_token": "rpn_…",
"install_command": "curl -Ls https://.../install.sh | sudo bash -s -- --join '…#rpn_…'"
}
}The join token is embedded once and expires in 24h; /regen-join issues a new one.
System proxy — a SOCKS5 and/or HTTP forward listener on that server, for traffic that is not a VPN client: a scraper, a bot, another panel chaining its egress here. No user credential opens it, no access group gates it, and it never appears in a subscription. Traffic leaves under that server's routing, so WARP, Opera and the proxy lanes apply exactly as they do for VPN clients.
{ "socks_enabled": true, "socks_port": 1080,
"http_enabled": true, "http_port": 3128,
"accounts": [ { "user": "scraper", "pass": "…" },
{ "user": "bot", "pass": "…" } ] }accounts is the full list every time — the way to delete one is to send the list
without it. Logins must be unique and carry no colon or space (a colon is the
separator in user:pass), and at least one complete account is required whenever
either listener is on: an open proxy on a public port is found by scanners within
hours, and is then somebody else's spam relay with this server's IP on it. A protocol
enabled without a port gets 1080 (SOCKS) / 3128 (HTTP). Each server has its OWN
accounts and ports — a node never inherits the master's, so a leaked login opens one
machine. The response is the stored configuration, so a caller that sent only
{"socks_enabled": true} learns which port it got.
Operator-defined inbounds beyond the three built-in lanes, one set per server (server
0 = the master). Each is a public listener, so a create/update is validated on the
target machine (xray -test + a port-bind probe) before it's saved.
| Method | Path | Description |
|---|---|---|
GET |
/v1/servers/{id}/inbounds |
List one server's custom inbounds (id = server id, 0 = master). |
POST |
/v1/servers/{id}/inbounds |
Create a custom inbound on that server. |
POST |
/v1/inbounds/{id} |
Update a custom inbound (keyed by the inbound's own id). |
DELETE |
/v1/inbounds/{id} |
Delete a custom inbound. |
Create / update — the body mirrors the panel's inbound editor: name, protocol
(vless / trojan / hysteria2 / shadowsocks), transport (tcp / ws / xhttp /
grpc / httpupgrade), port, security (none / tls / reality) with the matching
keys (REALITY dest & keys, fingerprint, path/host, Hysteria2 hop range), plus optional
advanced blocks (XHTTP extra, TCP HTTP masquerade, sockopt, extra TLS keys). The
full field list — and which combinations are valid — is in openapi.json / Swagger.
shadowsocks is Shadowsocks-2022 (method picks the AEAD; the server key is generated,
the per-user key derived from the UUID) — multi-user, so per-user stats and quotas work,
but only modern clients (sing-box, mihomo, v2rayN, Shadowrocket, Streisand) speak it.
Two formats the schema can only call string: reality_dest is a bare hostname
(www.microsoft.com — a host:port form is rejected), and hop_interval is a range in
seconds ("5-10", not "30").
The response data is the saved inbound; a rejected config (port already bound, invalid
combo, node offline) returns 400 with the reason. Inbounds a client can't represent are
silently dropped from Clash/sing-box subscriptions rather than emitted broken.
An inbound is addressable by an access group via the inbound:<id> grant token (see
Groups).
GET /v1/inbounds/catalog publishes what can be combined with what — every protocol ×
transport, the security modes each allows, which subscription formats cannot carry it,
and the enum values the advanced fields accept. It is the same table the panel editor
uses, so a client that builds inbounds from it cannot construct one the validator will
reject.
Access groups gate which connections a user may use. A user in no group reaches
everything; a user in one or more groups reaches the union of their groups' grants.
Enforcement is server-side — a disallowed lane's credential is withheld from Xray,
not merely hidden — so the user object's links, the subscription, and a hand-built
link all expose only what's granted.
| Method | Path | Description |
|---|---|---|
GET |
/v1/groups |
List groups, each with its grants, member_ids and member count. |
POST |
/v1/groups |
Create a group. |
POST |
/v1/groups/{id} |
Update a group (name + grants). |
DELETE |
/v1/groups/{id} |
Delete a group (members left in no group revert to unrestricted). |
POST |
/v1/groups/{id}/members |
Replace the group's members. |
POST |
/v1/users/{id}/groups |
Replace one user's group membership. |
Create / update — body:
{ "name": "VIP", "grants": ["builtin:0:vless", "builtin:0:reality", "inbound:7"] }A grant token names one connection:
builtin:<server_id>:<lane>— a built-in lane, where<lane>isvless,realityorhysteria2and<server_id>is0for the master or a node id fromGET /v1/nodes.inbound:<id>— a custom inbound, by the id fromGET /v1/servers/{id}/inbounds.
An empty grants array is a real state, not a no-op: members of a group that grants
nothing reach nothing — that's how you revoke. The response data is the group.
Set members — body { "user_ids": [1, 2, 3] }; replaces the whole member set.
Set a user's groups — body { "group_ids": [4, 5] }; replaces the user's whole
membership. An empty array removes the user from every group (→ unrestricted).
Grants that reference a deleted inbound or node are swept automatically, and harmless until then.
Where the tokens come from — GET /v1/groups/targets lists every server with the
connections a group can grant, each with its ready-made token:
{ "data": [
{ "server_id": 0, "server_name": "Мастер",
"lanes": [ { "lane": "vless", "label": "VLESS-TCP-TLS",
"token": "builtin:0:vless", "enabled": true } ],
"inbounds": [ { "id": 7, "name": "WS резерв",
"token": "inbound:7", "enabled": true } ] }
] }Disabled lanes and inbounds are included, so a grant can be prepared before the connection is switched on. Assembling those tokens by hand works too — but a typo grants nothing, silently, which is exactly what this endpoint prevents.
The endpoints above operate a panel; these configure one. Everything here mirrors a screen in the panel and calls the same code behind it, so a value the UI refuses is refused here too.
Two things are deliberately absent. Administrators and API keys have no endpoints:
a key able to mint keys turns one leak into permanent, self-renewing access, and that
decision belongs behind a session with a password prompt. The panel's secret path
is never returned by any /v1 route — it is the obscurity layer in front of the panel,
not a setting to read out.
GET $BASE/v1/settings → the operational knobs
PATCH $BASE/v1/settings → change some of them
GET $BASE/v1/servers/{id}/routing → routing, DNS and egress backends
POST $BASE/v1/servers/{id}/routing → change them
POST $BASE/v1/servers/{id}/xray-restart → restart that server's Xray
GET $BASE/v1/config/snapshots → config save-points
POST $BASE/v1/config/snapshots → take one
POST $BASE/v1/config/snapshots/{id}/rollback → restore the server config from one
DELETE $BASE/v1/config/snapshots/{id} → forget one
PATCH /v1/settings applies only the fields the body carries. Everything absent is
left as it is, which is what makes it safe to read the object, change one value and send
that one value back — re-posting the whole thing would also re-apply whatever another
operator changed in between. The grouped fields (hwid_*, local_backup_*) are stored
by a single write internally, so they overlay onto the current row rather than resetting
their siblings to zero.
curl -X PATCH $BASE/v1/settings -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"maintenance_mode":true}'{ "data": {
"xray_dns": "https://dns.example/dns-query\n1.1.1.1",
"decoy_template": "coming-soon",
"decoy_templates": ["coming-soon", "YouTube", "…"],
"maintenance_mode": true,
"probe_detect": true, "probe_block": false,
"watchdog_enabled": true, "watchdog_restarts": 0,
"user_autodelete_days": 0,
"hwid_enabled": false, "hwid_require": true,
"hwid_fallback_limit": 0, "hwid_ttl_days": 30,
"device_count_mode": "auto",
"local_backup_cron": "", "local_backup_keep": 7,
"sub_path": "sub", "warp_enabled": false, "warp_registered": true
} }device_count_mode decides what a user's device limit counts. auto (the default) counts
distinct source addresses seen in the last two minutes — the only thing that caps how many
places one subscription is used at once — and cuts a user off only once they have been over
the limit continuously for two and a half minutes, so that a network change or a carrier's
address rotation passes without touching them. hwid stops counting addresses entirely,
leaving the bound-install roster as the only limit, and gives up concurrency enforcement to
do it: HWID caps who may fetch a subscription rather than who may connect. both is accepted
for rows written before the two modes collapsed and behaves as auto. Anything else is
refused rather than silently falling through to the default.
active_devices on a user is the raw number of addresses seen in the window and is reported
whether or not anything is being enforced; status becomes device_limited only once the
cut has actually landed.
Three of these have an effect the database alone does not carry — the masquerade
template, scanner detection and maintenance mode are consulted on every request — so the
API swaps them live exactly as the panel does. An unknown decoy_template is refused
before anything is written; a xray_dns entry that is not an IP, host:port, URL or
localhost is refused the same way the panel refuses it, because a DNS server Xray
cannot parse makes every later config regeneration fail.
Routing is per server, and server 0 is the panel's own machine — the same numbering
/v1/servers/{id}/inbounds uses. A GET and a POST speak the same shape:
{ "data": {
"routing": { "block_ads": true, "block_bittorrent": false, "lanes": [], "…": null },
"xray_dns": "1.1.1.1",
"warp_enabled": false,
"opera_enabled": false,
"opera_country": "EU"
} }An omitted field is left unchanged; routing, when present, replaces the rule set
wholesale. The split is deliberate: merging rule lists has no meaning a caller could
predict (does a shorter list delete rules or leave them?), while silently switching an
egress backend off because a body did not mention it is how tunnelled traffic starts
leaving from the server's own IP. So send routing to change rules, and name only the
backends you actually mean to move.
Snapshots capture the whole server config — protocols, ports, REALITY, routing, egress, DNS, decoy and the custom inbounds — as one save-point. A rollback restores all of it except the certificate and domain, so an undo can never break the live cert, and takes an automatic save-point of the current state first, so the rollback is itself undoable. It regenerates and restarts Xray; because nodes inherit the master's fields, that restart is fleet-wide and every live connection drops.
curl -X POST $BASE/v1/config/snapshots -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"label":"before the egress change"}'The answer carries the save-point that call made, by id — not "the newest one", which would be somebody else's snapshot if a create landed in between.
xray-restart is direct for server 0 and queued for a node, which picks it up on its
next sync — a 200 there means accepted, not done.
GET $BASE/v1/stats/series?user_id=5&from=2026-01-01&to=2026-01-31 → daily traffic
GET $BASE/v1/stats/nodes?user_id=5&from=2026-01-01&to=2026-01-31 → totals per server
GET $BASE/v1/stats/nodes/series?user_id=5&from=2026-01-01&to=2026-01-31 → daily, per server
GET $BASE/v1/stats/users?from=2026-01-01&to=2026-01-31 → per-user totals
user_id narrows series, nodes and nodes/series to one account; omit it for a
panel-wide figure. users has no user_id — it already answers per user.
from/to are YYYY-MM-DD in the panel's configured timezone, and both default to
the last 30 days — an omitted window is a window, not a request for nothing.
A malformed or reversed range is a 400 rather than an empty answer.
Every day in the range is present, including the ones that carried nothing:
{ "data": [
{ "day": "2026-01-01", "up": 52711616, "down": 3901246326 },
{ "day": "2026-01-02", "up": 0, "down": 0 },
{ "day": "2026-01-03", "up": 46059475, "down": 1488367869 }
] }A quiet day writes no row internally, so the stored history is a list of busy days rather than a series; these endpoints fill the gaps, because a hole and a zero are different shapes to anything computing a moving average.
nodes breaks the same traffic down by the server that carried it, busiest first —
series tells you how much, this tells you where. node_id is 0 for the panel's
own server; names are resolved for you, including servers deleted since (their
traffic rows outlive them).
{ "data": [
{ "node_id": 2, "name": "NL", "up": 46059475, "down": 1488367869 },
{ "node_id": 0, "name": "Мастер", "up": 52711616, "down": 3901246326 }
] }nodes/series is both dimensions at once — one row per day per server, so one call
plots a line per server:
{ "data": [
{ "day": "2026-01-01", "node_id": 0, "name": "Мастер", "up": 52711616, "down": 3901246326 },
{ "day": "2026-01-01", "node_id": 2, "name": "NL", "up": 46059475, "down": 1488367869 },
{ "day": "2026-01-02", "node_id": 0, "name": "Мастер", "up": 0, "down": 0 },
{ "day": "2026-01-02", "node_id": 2, "name": "NL", "up": 12000000, "down": 340000000 }
] }Servers that carried nothing across the whole window are left out entirely; the ones that appear have a row for every day.
Where the connections came from, rather than how much they carried:
GET $BASE/v1/stats/countries → recent connections grouped by country
GET $BASE/v1/stats/asns → the same, grouped by network operator
Both count distinct source IPs and their activity over the connection retention
window, busiest first. asn is 0 and org empty for an address no routing table
covers (a private or unrouted one):
{ "data": [
{ "asn": 15169, "org": "GOOGLE", "ips": 23, "hits": 2548 },
{ "asn": 13335, "org": "CLOUDFLARENET", "ips": 4, "hits": 61 }
] }Each answer is computed from the whole connections table, so both are memoized for about half a minute — two calls a second get the same figures, and the panel keeps its one database connection for everything else.
Two families of counter exist and they answer different questions. Mixing them up looks exactly like a double-counting bug:
| Number | Source | Reset by |
|---|---|---|
total_up / total_down on /v1/summary |
the users' quota counters | a traffic reset, or a plan rolling the period over |
/v1/stats/series, /v1/stats/nodes |
the daily traffic history | nothing — it accumulates |
So the summary's totals are usage in the current quota period, which is what a data limit is measured against, and they are routinely smaller than the history over the same span. That is not traffic going missing: it is one user's counters having been reset while their history stayed. Bill from the quota counters, report from the history, and don't expect the two to agree.
GET $BASE/v1/stats/abuse?limit=50 → recent blocklist matches across the fleet
Two read-only trails. Events is what happened to users; admin audit is what admins and API keys did to the panel — including everything done through this API, attributed to the key's name.
| Method | Path | Description |
|---|---|---|
GET |
/v1/events |
User events, filterable. |
GET |
/v1/events/catalog |
The event keys a row can carry. |
GET |
/v1/admin-audit |
The admin trail. |
GET |
/v1/admin-audit/catalog |
Its categories and the actions in each. |
Both page backwards: pass before=<id of the oldest row you hold> and read
next_before from the response — 0 means you have reached the end. Ids are
monotonic, so the cursor stays stable while new rows land at the top.
{ "data": { "events": [ { "id": 812, "user_id": 5, "action": "plan.cancelled", … } ],
"next_before": 780 } }/v1/events takes action (one key from the catalog), actor (admin / user /
system / api), user_id and the paging pair. /v1/admin-audit takes category
(expands to the actions it holds), action, actor and the paging pair. An unknown
action or category is a 400, not an empty page — a filter that quietly matches
nothing is indistinguishable from a quiet period.
The push half of an integration, configurable from the integration itself.
| Method | Path | Description |
|---|---|---|
GET |
/v1/webhooks |
List the configured endpoints. |
GET |
/v1/webhooks/events |
The event keys a webhook can subscribe to. |
POST |
/v1/webhooks |
Add an endpoint. |
POST |
/v1/webhooks/{id} |
Update one (whole object). |
DELETE |
/v1/webhooks/{id} |
Delete one. |
POST |
/v1/webhooks/{id}/test |
Send a test delivery. |
Body: { "url": "https://…", "events": ["user.created"], "enabled": true }. An empty
events array means all events. enabled is optional on create (defaults to on).
The delivery format, signature and retry policy are described under Webhooks below.
A test delivery answers 200 even when the endpoint fails — the call succeeded, the
delivery is the result: { "data": { "status": 502, "ok": false, "error": "…" } }.
The moderated signup queue (only meaningful while the user bot is in moderation mode —
moderation says whether it is).
| Method | Path | Description |
|---|---|---|
GET |
/v1/registrations |
Pending signups. |
POST |
/v1/registrations/{id}/approve |
Create the account and link its Telegram chat. |
POST |
/v1/registrations/{id}/reject |
Drop the request. |
GET $BASE/v1/summary → users / online / traffic totals / xray + cert status
GET $BASE/v1/system → live CPU / RAM / disk / network / VPN throughput
GET $BASE/v1/health/report → full self-diagnostics (xray, config, TLS, geo, egress lanes)
GET $BASE/v1/nodes/{id}/health → one server's diagnostics (id 0 = the master)
GET $BASE/v1/nodes/{id}/logs → a node's recent log lines
GET $BASE/v1/backup/info → what a backup taken now would contain
GET $BASE/v1/backup → download that backup (.tar.gz body, not JSON)
GET $BASE/v1/metrics → Prometheus exposition (text, not JSON)
/v1/metrics is a scrape target for Prometheus, authenticated with the same API key:
scrape_configs:
- job_name: rospanel
scheme: https
metrics_path: /<api-path>/v1/metrics
authorization:
credentials: rp_...
static_configs:
- targets: ["vpn.example.com"]It publishes user counts, lifetime and daily traffic, live throughput, Xray and certificate state, and the panel host's own CPU/RAM/disk.
Every server also gets its own series, labelled node and node_id (the master is
node_id="0"): rospanel_node_online, rospanel_node_enabled,
rospanel_node_xray_running, rospanel_node_last_seen_seconds,
rospanel_node_traffic_today_bytes{direction=…}, plus the machine under it —
rospanel_node_cpu_percent, rospanel_node_memory_bytes{state=…},
rospanel_node_disk_bytes{state=…} and rospanel_node_host_uptime_seconds. A node that
has never reported (or whose agent predates these fields) simply has no samples, which is
what a gap in a graph should mean — rather than a row of zeros that reads as an idle
machine.
There are deliberately no per-user series: a panel with a thousand users would produce a thousand time series per metric.
/v1/nodes/{id}/logs is answered from the node's next long-poll, so a freshly-woken
node may return the previous batch — at is the unix time the lines were collected.
/v1/backup streams the archive itself (Content-Type: application/gzip), so it is
the one endpoint outside the {"data": …} envelope — point a scheduler at it to keep
copies off the box. Restore is deliberately not exposed: it is staged on disk and
applied at the next start, so over an API it would be a request that silently replaces
everything on the next restart.
The panel serves this API to an assistant over the Model Context Protocol itself. There is nothing to install anywhere: an assistant that takes a URL:
$BASE/v1/mcp/<api-key> read-only
$BASE/v1/mcp/<api-key>/write plus everything that changes state
The key rides in the path because those dialogs accept a URL and nothing else: the address is the credential, exactly as secret as the key inside it, and it stops working the moment that key is revoked. Build it by hand from the two values Settings → API gives you — the base address shown there, and a key at the moment you create it (it is never shown again).
The two addresses are the same server with a different toolbox. The short one cannot delete a user even though the key behind it could — which is the point: an assistant acting on a misread sentence should not be able to, and the operator chooses that by which URL they paste.
Transport is MCP's Streamable HTTP: one JSON-RPC message per POST, answered with
application/json; notifications get 202 and no body; GET is 405 (this endpoint has no
server-initiated stream). Calls are dispatched against the panel's own REST API in process, so
they carry the same permissions, the same audit trail and the same error shapes as any other
client of /v1.
A list call with no limit gets 50 rows, not the REST default of 100: an assistant pays
for every row in context. Ask for more explicitly and you get it, limit=0 (everything)
included.
Above that sits a hard ceiling of 512 KB per call. A response over it is shortened by
whole rows, and meta says so — limit becomes the count actually returned while total
still reports how many exist, so paging on limit/offset reaches the rest. The result
stays valid JSON.
The backup surface is not offered as a tool at all. GET /v1/backup because its body is
a tarball an assistant cannot read, and GET /v1/backup/info because knowing what a dump
would contain is only useful to somebody about to take one — an operator's job, done from the
panel where the file actually lands somewhere. Restoring is not in /v1 in any form: it lives
in the panel behind a session and a re-entered password, so no key, and no assistant holding
one, can put a backup back.
The tool list is generated from the OpenAPI document above, so it never drifts from the API:
an endpoint added to /v1 becomes a tool with no one remembering to register it, and a
removed one disappears. That includes the configuration half — an assistant on the /write
address can read and change settings, rewrite a server's routing, take and roll back config
save-points and restart Xray.
Each tool carries the annotations a client uses to decide whether to ask its human first
(readOnlyHint, destructiveHint). Which writes count as destructive is declared by the
route, not guessed from its wording — a rewritten sentence must not be able to turn a
warning off, and the calls that reroute traffic, restart Xray, roll the config back or take
the panel into maintenance are all flagged. Answers come back as text and, when the panel replied
with JSON, as structuredContent too; one answer is capped so a single call cannot fill the
assistant's context — ask for a smaller page (limit/offset) rather than the lot.
Create a user:
curl -sS -X POST "$BASE/v1/users" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"alice","data_limit":0,"expire_at":0}'Fetch a user's subscription URL:
curl -sS "$BASE/v1/users/5" -H "Authorization: Bearer $KEY" \
| jq -r '.data.sub_url'Delete a user:
curl -sS -X DELETE "$BASE/v1/users/5" -H "Authorization: Bearer $KEY"Instead of polling the API, you can have the panel push lifecycle events to
your own HTTP endpoint. Configure them in the panel → Settings → API →
Webhooks (add a receiver URL and tick the events you want — tick none = all), or
over the API itself: POST /v1/webhooks.
Webhook targets, unlike the API's outbound fetches, may be http or https
and may point at a private/localhost host — the receiver is often the
operator's own internal service, and each delivery is a blind POST (the response
body is never read).
| Event | Fires when |
|---|---|
user.created |
a user is created (panel or API) |
user.deleted |
a user is deleted |
user.registered |
a user self-registers via the Telegram user bot |
user.expired |
a subscription lapses |
user.limited |
a user exhausts their traffic quota |
user.device_limited |
a user exceeds their device limit |
payment.created |
a payment order is opened |
payment.paid |
an order is paid and the plan applied |
payment.cancelled |
an order is cancelled |
Each delivery is an HTTP POST with a JSON body:
{
"id": "3f1c…", // unique delivery id
"event": "user.created",
"created_at": 1767225600,
"data": { "id": 7, "name": "alice", "status": "active", "enabled": true,
"expire_at": 0, "data_limit": 0, "plan_id": 0 }
}data is the user object for user.* events and the payment order for
payment.* events.
Headers:
Content-Type: application/json
User-Agent: RosPanel-Webhook/1
X-RosPanel-Event: user.created
X-RosPanel-Signature: sha256=<hex HMAC-SHA256 of the raw body>
Every webhook has a secret (shown in the panel). Recompute the HMAC over the raw request body and compare in constant time:
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)import crypto from "node:crypto";
function verify(secret, body, header) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Return a 2xx status to acknowledge. A non-2xx response or a connection error is
retried with a growing backoff (roughly 10s, 30s, 2m, 10m — up to 5 attempts),
then dropped. Deliveries can arrive out of order and, on retry, more than
once — treat the id field as an idempotency key. The Test button in the
panel sends a ping delivery so you can confirm reachability and signature
verification. The last delivery's status is shown next to each webhook.