API

API reference

Overview

Base URL, authentication and conventions

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

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:

bash
curl -s http://localhost:3000/v1/auth \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"email":"[email protected]","password":"secret123"}'
json
{ "access_token": "eyJhbGciOi...", "refresh_token": "eyJhbGciOi..." }

2. Call an authenticated endpoint with Authorization: Bearer <access_token>:

bash
curl -s http://localhost:3000/v1/users/me \
  -H 'Accept: application/json' \
  -H 'Authorization: Bearer eyJhbGciOi...'
json
{ "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:

bash
curl -s http://localhost:3000/v1/tokens/refresh \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"token":"eyJhbGciOi..."}'
json
{ "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:

text
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:

json
{ "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:

json
{
  "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)