Full Python SDK for the Remnawave panel API — every endpoint, typed, sync and async
Project description
rw-sdk
Full Python SDK for the Remnawave panel API. Every endpoint, typed, sync and async.
pip install rw-sdk
from rw_sdk import Remnawave
with Remnawave("https://panel.example.com", token="...") as rw:
user = rw.users.create(
username="alice",
expire_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
traffic_limit_bytes=100 * 1024**3,
)
print(user.subscription_url, user.status)
for u in rw.users.iter_all(): # pages fetched automatically
print(u.username, u.user_traffic.used_traffic_bytes)
| API version | 2.8.1 — 186 operations across 27 resources, all implemented |
| Models | 580 pydantic v2 models, named after Remnawave's own zod contract schemas |
| Async | every method mirrored on AsyncRemnawave |
| Verified | offline contract tests + live integration run against a real 2.8.1 panel |
Authentication
Use an API token created on the panel's API Tokens page. That is the supported mode for automation and what everything here is built around.
rw = Remnawave("https://panel.example.com", token="eyJhbGci...")
11 endpoints an API token cannot reach
An API token always carries role API. Three controllers declare @Roles(ROLE.ADMIN)
without ROLE.API, so calling them with a token returns 403 A004 no matter its
scopes — verified against a live 2.8.1 panel:
| Endpoints | Why |
|---|---|
rw.api_tokens.* (4) |
tokens are minted from the panel UI or an admin session |
rw.passkeys.* (5) |
passkey management is a dashboard flow |
rw.settings.* (2) |
/api/remnawave-settings is admin-only |
They are still generated — this SDK covers all 186 operations — and each one says so in
its docstring. rw_sdk.ENDPOINTS carries an admin_only flag if you want to check
programmatically.
Everything else (users, nodes, hosts, squads, config profiles, subscriptions, stats, HWID, IP control, infra billing, snippets, templates …) works with an API token.
Admin JWT, if you need those 11
auth.login returns an admin-role JWT, which JwtDefaultGuard rejects unless the
request also carries X-Remnawave-Client-Type: browser (def-jwt-guard.ts).
The SDK does not send that header — it targets API tokens. Supply it yourself if
you need the admin path:
rw = Remnawave(
"https://panel.example.com",
headers={"X-Remnawave-Client-Type": "browser"},
)
rw.set_token(rw.auth.login(username="admin", password="...").access_token)
token = rw.api_tokens.create(name="ci", expires_in_days=90, scopes=["users:*"])
print(token.token) # returned once
Without the header every call returns 403. Per-endpoint API-token scopes are in each
method's docstring.
The HTTPS/proxy requirement
In production Remnawave destroys the socket of any request that lacks
x-forwarded-for or does not carry x-forwarded-proto: https
(proxy-check.middleware.ts) — you get a connection reset, not an HTTP error.
proxy_headers="auto" (the default) sends those headers only when the target looks
like a direct connection — plain http://, or a loopback/private host. Behind a real
reverse proxy the headers already exist, and forging x-forwarded-for there would
corrupt the panel's client-IP accounting, so they are left alone.
Remnawave("http://127.0.0.1:3000", token=t) # headers sent
Remnawave("https://panel.example.com", token=t) # not sent
Remnawave("https://panel.example.com", token=t, proxy_headers="always") # forced
A reset connection is surfaced as ProxyRestrictionError with the fix in the message.
Pagination
Seven endpoints paginate. Each gets an iterator that handles the offset or cursor for you:
for user in rw.users.iter_all(page_size=500): # offset: start/size/total
...
for user in rw.users.iter_stream(page_size=1000): # cursor: nextCursor/hasMore
...
async for device in arw.hwid.iter_all(): # same on the async client
...
The underlying single-page calls (rw.users.get_all(start=..., size=...)) are still
there when you want them.
Filtering and sorting
Four list endpoints take TanStack-table style filters. None of them appear in the
OpenAPI document — the panel reads them with JSON.parse, and this SDK serialises them
for you:
from rw_sdk.models import TanstackFilter, TanstackSorting
page = rw.users.get_all(
filters=[TanstackFilter(id="username", value="alice")],
filter_modes={"username": "startsWith"},
sorting=[TanstackSorting(id="createdAt", desc=True)],
)
# plain dicts work too, and iterators forward the filter
for user in rw.users.iter_all(filters=[{"id": "tag", "value": "VIP"}]):
...
Modes: equals, startsWith, endsWith, greaterThan, greaterThanOrEqualTo,
lessThan, lessThanOrEqualTo, between. Anything else falls through to a contains
match rather than erroring. A sorting id is passed straight to the query builder, so
an unknown column returns 500.
Omitted vs null
Remnawave's PATCH endpoints distinguish an absent key ("leave unchanged") from an
explicit null ("clear this field"). Passing None means null; leaving an argument
out sends nothing.
rw.users.update(uuid=u.uuid, description="new") # tag untouched
rw.users.update(uuid=u.uuid, tag=None) # tag cleared
Raw subscriptions
GET /api/sub/{shortUuid} and /api/sub/{shortUuid}/{clientType} are written straight
to the socket by the panel — the body may be base64, YAML, JSON or an encrypted blob
depending on the matched response rule, and the useful metadata is in the headers.
They return a RawSubscription instead of a model:
raw = rw.sub.get_raw(user.short_uuid)
raw.text # decoded body
raw.user_info # {'upload': 0, 'download': 0, 'total': ..., 'expire': ...}
raw.headers["profile-title"]
from rw_sdk.models import ClientType
rw.sub.get_by_client_type(user.short_uuid, ClientType.SINGBOX).json()
Errors
from rw_sdk import errors
try:
rw.users.get_by_uuid(uuid)
except errors.NotFoundError as e:
print(e.status_code, e.error_code, e.error) # 404 A019 ErrorCode.USER_NOT_FOUND
except errors.ValidationError as e:
for issue in e.errors: # zod field-level failures
print(issue["path"], issue["message"])
except errors.PermissionDeniedError:
... # role or token scope too narrow
except errors.APIConnectionError:
... # network, timeout, or proxy check
All 236 panel error codes are available as errors.ErrorCode. 5xx, 429 and connection
failures are retried twice with jittered backoff by default (max_retries=).
Transport
Built on niquests — sync and async from one library, HTTP/1.1, HTTP/2 and HTTP/3.
Measured against a real 2.8.1 panel, same workload, versus the same SDK on httpx:
| httpx | niquests | |
|---|---|---|
| one user by name | 1.74 ms | 1.68 ms |
| page of 300 users | 14.56 ms | 10.73 ms |
| 100 concurrent lookups | 176.6 ms | 42.7 ms |
The concurrency gap is the one that matters — httpx's per-request overhead multiplies
under asyncio.gather, and raising its connection limits makes it worse, not better.
benchmarks/http_clients.py reproduces the comparison across httpx, urllib3, rnet,
curl_cffi, aiohttp and aiosonic on your own hardware and latency.
Pass your own session to tune it:
import niquests
Remnawave(url, token=t, http_client=niquests.Session(base_url=url, pool_maxsize=100))
Escape hatch
Newer panel than this SDK? Call anything directly — auth, retries, headers and error mapping still apply:
resp = rw.request("POST", "/api/some/new/endpoint", json={"foo": 1})
resp.json()
Versioning
The SDK version tracks the panel API version it was generated from.
| Branch | Targets |
|---|---|
main |
latest released Remnawave |
2.8.1 |
pinned to panel 2.8.1 |
pip install "rw-sdk==2.8.1.*" # stay on the 2.8.1 API
The X.Y.Z part is the panel API version. A .postN suffix means an SDK-only fix
against that same API — 2.8.1 < 2.8.1.post1 < 2.8.2.
extra="allow" on every model means a panel that adds fields will not break your
client; a panel that removes or retypes them can, which is what the pinned branches
are for.
How this is built
The Remnawave OpenAPI spec is generated by nestjs-zod and inlines every schema — 268
components with zero $refs between them. Point a stock generator at it and you get
either ~20 mutually-incompatible copies of the User entity (openapi-python-client) or
deduped-but-anonymous Response19 classes (datamodel-code-generator). Neither unwraps
the {"response": ...} envelope every endpoint uses.
So the pipeline is three stages:
codegen/refify.pyrewrites the spec into one with real$refs, deduping shapes by structural fingerprint and naming them from Remnawave's own zod contract models (libs/contract/models/*.schema.ts, including.extend()/.merge()composition).- datamodel-code-generator turns that into pydantic v2 models. Model generation is a solved problem; we do not reimplement it.
codegen/generate.pyemits the layer no generator provides — resource classes, envelope unwrapping, auto-paging iterators, raw-subscription handling, and scope docs read out of the contract sources.
Regenerate for a new panel release:
git clone --depth 1 --branch 2.9.0 https://github.com/remnawave/backend /tmp/rw
curl -o specs/openapi-2.9.0.json https://panel.example.com/docs-json # IS_DOCS_ENABLED=true
pip install "rw-sdk[codegen]"
python3 codegen/generate.py specs/openapi-2.9.0.json --backend /tmp/rw
pytest
What the spec gets wrong
All 186 operations were cross-checked line-by-line against the backend sources at tag
2.8.1 and against a running panel. What that turned up, and how it is handled:
- 22 query parameters are missing from the document. A hand-written
@ApiQuerydecorator replaces the zod-derived parameter list wholesale, so controllers that documentstart/sizesilently drop everything else. Six operations were affected — four lose the whole TanStack filter/sort set,/api/infra-billing/historyloses its pagination,/api/system/stats/bandwidthlosestz.codegen/spec_patches.pyputs them back, deriving the TanStack ones from which commands importTanstackQueryRequestQuerySchemarather than from a hardcoded list. - 12 auth/passkey operations document no 2xx response at all (no
@ApiResponse(200)decorator), so a spec-only generator types themAny. Their return types are read from the NestJS handlers'Promise<XxxResponseDto>declarations instead. templateJsonisz.nullable(z.unknown())— optional in zod, but emitted as required, while the panel omits the key entirely. Trusting the spec makessubscription_template.get_all()raise on a real panel.z.unknown()-shaped properties are dropped fromrequired.type: number(zodz.number()without.int()) covers ids and byte counters. Mapped toint | floatso an 8-byte traffic total stays exact instead of becoming a float.- A short page is not the last page.
getAllSubscriptionsskips users whose subscription fails to build but still reportstotalas the user count, so a page can come back short — or empty — with rows remaining. The iterators advance by the requested page size and stop ontotal, never on a short page. - 11 endpoints are unreachable with an API token (
@Roles(ROLE.ADMIN)); flagged in their docstrings and inENDPOINTS[...]["admin_only"]. - Documented status codes are unreliable — several POSTs documented as
200really return201, and some "not found" cases arrive as500with anerrorCode. The client accepts any 2xx and maps exceptions by HTTP status, never by the documented response list. - A 5xx carrying an
errorCodeis not retried.HttpExceptionFilterstamps that field only onto deliberate failures — config-profile (A112/A061) and settings (A199/A193) validation both surface as coded 500s — so retrying only delays the error. Uncoded 5xx, 429 and connection failures still retry. - The two raw subscription endpoints are
@Res()handlers with no schema; they are special-cased rather than typed asAny.
Running against a local panel
git clone --depth 1 --branch 2.8.1 https://github.com/remnawave/backend /tmp/rw
cd /tmp/rw && cp .env.sample .env # set APP_SECRET, IS_DOCS_ENABLED=true
docker compose -f docker-compose-prod.yml up -d
export RW_SDK_TEST_URL=http://127.0.0.1:3000
export RW_SDK_TEST_TOKEN=... # an API token from the panel
export RW_SDK_TEST_ADMIN_TOKEN=... # optional: admin JWT, unlocks the admin-only tests
pytest tests/test_live.py
The panel runs in production mode, so requests need the forwarded headers — the live
tests pass proxy_headers="always".
License
MIT. Remnawave itself is AGPL-3.0; this is an independent client, not affiliated with the Remnawave project.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rw_sdk-2.8.1.tar.gz.
File metadata
- Download URL: rw_sdk-2.8.1.tar.gz
- Upload date:
- Size: 153.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5e087f579355079f1a8d4fb65f1e453a9faa4777f02d75f52a14c773580a9b15
|
|
| MD5 |
243240ccce95182f484fb9b5c83f3cc5
|
|
| BLAKE2b-256 |
c3c3dd5a5545f1870f22837007e9a2828abfa716ebbee880a7af9e0f2db0125e
|
Provenance
The following attestation bundles were made for rw_sdk-2.8.1.tar.gz:
Publisher:
publish.yml on akenai-vpn/rw-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rw_sdk-2.8.1.tar.gz -
Subject digest:
5e087f579355079f1a8d4fb65f1e453a9faa4777f02d75f52a14c773580a9b15 - Sigstore transparency entry: 2256134130
- Sigstore integration time:
-
Permalink:
akenai-vpn/rw-sdk@f61c1510b98e1099e66d5920fbcf9d4073de6796 -
Branch / Tag:
refs/tags/v2.8.1 - Owner: https://github.com/akenai-vpn
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f61c1510b98e1099e66d5920fbcf9d4073de6796 -
Trigger Event:
release
-
Statement type:
File details
Details for the file rw_sdk-2.8.1-py3-none-any.whl.
File metadata
- Download URL: rw_sdk-2.8.1-py3-none-any.whl
- Upload date:
- Size: 92.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efe3bedc472e76f2267da8154937f1f73af47df631364f0641904e8433acd7ae
|
|
| MD5 |
314970f05ebcc58399eb153c252c8325
|
|
| BLAKE2b-256 |
cfcf553a117c8e9ee8cf30affd88721d22704f53067b4e45ef9594af6c3fde48
|
Provenance
The following attestation bundles were made for rw_sdk-2.8.1-py3-none-any.whl:
Publisher:
publish.yml on akenai-vpn/rw-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rw_sdk-2.8.1-py3-none-any.whl -
Subject digest:
efe3bedc472e76f2267da8154937f1f73af47df631364f0641904e8433acd7ae - Sigstore transparency entry: 2256134139
- Sigstore integration time:
-
Permalink:
akenai-vpn/rw-sdk@f61c1510b98e1099e66d5920fbcf9d4073de6796 -
Branch / Tag:
refs/tags/v2.8.1 - Owner: https://github.com/akenai-vpn
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f61c1510b98e1099e66d5920fbcf9d4073de6796 -
Trigger Event:
release
-
Statement type: