# Portwarp API reference Canonical human documentation: https://portwarp.com/docs/api OpenAPI 3.1: https://portwarp.com/openapi/portwarp-v2.yaml Base URL: `https://api.portwarp.com/v2` API keys are created and revoked in the Portwarp dashboard. ## Authentication Send the API key in every request: ```http Authorization: Bearer pw_v2_REPLACE_ME ``` Keys have explicit scopes: | Scope | Permission | |---|---| | `account:read` | Read account, usage, and scope presets | | `tunnels:read` | Read tunnels, catalog, usage, and sessions | | `tunnels:write` | Create and configure tunnels | | `tunnels:control` | Start, stop, and restart tunnels | | `devices:read` | Read devices | | `devices:write` | Rename owned devices or disable remote control | | `devices:control` | Stop tunnels or restart an agent | | `operations:read` | Read asynchronous operation results | | `webhooks:read` | Read webhooks and delivery history | | `webhooks:write` | Create, update, test, and delete webhooks | ## Common request rules - Send JSON with `Content-Type: application/json`. - Timestamps are RFC 3339 UTC. - IDs are UUID strings. - Endpoints marked `idempotency: required` require an `Idempotency-Key` header of at most 128 characters. - Retry an identical request with the same idempotency key. Reusing the key with another request returns `409 idempotency_conflict`. - Paginated lists accept `limit` from 1 to 100 and an opaque `cursor`. Pass `meta.next_cursor` unchanged to the next request. - Rate limits are 120 requests per minute and 10,000 per day. Read `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `Retry-After`. Successful response: ```json { "data": {}, "meta": {"request_id": "...", "next_cursor": null} } ``` Error response (`application/problem+json`): ```json { "type": "https://portwarp.com/docs/api/errors#device_offline", "title": "Device Offline", "status": 409, "code": "device_offline", "detail": "The device is offline; no command was queued.", "request_id": "...", "errors": [{"field": "field_name", "message": "reason"}] } ``` Client logic should branch on `code`, not `title` or `detail`. Malformed JSON returns `400 invalid_json`. A valid JSON body whose root is not an object returns `400 invalid_json_object`. `403 feature_not_available` means the API or requested remote action is not currently available for the account. Do not rotate or delete the key. Existing keys remain stored and become usable again when access is available. ## Account and catalog endpoints ### `GET /` - Scope: `account:read` - Returns the API version, availability status, and canonical documentation URL. ### `GET /account` - Scope: `account:read` - Returns: account identity, personal or Team scope, plan name, tunnel limit, bandwidth limit, and creation time. ### `GET /account/usage` - Scope: `account:read` - Returns: active/total tunnel counts and bandwidth totals for today and the current month. ### `GET /nodes` - Scope: `tunnels:read` - Returns: nodes available for placement with `id`, `name`, `region`, `country`, `region_flag`, `status`, and `pro_only`. - Use `id` as `node_id` in `POST /tunnels/{tunnel_id}/move`. ### `GET /presets` - Scope: `tunnels:read` - Returns: application/game presets. - Use a returned `id` as `game_preset_id` when creating a tunnel. ### `GET /domains` - Scope: `tunnels:read` - Returns: platform domains available for generated tunnel hostnames. ### `GET /scope-presets` - Scope: `account:read` - Returns: dashboard permission presets and the scopes in each preset. ## Tunnel endpoints ### `GET /tunnels` - Scope: `tunnels:read` - Query: `limit` (1-100, default 25), `cursor` (opaque). - Returns: tunnels ordered newest first and `meta.next_cursor`. ### `POST /tunnels` - Scope: `tunnels:write` - Idempotency: required. - Status: `201`. - Required body field: `local_port` integer from 1 to 65535. - Optional body fields: - `name`: display name. - `protocol`: `tcp`, `udp`, `both`, or `http`; default `tcp`. - `local_host`: local target; default `127.0.0.1`. - `region`, `country`: placement preference. - `game_preset_id`: ID from `GET /presets`. - `subdomain`: requested platform subdomain. - `http_ssl_enabled`: boolean. - `http_basic_auth_enabled`: boolean. - `http_basic_auth_username`, `http_basic_auth_password`: credentials applied to HTTP tunnels. - `pro_network`: choose PRO Network when true; default true for eligible accounts. - `device_ids`: device allowlist; `[]` means any authorized device. - `extra_ports`: array of `{label, local_port, protocol}`. ```json { "name": "Minecraft", "protocol": "tcp", "local_port": 25565, "device_ids": ["DEVICE_ID"], "extra_ports": [{"label": "Query", "local_port": 25565, "protocol": "udp"}] } ``` ### `GET /tunnels/{tunnel_id}` - Scope: `tunnels:read`. - Returns: tunnel configuration, public connection address, public node metadata, current running device, authorized devices, and extra ports. ### `PATCH /tunnels/{tunnel_id}` - Scope: `tunnels:write`. - Supply at least one of: `name`, `local_host`, `local_port`, `http_ssl_enabled`, `http_basic_auth_enabled`, `http_basic_auth_username`, `http_basic_auth_password`, `idle_timeout`, `buffer_size`, `pro_network`. - Use the dedicated domain endpoints for custom domains. `PATCH /tunnels/{tunnel_id}` never bypasses domain verification. - Returns the updated tunnel. ### `DELETE /tunnels/{tunnel_id}` - Scope: `tunnels:write`. - Idempotency: required. - Deletes the tunnel and stops any active remote runtime. - Returns: `{"id":"TUNNEL_ID","deleted":true}`. ### `PUT /tunnels/{tunnel_id}/devices` - Scope: `tunnels:write`. - Idempotency: required. - Replaces the entire device allowlist. - Body: `{"device_ids":[]}` allows any authorized device. - One ID pins the tunnel to one device. Several IDs allow any listed device, but only one device can run the tunnel at a time. ### `POST /tunnels/{tunnel_id}/actions/start` - Scope: `tunnels:control`. - Idempotency: required. - Status: `202`. - Body: `device_id` is required when the tunnel is not running. `handoff` defaults to false. - Returns an operation. Follow it with `GET /operations/{operation_id}`. - If another device is running the tunnel, returns `409 tunnel_already_running`. Set `handoff: true` to move it explicitly. ```json {"device_id":"DEVICE_ID","handoff":false} ``` ### `POST /tunnels/{tunnel_id}/actions/stop` - Scope: `tunnels:control`. - Idempotency: required. - Status: `202`. - Body: no fields are required. Portwarp uses the current running device. - Returns an operation. ### `POST /tunnels/{tunnel_id}/actions/restart` - Scope: `tunnels:control`. - Idempotency: required. - Status: `202`. - Body: no fields are required. Portwarp uses the current running device. - Returns an operation. ### `GET /tunnels/{tunnel_id}/extra-ports` - Scope: `tunnels:read`. - Returns all extra port mappings. ### `POST /tunnels/{tunnel_id}/extra-ports` - Scope: `tunnels:write`. - Idempotency: required. - Status: `201`. - Body: `local_port` required; `protocol` is `tcp`, `udp`, `both`, or `http`; `label` optional. ```json {"label":"Query","local_port":25565,"protocol":"udp"} ``` ### `DELETE /tunnels/{tunnel_id}/extra-ports/{port_id}` - Scope: `tunnels:write`. - Idempotency: required. - Removes one extra port. ### `GET /tunnels/{tunnel_id}/domain` - Scope: `tunnels:read`. - Returns the custom domain, verification status, and required DNS target. ### `POST /tunnels/{tunnel_id}/domain/actions/verify` - Scope: `tunnels:write`. - Idempotency: required. - Body: `{"domain":"play.example.com"}`. - Checks whether the required CNAME is visible. ### `PUT /tunnels/{tunnel_id}/domain` - Scope: `tunnels:write`. - Idempotency: required. - Body: `{"domain":"play.example.com"}`. - Assigns a verified custom domain. ### `DELETE /tunnels/{tunnel_id}/domain` - Scope: `tunnels:write`. - Idempotency: required. - Removes the custom domain. ### `GET /tunnels/{tunnel_id}/firewall` - Scope: `tunnels:read`. - Returns enabled IP allow/block rules. ### `PUT /tunnels/{tunnel_id}/firewall` - Scope: `tunnels:write`. - Idempotency: required. - Replaces all firewall rules. - Each rule uses `rule_type` (`whitelist` or `blacklist`), one of `ip_address` or `ip_cidr`, and optional `description`. ```json {"rules":[{"rule_type":"whitelist","ip_cidr":"203.0.113.0/24","description":"Office"}]} ``` ### `GET /tunnels/{tunnel_id}/geoip` - Scope: `tunnels:read`. - Returns enabled country rules. ### `PUT /tunnels/{tunnel_id}/geoip` - Scope: `tunnels:write`. - Idempotency: required. - Replaces all country rules. - Each rule uses a two-letter ISO `country_code` and `action` (`allow` or `block`). ```json {"rules":[{"country_code":"BR","action":"allow"}]} ``` ### `POST /tunnels/{tunnel_id}/move` - Scope: `tunnels:write`. - Idempotency: required. - Status: `202`. - Body: `{"node_id":"NODE_ID"}` where the ID came from `GET /nodes`. - Returns: `{"tunnel_id":"...","node_id":"...","status":"moving"}`. ### `GET /tunnels/{tunnel_id}/usage` - Scope: `tunnels:read`. - Returns bandwidth totals for today and the current month. ### `GET /tunnels/{tunnel_id}/sessions` - Scope: `tunnels:read`. - Query: `limit` from 1 to 100 (default 25), `cursor` (opaque). - Returns recent sessions with device ID, start/end timestamps, duration, bytes in/out, and end reason. ## Device endpoints Remote control is disabled by default. It can only be enabled locally with `pwrp remote enable`. The API may query or disable consent, never enable it. ### `GET /devices` - Scope: `devices:read`. - Returns visible devices with `id`, `owner_id`, `name`, `platform`, `version`, `online`, `remote_control_enabled`, `capabilities`, and `last_seen_at`. ### `GET /devices/{device_id}` - Scope: `devices:read`. - Returns one device. ### `PATCH /devices/{device_id}` - Scope: `devices:write`. - Only the device owner may update it. - Body supports `name` and `remote_control_enabled`. The latter only accepts `false`. ```json {"name":"Office PC","remote_control_enabled":false} ``` ### `POST /devices/{device_id}/actions/stop-all` - Scope: `devices:control`. - Idempotency: required. - Status: `202`. - Stops every tunnel running on the device and returns an operation. ### `POST /devices/{device_id}/actions/restart-agent` - Scope: `devices:control`. - Idempotency: required. - Requires the target device to advertise `restart_agent_v1`. - Status: `202`. - Restarts the Portwarp agent and returns an operation. Device actions fail immediately if the device is offline, remote control is disabled, or the capability is unavailable. Team owners/admins may control active members' devices. Members can control only devices they own. ## Operation endpoints Terminal statuses are `succeeded`, `failed`, `expired`, and `canceled`. Non-terminal statuses are `queued`, `claimed`, and `running`. ### `GET /operations` - Scope: `operations:read`. - Query: `limit` (1-100, default 25), `cursor` (opaque). - Returns operations newest first. ### `GET /operations/{operation_id}` - Scope: `operations:read`. - Returns: `id`, `status`, `action`, `device_id`, optional `tunnel_id`, `result`, optional `error`, and timestamps. - Poll until status is terminal, or use `command.*` webhooks. ## Webhook endpoints Each personal or Team API scope may keep up to 20 webhook endpoints. Webhook URLs must use HTTPS and resolve to a public address. ### `GET /webhooks` - Scope: `webhooks:read`. - Returns webhook endpoints without signing secrets. ### `POST /webhooks` - Scope: `webhooks:write`. - Idempotency: required. - Status: `201`. - Body requires `url` and a non-empty `events` array; `description` is optional. - Returns the signing `secret` once. ```json { "url":"https://example.com/webhooks/portwarp", "events":["command.succeeded","command.failed","device.offline"], "description":"Production automation" } ``` ### `GET /webhooks/{webhook_id}` - Scope: `webhooks:read`. - Returns one webhook endpoint without its secret. ### `PATCH /webhooks/{webhook_id}` - Scope: `webhooks:write`. - Supply one or more of `url`, `events`, `description`, or `status` (`enabled` or `disabled`). ### `DELETE /webhooks/{webhook_id}` - Scope: `webhooks:write`. - Idempotency: required. - Removes the endpoint. ### `POST /webhooks/{webhook_id}/test` - Scope: `webhooks:write`. - Idempotency: required. - Status: `202`. - Schedules a test event and returns its event ID. ### `GET /webhooks/{webhook_id}/deliveries` - Scope: `webhooks:read`. - Returns up to 100 recent delivery attempts without event payloads. Supported events: - `command.queued` - `command.claimed` - `command.running` - `command.succeeded` - `command.failed` - `command.expired` - `command.canceled` - `tunnel.started` - `tunnel.stopped` - `device.online` - `device.offline` Webhook request headers: - `Portwarp-Webhook-Id` - `Portwarp-Webhook-Timestamp` - `Portwarp-Webhook-Signature` Signature input is `id.timestamp.raw_body`. Compute HMAC-SHA256 with the endpoint secret, hex-encode it, prefix it with `v1=`, and compare in constant time. Reject timestamps older than five minutes and deduplicate by event ID. Retries keep the same event ID. Failed deliveries are retried after approximately 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. ## Common conflict codes | Code | Meaning | |---|---| | `device_offline` | Target device is offline; no command was queued | | `remote_control_disabled` | Local consent is disabled | | `upgrade_required` | Client must be updated before it can perform the action | | `device_not_allowed` | Device is not allowed for this tunnel | | `tunnel_already_running` | Another device is running the tunnel; use explicit handoff if desired | | `operation_in_progress` | A conflicting operation is already active | | `idempotency_conflict` | Idempotency key was reused with a different request | | `feature_not_available` | The API or requested remote action is not currently available for the account | ## API v1 migration All `/v1/*` routes return `410 Gone`. Legacy keys cannot authenticate to `/v2`. Create a new key, change the base path to `/v2`, read successful data from `data`, use cursor pagination, and add `Idempotency-Key` to creations and actions.