Skip to main content

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:

  1. codegen/refify.py rewrites 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).
  2. datamodel-code-generator turns that into pydantic v2 models. Model generation is a solved problem; we do not reimplement it.
  3. codegen/generate.py emits 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 @ApiQuery decorator replaces the zod-derived parameter list wholesale, so controllers that document start/size silently drop everything else. Six operations were affected — four lose the whole TanStack filter/sort set, /api/infra-billing/history loses its pagination, /api/system/stats/bandwidth loses tz. codegen/spec_patches.py puts them back, deriving the TanStack ones from which commands import TanstackQueryRequestQuerySchema rather 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 them Any. Their return types are read from the NestJS handlers' Promise<XxxResponseDto> declarations instead.
  • templateJson is z.nullable(z.unknown()) — optional in zod, but emitted as required, while the panel omits the key entirely. Trusting the spec makes subscription_template.get_all() raise on a real panel. z.unknown()-shaped properties are dropped from required.
  • type: number (zod z.number() without .int()) covers ids and byte counters. Mapped to int | float so an 8-byte traffic total stays exact instead of becoming a float.
  • A short page is not the last page. getAllSubscriptions skips users whose subscription fails to build but still reports total as 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 on total, never on a short page.
  • 11 endpoints are unreachable with an API token (@Roles(ROLE.ADMIN)); flagged in their docstrings and in ENDPOINTS[...]["admin_only"].
  • Documented status codes are unreliable — several POSTs documented as 200 really return 201, and some "not found" cases arrive as 500 with an errorCode. The client accepts any 2xx and maps exceptions by HTTP status, never by the documented response list.
  • A 5xx carrying an errorCode is not retried. HttpExceptionFilter stamps 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 as Any.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rw_sdk-2.8.1.tar.gz (153.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rw_sdk-2.8.1-py3-none-any.whl (92.5 kB view details)

Uploaded Python 3

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

Hashes for rw_sdk-2.8.1.tar.gz
Algorithm Hash digest
SHA256 5e087f579355079f1a8d4fb65f1e453a9faa4777f02d75f52a14c773580a9b15
MD5 243240ccce95182f484fb9b5c83f3cc5
BLAKE2b-256 c3c3dd5a5545f1870f22837007e9a2828abfa716ebbee880a7af9e0f2db0125e

See more details on using hashes here.

Provenance

The following attestation bundles were made for rw_sdk-2.8.1.tar.gz:

Publisher: publish.yml on akenai-vpn/rw-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for rw_sdk-2.8.1-py3-none-any.whl
Algorithm Hash digest
SHA256 efe3bedc472e76f2267da8154937f1f73af47df631364f0641904e8433acd7ae
MD5 314970f05ebcc58399eb153c252c8325
BLAKE2b-256 cfcf553a117c8e9ee8cf30affd88721d22704f53067b4e45ef9594af6c3fde48

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page