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).
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 / 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. |
GET |
/v1/users/{id}/connections |
List the user's recent source IPs / devices. |
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. |
GET |
/v1/billing/orders?status=pending |
List payment orders (status optional). |
POST |
/v1/billing/orders |
Open an order for a user+plan. |
POST |
/v1/billing/orders/{id}/confirm |
Mark an order paid (activates the plan). |
POST |
/v1/billing/orders/{id}/cancel |
Cancel an order. |
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.
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). |
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.
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), 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.
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).
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.
GET $BASE/v1/stats/series?user_id=5&from=2026-01-01&to=2026-01-31 → daily traffic points
GET $BASE/v1/stats/nodes?user_id=5&from=2026-01-01&to=2026-01-31 → traffic split by server
GET $BASE/v1/stats/users?from=2026-01-01&to=2026-01-31 → per-user totals
user_id is optional on series and nodes (omit for a panel-wide figure).
from/to are YYYY-MM-DD (in the panel's configured timezone).
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 }
] }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)
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 → Вебхуки: add a receiver URL and tick the events you want (tick none = all).
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.