API

API reference

VPN

Locations, servers, devices, tunnels, plan

Base URL https://api.ghostunnel.com Auth Bearer token

VPN API β€” /v1/*

For the native apps (iOS, Android, desktop): locations, servers, devices, tunnels and the user's plan. Accounts and tokens are in general.md; conventions in README.md. All endpoints require a Bearer token πŸ”’. $TOKEN below is an access token.

How it works:

More may appear later; apps should ignore protocols they don't support.

App flow: sign in β†’ register device (store its tokens) β†’ GET …/servers β†’ generate a fresh keypair β†’ POST …/tunnels β†’ bring the tunnel up. To switch servers, generate another keypair and POST …/tunnels again β€” the new tunnel replaces the device's old one (released after the new one is up), so it doesn't use another slot. Use a new keypair for every connect: a key that's still live on a server can't be reused there.

Sessions are short-lived on purpose. A device tunnel with no WireGuard handshake for 30 minutes (the app was closed, the phone offline) is released: the node forgets the device's address, and the tunnel's IP and keys are erased from the database. A connected client handshakes every ~2 minutes, so this only hits tunnels nobody is using. When GET …/tunnels/:uuid says offline (or a handshake stops working), connect again with a new keypair. Tunnel addresses are assigned at random, so each session usually gets a different one.


GET/v1/locations β€” available countries Token

List the countries you can place a tunnel in. premium_only = every server there is Premium-only; allowed = the user can connect there on their current plan (available, and not Premium-only for a Free user).

Param In Notes
name query optional partial-match filter
id query optional β€” filter to one country
page, per query pagination

Request

bash
curl -s http://localhost:3000/v1/locations \
  -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"

200

json
{
  "data": [ { "id": 1, "name": "Indonesia", "code": "ID", "available": true, "premium_only": false, "allowed": true } ],
  "current_page": 1, "previous_page": null, "next_page": null,
  "limit": 25, "total_pages": 1, "total_count": 1
}

401 β€” no/invalid token.


POST/v1/devices β€” register this install Token

Param In Notes
name body required β€” shown to the user, e.g. "Ada's iPhone" (max 60)
platform body required β€” ios, android, macos, windows or linux
app_version body optional

Request (with the token from POST/v1/auth)

bash
curl -s http://localhost:3000/v1/devices \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" \
  -d '{"name":"Ada'"'"'s iPhone","platform":"ios","app_version":"1.0.0"}'

201

json
{
  "device": {
    "uuid": "0b6f…", "name": "Ada's iPhone", "platform": "ios", "app_version": "1.0.0",
    "current": true, "connected": false, "last_seen_at": "2026-10-22T10:00:00.000Z", "created_at": "2026-10-22T10:00:00.000Z"
  },
  "access_token": "eyJhbGciOi...", "refresh_token": "eyJhbGciOi..."
}

Replace the app's stored tokens with these. Refreshing (POST/v1/tokens/refresh) keeps them bound to the device. 422 β€” missing name or unknown platform. 401 β€” no token.

GET/v1/devices β€” your installs Token

200 β†’ { "data": [ <device> ] } β€” signed-in installs, most recently seen first; current marks the caller.

DELETE/v1/devices/:uuid β€” sign an install out Token

The app's own "log out", or removing a lost device from another one. Disconnects its tunnel; its tokens get 401 from then on. 204. 404 β€” unknown, or another user's. (Also available on the website: Settings β†’ Devices.)


GET/v1/servers β€” servers you can connect to Token

Param In Notes
country_id query optional β€” only servers in that country
transport query optional β€” only servers offering that protocol (e.g. wireguard)
country query optional β€” ISO code of where the user is connecting from (SIM / locale / the app's own lookup). Falls back to the CF-IPCountry header when the API is behind Cloudflare. Drives recommended_transports.

Request

bash
curl -s http://localhost:3000/v1/servers \
  -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"

200

json
{
  "network_profile": "default",
  "recommended_transports": ["wireguard", "amneziawg", "hysteria2"],
  "data": [
    {
      "id": 3, "name": "Singapore 1",
      "country": { "id": 7, "name": "Singapore", "code": "SG" }, "city": "Singapore",
      "premium": false, "allowed": true, "available": true, "load": 25,
      "transports": [
        { "protocol": "wireguard", "host": "203.0.113.5", "port": 51820, "public_key": "x4P0…=" }
      ]
    }
  ]
}

401 β€” no token.


POST/v1/tunnels β€” connect Token

Param In Notes
public_key body required for wireguard/amneziawg β€” base64 WireGuard public key of a keypair generated on the device (ignored for hysteria2 and reality)
server_id body the server the app chose…
country_id body …or let the balancer pick a server in that country
transport body optional β€” wireguard (default), amneziawg, hysteria2 or reality; the server must offer it
name body optional β€” the user's label for it ("Phone"), up to 40 characters; display_name falls back on the location

Needs a device-bound token (POST …/devices). The user's plan applies β€” devices connected at once, monthly data, Premium-only servers (see GET/v1/subscription) β€” except that the calling device's own tunnel doesn't count: it's replaced.

Request

bash
curl -s http://localhost:3000/v1/tunnels \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" \
  -d '{"server_id":3,"public_key":"hW3i…=","transport":"wireguard"}'

201

json
{
  "uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Phone", "display_name": "Phone", "status": "online", "transport": "wireguard",
  "device_uuid": "0b6f…",
  "server": { "id": 3, "name": "Singapore 1", "location": "Singapore - Singapore" },
  "connected_at": "2026-10-22T10:00:00.000Z", "disconnected_at": null, "expires_at": null,
  "usage": { "bytes_downloaded": 0, "bytes_uploaded": 0 },
  "config": {
    "interface": { "address": "10.200.0.2/32", "dns": ["10.200.0.1"] },
    "peer": {
      "public_key": "x4P0…=", "endpoint": "203.0.113.5:51820",
      "allowed_ips": ["0.0.0.0/0", "::/0"], "persistent_keepalive": 21
    }
  }
}

Build the WireGuard interface from config plus the device's private key. dns points at a resolver on the node, inside the tunnel. expires_at is always null (tunnels don't expire; Free web tunnels unused for 30 days are released β€” see limits.inactive_release_days); it's kept for compatibility.

Hysteria2 β€” config is a Hysteria client config:

json
"config": {
  "server": "203.0.113.5:443",
  "auth": "550e8400-e29b-41d4-a716-446655440000:Zk3…",
  "tls": { "sni": "www.wikipedia.org", "insecure": true, "pinSHA256": "AB:CD:…" },
  "obfs": { "type": "salamander", "salamander": { "password": "…" } }
}

auth is this tunnel's credential and is returned only here β€” store it on the device (show / list responses leave it out; the server keeps only a digest). The node's certificate is self-signed: insecure: true skips chain validation and pinSHA256 pins the exact certificate instead. obfs appears only on nodes that use Salamander. Releasing the tunnel (or idling out) ends the session and the credential stops working.

VLESS+REALITY β€” config is an Xray outbound, plus the same thing as a vless:// link for clients that import links:

json
"config": {
  "protocol": "vless", "address": "203.0.113.5", "port": 443,
  "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "flow": "xtls-rprx-vision", "encryption": "none",
  "network": "tcp", "security": "reality",
  "reality": { "serverName": "www.microsoft.com", "publicKey": "Hy3…", "shortId": "a1b2c3d4e5f60718", "fingerprint": "chrome" },
  "uri": "vless://6ba7b810-…@203.0.113.5:443?encryption=none&flow=xtls-rprx-vision&security=reality&…"
}

id (and uri) are this tunnel's credential and are returned only here β€” store them on the device.

403 device_required β€” the token isn't bound to a device. 422 (standard error shape) with code public_key_required, invalid_public_key, unsupported_transport, name_too_long, device_limit, data_limit, premium_location, no_server_available or connect_failed. 401 β€” no token.


GET/v1/tunnels β€” your tunnels Token

Param In Notes
status query active β€” connected only
page, per query pagination

200 β†’ { "data": [ <tunnel without config> ], "current_page": 1, "next_page": null, "total_count": 1 }

GET/v1/tunnels/:uuid β€” one tunnel Token

200 β€” as from create (config only while connected). 404 β€” unknown, or another user's.

PATCH/v1/tunnels/:uuid β€” rename Token

Param In Notes
name body the new label, up to 40 characters; blank clears it

200 β€” the tunnel, as from GET. 422 β€” too long. 404 β€” unknown, or another user's.

DELETE/v1/tunnels/:uuid β€” disconnect Token

Removes the peer from the node and frees the device slot. 200 β€” the tunnel, "status": "offline", no config. 404 β€” unknown, or another user's.


GET/v1/subscription β€” your plan Token

The user's plan, its limits and this month's usage. Plans are bought on the web (Stripe Checkout) β€” send the user to upgrade_url. null limits mean unlimited.

Request

bash
curl -s http://localhost:3000/v1/subscription \
  -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"

200

json
{
  "plan": "free", "plan_name": "Free",
  "status": null, "current_period_end": null, "cancel_at_period_end": false,
  "payment_failed": false, "grace_ends_at": null,
  "limits": { "max_devices": 1, "inactive_release_days": 30, "monthly_data_bytes": 10737418240, "premium_locations": false },
  "usage": { "active_tunnels": 1, "data_used_bytes": 52428800, "data_period_start": "2026-10-01" },
  "upgrade_url": "https://ghostunnel.com/settings/billing"
}

On Premium: "plan": "premium", status is Stripe's (active, trialing, past_due, …), and current_period_end is when it renews (or ends, if cancel_at_period_end). payment_failed: true means a renewal failed and Premium continues until grace_ends_at (3 days) β€” prompt the user to update their card. 401 β€” no token.