Ghostunnel API
This reference is served as a browsable page by the running app at
/docs(e.g.http://localhost:3000/docs). It renders these Markdown files — edit them and the page updates.
The JSON API is what the native Ghostunnel clients (iOS, Android, desktop) talk to. The web app at / uses
the same models and services over a session instead of tokens.
There is no /api path prefix — scope module: :api only adds a Ruby module namespace, not a URL
segment, so the auth endpoint is POST/v1/auth, not /api/v1/auth. (In production the API is served on
the api. subdomain, e.g. https://api.ghostunnel.com/v1/....)
| Area | Namespace | Doc |
|---|---|---|
| Accounts (auth, tokens, users, passwords) | /v1/* |
general.md |
| VPN (locations, servers, devices, tunnels, plan) | /v1/* |
vpn.md |
(The /console/* UI is a server-rendered admin panel — Devise-authenticated, not part of these APIs.)
Machine-readable contract: openapi.yaml (OpenAPI 3.1, served at GET/docs/openapi.yaml). Generate
clients from it; CI checks every /v1 response against it (spec/requests/api/v1/openapi_contract_spec.rb).
Making requests
Every endpoint below shows an example Request (curl) and the Response body. The base URL is your
host — http://localhost:3000 in development, https://api.ghostunnel.com in production.
1. Get a token pair from POST/v1/auth:
curl -s http://localhost:3000/v1/auth \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"email":"[email protected]","password":"secret123"}'
{ "access_token": "eyJhbGciOi...", "refresh_token": "eyJhbGciOi..." }
2. Call an authenticated endpoint with Authorization: Bearer <access_token>:
curl -s http://localhost:3000/v1/users/me \
-H 'Accept: application/json' \
-H 'Authorization: Bearer eyJhbGciOi...'
{ "id": 1, "name": "Ada", "email": "[email protected]", "username": "ada", "confirmed": false }
3. When the access token expires (~1h, error code token_expired), exchange the refresh token for a fresh pair via
POST/v1/tokens/refresh — an access token is refused there:
curl -s http://localhost:3000/v1/tokens/refresh \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"token":"eyJhbGciOi..."}'
{ "access_token": "eyJhbGciOi...", "refresh_token": "eyJhbGciOi..." }
Request bodies may be JSON (Content-Type: application/json) or form-encoded (-d email=… -d password=…) —
Rails parses both. The examples in these docs use JSON.
Conventions
Format. All responses are JSON. Send Accept: application/json. The API also defaults the format to JSON.
Authentication. Most endpoints require a JWT in the Authorization header:
Authorization: Bearer <access_token>
You obtain a token pair (access_token, refresh_token) from POST/v1/auth (email/password) or
POST/v1/registrations. Access tokens are short-lived (~1h); refresh with POST/v1/tokens/refresh. Refresh tokens bound to an app
install (POST/v1/devices) last 90 days and work once: every refresh returns a new pair — store the new
refresh token. Presenting one that was already exchanged signs the device out (refresh_token_reused); a retry
within a minute is tolerated. Refresh tokens without a device last ~3h
(send the refresh token as token). The two aren't interchangeable: a refresh token is refused as a Bearer
token and an access token is refused by /refresh. Changing the password (any way — API, web, reset link) or
POST/v1/tokens/revoke invalidates every token issued to the user. Some endpoints use optional auth (authorize_token vs.
authorize_token!) — they work without a token but behave differently when one is present (noted per-endpoint).
Errors. Every error has the same shape, with a stable machine-readable code — branch on it, not on the
English message:
{ "code": "device_limit", "message": "You already have a connected tunnel…", "errors": ["…"], "invalid": {} }
invalid is filled for validation errors ({ "email": ["has been used"] }). Codes:
| Code | Status | Meaning / what the client should do |
|---|---|---|
token_expired |
401 | Access token expired — refresh it (POST/v1/tokens/refresh) and retry |
invalid_token |
401 | Bad, revoked or wrong-type token, or a signed-out device — sign in again |
refresh_token_reused |
401 | A refresh token was used twice: the device has been signed out (possible theft) — sign in again |
invalid_credentials |
401 | Wrong email/password, or a provider token we couldn't verify |
two_factor_required / two_factor_invalid |
401 | Ask for the authenticator (or backup) code and retry POST/v1/auth with otp_code; the body also has "two_factor_required": true |
device_required |
403 | Register this install first (POST/v1/devices) |
device_limit |
422 | All of the plan's connections are in use |
data_limit |
422 | This month's data is used up (Free) |
premium_location |
422 | The location needs Premium |
no_server_available |
422 | Nothing can take a tunnel there right now — try another location |
public_key_required, invalid_public_key, unsupported_transport, location_required, name_too_long |
422 | Fix the request |
connect_failed |
422 | The node didn't take the tunnel — retry, or another location |
email_taken, email_invalid, email_unchanged, confirmation_mismatch |
422 | Account changes |
validation_failed |
422 | Other validation errors (see invalid) |
bad_request · not_found · rate_limited |
400 · 404 · 429 | |
payment_provider_unavailable |
502 | Stripe couldn't be reached; nothing was changed |
Codes are only ever added; an existing code keeps its meaning within /v1 (see Stability).
Pagination. List endpoints accept page and per. Paginated responses wrap the items in data plus
pagination metadata:
{
"data": [ ... ],
"current_page": 1,
"previous_page": null,
"next_page": 2,
"limit": 25,
"total_pages": 4,
"total_count": 87
}
Some list endpoints only return { "data": [ ... ] } with no metadata — noted per-endpoint.
Rate limiting (Rack::Attack, production only). POSTs to the auth/tokens/passwords endpoints are throttled
to 20/min per IP and 6 per 5 min per email; signup/verification/confirmation to 10/hour per IP. Over the limit
returns 429 with a Retry-After header and { "error": { "message": "Throttle limit reached. ..." } }.
Localhost is exempt. Separately, each endpoint group has an app-level quota of 100 requests an hour per user (or IP
when unauthenticated): every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset,
and going over returns 429 with the standard error shape (rate_limited).
CORS. Browser clients are restricted to an allowlist of Ghostunnel domains (config/initializers/cors.rb),
with credentials enabled. Local/preview origins are only allowed outside production.
Status legend used in these docs: ✅ works · ⚠️ partially implemented / has a known bug (see
docs/NOTES.md) · 🔒 requires a Bearer token · 🔓 optional token · 🌐 public. On /docs the
endpoint headings show these as pills — Token, Optional token, Public, Partial (works is the default).
Stability (v1)
- Additive only within
/v1: new endpoints, new optional request parameters, new response fields, new error codes and new enum values (transports, network profiles) can appear at any time. Clients must ignore response fields they don't know, and handle an unknown errorcodeby its HTTP status. - Breaking changes need
/v2: removing or renaming an endpoint, parameter or field, changing a field's type or meaning, or making an optional parameter required. - Deprecations are announced at least 90 days ahead in these docs (and marked
deprecated: truein the OpenAPI file) before anything is removed in a new version. - openapi.yaml is the source of truth for code generation. If it disagrees with these Markdown pages, the OpenAPI file (which CI holds the app to) wins.