Remnawave Python SDK — fork
Asynchronous Python client for the Remnawave panel API, built on
httpx + pydantic v2, with orjson for fast serialization.
[!IMPORTANT] This is a fork of
remnawave/python-sdk. It is published to PyPI asremnactual(the upstreamremnawaveproject belongs to the Remnawave community). The import name is unchanged —import remnawave. The fork exists to track the Remnawave 3.x API closely and to fix the divergences that the migration from 2.7.x left behind — see What this fork changes. Attribution for the original work is at the bottom of this file.
Compatibility
| SDK version | Remnawave panel | Backend contract |
|---|---|---|
| 3.0.0 | >= 3.0.0 | @remnawave/backend-contract 3.0.0 |
| 2.8.1 | >= 2.8.0, < 3.0 | @remnawave/backend-contract 2.8.35 |
Every endpoint, request body and response model in this fork is verified against
libs/contract of remnawave/backend at tag 3.0.0.
Remnawave 3.0 is not backwards compatible with 2.8 — users are identified by a numeric
idinstead of auuid,/api/ip-controlbecame/api/connections, and many endpoints answer with an empty body. Pin2.8.1if your panel is still on the 2.8 line.
Requirements: Python >= 3.11, < 3.15.
Installation
pip install remnactual
The import name stays remnawave:
from remnawave import RemnawaveSDK
A specific branch straight from git:
pip install "git+https://github.com/IceOne-i/remnactual.git@feat/api-3.0"
Quick start
import asyncio
import os
from remnawave import RemnawaveSDK
from remnawave.models import GetAllUsersResponseDto
async def main() -> None:
sdk = RemnawaveSDK(
# https://panel.example.com or http://127.0.0.1:3000
base_url=os.environ["REMNAWAVE_BASE_URL"],
# Bearer token from the panel: Settings -> API Tokens
token=os.environ["REMNAWAVE_TOKEN"],
)
page: GetAllUsersResponseDto = await sdk.users.get_all_users(start=0, size=50)
print(f"total: {page.total}")
for user in page.users:
# 3.0: users are identified by a numeric id; there is no `uuid` field any more
print(user.id, user.username, user.status, user.expire_at)
asyncio.run(main())
The transport unwraps the API's {"response": ...} envelope for you, so responses are the
payload itself.
Client options
sdk = RemnawaveSDK(
base_url="https://panel.example.com",
token="...",
caddy_token="...", # X-Api-Key when the panel sits behind Caddy
ssl_ignore=False, # skip TLS verification (self-signed certs)
custom_headers={"X-Real-IP": "1.2.3.4"},
cookies={"session": "..."}, # reverse-proxy auth
)
You can also pass a pre-configured client and manage its lifetime yourself:
import httpx
async with httpx.AsyncClient(
base_url="https://panel.example.com/api",
headers={"Authorization": "Bearer ..."},
) as client:
sdk = RemnawaveSDK(client=client)
base_url is normalised automatically: a trailing slash is stripped and /api is appended
when missing.
Controllers
| Attribute | Covers |
|---|---|
sdk.users |
CRUD, lookups by id / short-uuid / username, actions (incl. extend), tags, stream (cursor pagination + filters), accessible nodes |
sdk.users_bulk_actions |
bulk/* and bulk/all/* operations |
sdk.nodes |
CRUD, enable/disable/restart, reorder, reset traffic, bulk actions, tags |
sdk.hosts / sdk.hosts_bulk_actions |
CRUD, reorder, tags, bulk enable/disable/delete/update |
sdk.config_profiles |
Config profiles, inbounds, computed config, reorder |
sdk.inbounds |
Inbounds under /config-profiles/inbounds |
sdk.internal_squads / sdk.external_squads |
Squads, membership bulk actions, accessible nodes, reorder |
sdk.subscription |
Public /sub/{shortUuid} endpoints |
sdk.subscriptions |
Admin subscription lookups, raw subscription, connection keys |
sdk.subscriptions_settings / sdk.subscriptions_template / sdk.subscription_page_config |
Subscription settings, templates, subscription page configs |
sdk.subscription_request_history |
Request history and its stats |
sdk.hwid |
HWID devices, stats, top users |
sdk.connections |
Per-user / per-node connection jobs, drop connections (was sdk.ip_control) |
sdk.node_plugins |
Node plugins, executor, torrent-blocker reports |
sdk.infra_billing |
Providers, billing nodes, billing history |
sdk.bandwidthstats |
Per-node and per-user bandwidth stats (incl. legacy endpoints) |
sdk.system |
Stats, digest, HTTP counters, health, metrics, recap, x25519, SRR matcher |
sdk.auth / sdk.passkeys / sdk.api_tokens_management |
Login, OAuth2, passkeys, scoped API tokens |
sdk.remnawave_settings / sdk.snippets / sdk.keygen / sdk.metadata |
Panel settings, snippets, node secret key, user/node metadata |
sdk.webhook_utility |
Webhook signature validation and payload parsing |
Request bodies and null
Request models are serialized with model_dump(exclude_unset=True), i.e. exactly the fields
you set are sent. Several fields can only be cleared by sending an explicit null:
from remnawave.models import UpdateUserBodyDto
# `telegram_id` is cleared, `email` is left untouched
await sdk.users.update_user(
UpdateUserBodyDto(id=user_id, telegram_id=None)
)
Fields you do not pass are omitted from the payload, so the server's own defaults apply.
Endpoints without a response body
In 3.0, 43 endpoints answer 204 No Content or 202 Accepted with an empty body: every
DELETE, the asynchronous bulk operations, node restarts, squad membership changes and the
plugin executor. Their SDK methods return None — success is "no exception raised", and
affected-row counts are no longer reported by the API.
await sdk.users.delete_user(user_id) # -> None (204)
await sdk.users_bulk_actions.bulk_delete_users(
BulkDeleteUsersBodyDto(user_ids=[1, 2, 3])
) # -> None (204)
await sdk.nodes.restart_node(node_uuid, RestartNodeBodyDto(force_restart=True)) # -> None (202)
Error handling
Every non-2xx response raises a subclass of ApiError. The exception class is derived from
the httpCode declared for that error code in the backend contract.
from remnawave.exceptions import ApiError, NotFoundError, ConflictError
try:
user = await sdk.users.get_user_by_id(user_id)
except NotFoundError as e:
print(e.code, e.message) # A025 User not found
except ConflictError:
...
except ApiError as e:
print(e.status_code, e.error)
remnawave.enums.ErrorCode contains every error code of the contract; ERROR_HTTP_CODES and
ERROR_MESSAGES in remnawave.enums.error_code expose the declared status and default message.
Webhooks
from remnawave import RemnawaveSDK
sdk = RemnawaveSDK(base_url=..., token=...)
# `raw_body` must be the RAW request body (bytes or str) — re-serializing a parsed dict
# can change the byte string and break the signature check.
payload = sdk.webhook_utility.parse_webhook(raw_body, request_headers, webhook_secret)
if payload is None:
... # invalid signature
if sdk.webhook_utility.is_user_event(payload.event):
user = payload.data
if payload.event == "user.expiration":
# single event replacing user.expires_in_*; hours are in meta
print(payload.meta.expiration)
What this fork changes
This fork tracks the Remnawave API closely and fixes the divergences each upstream migration left behind.
Migration to 3.0
- Users are identified by a numeric
id. Theuuidfield is gone from the user model, all{uuid}path params became{userId}(integers), and bulk bodies takeuserIds. /api/ip-controlbecame/api/connections—sdk.ip_controlis nowsdk.connections, withby-user/by-nodejobs anddrop.- 43 endpoints answer with an empty body (204/202) and their methods return
None; theDelete*ResponseDto/Bulk*ResponseDtomodels were removed. - Removed upstream, removed here —
GET /users/by-email,/by-tag,/by-telegram-id,/by-id, both legacy bandwidth-stats endpoints, thedocsobject ofGET /tokens, and theprofileTitle/profileUpdateInterval/supportLink/isProfileWebpageUrlEnabled/happAnnounce/happRoutingsubscription settings (they moved to response headers). - Added —
POST /users/{userId}/actions/extend,POST /tokens/ott,GET /system/stats/digest,GET /system/stats/http,POST /bandwidth-stats/nodes/usage, internal-squad usage stats and targeted squad membership bulk actions,GET /subscriptions/by-id/{userId}. - Renamed —
keygenreturnssecretKeyinstead ofpubKey; external squads splitresponseHeadersintoresponseHeadersAdd/responseHeadersRemove; request models follow the contract's*BodyDtonaming, with the old*RequestDtonames kept as aliases.
Earlier: 2.8 compliance
- Removed the endpoints 2.8 dropped (
/xray,/inbounds/bulk/*,/sub/outline/...,POST /nodes/actions/reset-traffic,POST /auth/oauth2/tg/callback,GET /bandwidth-stats/nodes/realtime). userUuid→userIdin HWID devices and subscription request history;hwidCheckupreplacedisHwidLimited;resolvedProxyConfigsreplacedrawHosts.- Error codes regenerated from the contract, exceptions mapped from the declared
httpCode. - Name collisions in
remnawave.modelsresolved (webhook models are prefixedWebhook*). - Serialization switched from
exclude_nonetoexclude_unset, so an explicitnullreaches the API; keys the contract requires unconditionally are always emitted.
Development
The project is managed with uv — there is no lock file, because a library should resolve against whatever its consumers already have.
uv sync --group dev
# offline tests (3.0 contract regressions, models, enums, controller surface)
uv run pytest tests/test_3_0_compliance.py tests/test_models_validation.py \
tests/test_enums.py tests/test_controllers_completeness.py
# full suite — requires a live panel
# REMNAWAVE_BASE_URL, REMNAWAVE_TOKEN and the REMNAWAVE_* fixtures in tests/conftest.py
uv run pytest
# build locally — the version comes from the git tag
uv build
Releasing
The version lives in exactly one place: the git tag. pyproject.toml carries no version
number — hatch-vcs derives it from git describe at build time, so a release
is a single action:
git tag v3.0.1
git push origin v3.0.1
Pushing to a branch never publishes anything. The Publish Python Package workflow then builds
the wheel and sdist (their version comes straight from the tag), runs the offline test suite,
asserts that the built version equals the tag, publishes to PyPI via Trusted Publishing
(OIDC — no tokens), and finally creates the GitHub Release with the artifacts attached.
Between tags the version is a PEP 440 development version derived from the last tag, e.g.
3.0.1.post7.dev0+g1a2b3c4, and remnawave.__version__ reports whatever was installed:
import remnawave
print(remnawave.__version__)
To rehearse without touching PyPI: Actions → Publish Python Package → Run workflow →
target testpypi.
Credits
This SDK is a fork of the official
remnawave/python-sdk, maintained by the
Remnawave community.
Upstream history:
- originally written by @kesevone;
- previously maintained by @sm1ky at
sm1ky/remnawave-api; - now maintained by the Remnawave community at
remnawave/python-sdk.
The Remnawave panel itself lives at remnawave/backend;
API documentation is at docs.rw.
License
MIT — see LICENSE. The original license and copyright of the upstream project are preserved.
Metadata
Release files for remnactual 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| remnactual-3.0.0.tar.gz | 114.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| remnactual-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 237.4 kB
Release files / remnactual-3.0.0.tar.gz
| Download URL | remnactual-3.0.0.tar.gz |
|---|---|
| Size | 114.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
937fa4e56ea22b141594db728ca8fb3d282c914cd2c938e44134232d60df0581
|
|
BLAKE2b-256 checksum How to use checksums |
b0ea0cdfbc17dab7424f15aca33ac487783f9a2f47006c4da3e22d70b77f7b70
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.
Transparency logRelease files / remnactual-3.0.0-py3-none-any.whl
| Download URL | remnactual-3.0.0-py3-none-any.whl |
|---|---|
| Size | 123.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0f9124d61d5d52434f3fca0602c6a3aab0845f9f0312d6e36c39f9fd12a02e33
|
|
BLAKE2b-256 checksum How to use checksums |
f6409558a7432e7feac7028749515db24e79e3962a83785fddc494f46e7c7704
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.
Transparency log