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:
- The device generates the WireGuard keypair and sends only its public key. The server never sees or stores a
private key, and there's no
.conftext or QR code β the app builds the tunnel from a structuredconfig. - The app picks the server from
GET β¦/servers(location, load, health, plan, transports) and can fail over by itself. - Transports: each server lists the protocols it offers, each with its own
availableflag:wireguardβ plain WireGuard.amneziawgβ WireGuard with obfuscation (junk packets, padded handshakes, changed headers) so DPI can't fingerprint it. Same keys and flow as WireGuard; the tunnel config addsinterface.obfuscation(jc,jmin,jmax,s1,s2,h1βh4β set them on the AmneziaWG interface, e.g. via amneziawg-go). Use it when plain WireGuard doesn't connect.hysteria2β a QUIC-based proxy on UDP/443 (Hysteria 2), for networks that block or throttle WireGuard entirely. Not WireGuard: no keypair β the app runs a Hysteria client core with theconfigit gets (Hysteria's own client-config keys), typically in TUN mode.realityβ VLESS+REALITY (Xray-core) on TCP/443. To a censor it looks like an HTTPS visit to a real website (reality.serverName); the strongest option where only TLS gets through. Not WireGuard: the app runs an Xray core (or any VLESS+REALITY client) with theconfigit gets.
More may appear later; apps should ignore protocols they don't support.
- Tunnels are addressed by
uuid. - Each app install is a device. After signing in (
POST/v1/auth), the app registers itself once (POST β¦/devices) and from then on uses the device-bound tokens that returns β tunnels require them. A device holds one tunnel; signing a device out (from the app, another device or the website) disconnects it and invalidates its tokens.
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
curl -s http://localhost:3000/v1/locations \
-H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"
200
{
"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)
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
{
"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
curl -s http://localhost:3000/v1/servers \
-H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"
200
{
"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β¦=" }
]
}
]
}
recommended_transportsβ the order to try protocols in, for the user's network:default(WireGuard first),restricted(WireGuard is DPI-blocked: AmneziaWG, then Hysteria2) orcensored(unknown UDP blocked: Hysteria2 first, no plain WireGuard). Each server'stransportsare listed in that order too. Tunable server-side inconfig/transports.yml; fall back to the next transport when one doesn't connect.allowedβ the user's plan may use this server (Free users can't usepremiumones).availableβ taking new tunnels now: the location is open, the node is healthy, and it has capacity.loadβ percent of capacity in use; with latency, the basis for "fastest server".hostis a hostname, IPv4 or IPv6 address (no brackets); ping it to measure latency.
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
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
{
"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:
"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:
"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
curl -s http://localhost:3000/v1/subscription \
-H 'Accept: application/json' -H "Authorization: Bearer $TOKEN"
200
{
"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.