openapi: 3.1.0
info:
  title: Ghostunnel API
  version: "1"
  summary: Accounts, devices and VPN tunnels for the Ghostunnel apps.
  description: |
    The JSON API the native apps (and the shared Go client core) talk to. Human-readable reference: docs/api/*.md,
    also served at /docs. This file is the contract — client code is generated from it and CI checks the app's
    responses against it (spec/requests/api/v1/openapi_contract_spec.rb).

    **Stability (v1).** Within /v1 changes are additive only: new endpoints, new optional request fields, new
    response fields and new error codes. Removing or renaming a field, or changing what one means, needs /v2.
    Deprecations are announced in the docs at least 90 days ahead. Clients must ignore response fields they don't
    know.

    **Errors.** Every error has the `Error` shape with a stable machine-readable `code` (see the schema) — branch on
    `code`, not on the English `message`.

    **Rate limits.** In production each endpoint group allows 100 requests an hour per user (or IP when
    unauthenticated); responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and a
    429 (`rate_limited`) when it's used up.
servers:
  - url: https://api.ghostunnel.com
    description: Production
  - url: http://localhost:3000
    description: Local development
security:
  - bearerAuth: []
tags:
  - name: Auth
  - name: Users
  - name: Passwords
  - name: VPN
  - name: Billing

paths:
  /v1/auth:
    post:
      tags: [Auth]
      operationId: signIn
      summary: Sign in (email/password, Apple or Google) and get a token pair
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  type: string
                  description: "`apple`, `google`, or omitted/anything else for email + password."
                email: { type: string }
                password: { type: string }
                apple_identity_token: { type: string, description: "Required when provider is apple." }
                name: { type: string, description: "Optional, with apple: the name Apple shared on the first sign-in, for a new account." }
                google_access_token: { type: string, description: "Required when provider is google." }
                otp_code:
                  type: string
                  description: Required when the account has two-factor authentication on — a 6-digit authenticator code or a backup code.
      responses:
        "200":
          description: Signed in. These tokens aren't bound to a device — register one (POST /v1/devices) next.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "401":
          description: "`invalid_credentials`, or `two_factor_required` / `two_factor_invalid` (the body also has `two_factor_required: true`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/registrations:
    post:
      tags: [Auth]
      operationId: signUp
      summary: Create an account and get a token pair
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                name: { type: string, description: Defaults to the email's local part. }
                password: { type: string, description: A random one is generated when blank. }
                referral_code: { type: string, description: "Optional: the code from a friend's invite link (/r/<code>). Unknown codes are ignored." }
      responses:
        "200":
          description: Account created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tokens/validate:
    get:
      tags: [Auth]
      operationId: validateToken
      summary: Check an access token
      responses:
        "200":
          description: The token is valid.
          content:
            application/json:
              schema:
                type: object
                required: [valid]
                properties:
                  valid: { type: boolean, const: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tokens/refresh:
    post:
      tags: [Auth]
      operationId: refreshTokens
      summary: Exchange a refresh token for a new pair
      description: |
        For an app install (device-bound tokens) the refresh token rotates: it works once, the response carries the
        next one. Presenting an already-exchanged refresh token signs the device out (`refresh_token_reused`); a
        retry within a minute is tolerated.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, description: The refresh token (an access token is refused). }
      responses:
        "200":
          description: A new pair — store both.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPair" }
        "401":
          description: "`token_expired`, `invalid_token` or `refresh_token_reused`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tokens/revoke:
    post:
      tags: [Auth]
      operationId: revokeTokens
      summary: Sign out everywhere — every token issued so far stops working
      responses:
        "200":
          description: Revoked (including the token used for this request).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Success" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/users/{id}:
    parameters:
      - $ref: "#/components/parameters/UserId"
    get:
      tags: [Users]
      operationId: getCurrentUser
      summary: The signed-in user
      responses:
        "200":
          description: The user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Users]
      operationId: updateCurrentUser
      summary: Update the signed-in user
      description: A new `email` isn't applied directly — a confirmation link goes to it and it shows as `unconfirmed_email` until opened.
      requestBody: { $ref: "#/components/requestBodies/UserUpdate" }
      responses:
        "200": { $ref: "#/components/responses/UserUpdated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Users]
      operationId: patchCurrentUser
      summary: Update the signed-in user (same as PUT)
      requestBody: { $ref: "#/components/requestBodies/UserUpdate" }
      responses:
        "200": { $ref: "#/components/responses/UserUpdated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Users]
      operationId: deleteCurrentUser
      summary: Delete the account for good
      description: Cancels a Stripe subscription immediately, releases the user's tunnels and signs out every device. Irreversible.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmation]
              properties:
                confirmation: { type: string, description: "The account's email address (case-insensitive)." }
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`confirmation_mismatch`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: "`payment_provider_unavailable` — nothing was deleted."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/users/{id}/export:
    parameters:
      - $ref: "#/components/parameters/UserId"
    get:
      tags: [Users]
      operationId: exportCurrentUser
      summary: Everything we hold about the user (no secrets)
      responses:
        "200":
          description: The export.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountExport" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/passwords:
    post:
      tags: [Passwords]
      operationId: requestPasswordReset
      summary: Email password-reset instructions
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string }
      responses:
        "200":
          description: Accepted. The same answer whether or not the address has an account (instructions are sent only if it does).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Success" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/passwords/reset:
    post:
      tags: [Passwords]
      operationId: requestPasswordResetAlias
      summary: Deprecated alias of POST /v1/passwords
      deprecated: true
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string }
      responses:
        "200":
          description: Same as POST /v1/passwords.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Success" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/passwords/change:
    post:
      tags: [Passwords]
      operationId: changePassword
      summary: Set a new password — signed in (with current_password) or via a reset token/code
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, password_confirmation]
              properties:
                password: { type: string }
                password_confirmation: { type: string }
                current_password: { type: string, description: Required when signed in. }
                reset_password_token: { type: string, description: "When not signed in: this or reset_password_code." }
                reset_password_code: { type: string }
      responses:
        "200":
          description: Changed. A signed-in change also returns new tokens — the old ones (including this request's) are revoked.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Success"
                  - type: object
                    properties:
                      access_token: { type: string }
                      refresh_token: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/emails/{id}:
    get:
      tags: [Auth]
      operationId: checkEmail
      summary: Is this email address registered?
      description: The address is the path segment, URL-encoded (`/v1/emails/ada%40example.com`). The older `?email=` query parameter still works and wins when given.
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: The email address.
          schema: { type: string }
        - name: email
          in: query
          required: false
          deprecated: true
          schema: { type: string }
      responses:
        "200":
          description: Availability.
          content:
            application/json:
              schema:
                type: object
                required: [available, registered]
                properties:
                  messages: { type: array, items: { type: string } }
                  available: { type: boolean }
                  registered: { type: boolean }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locations:
    get:
      tags: [VPN]
      operationId: listLocations
      summary: Countries, with whether the user's plan may use them
      parameters:
        - { name: name, in: query, schema: { type: string }, description: Partial-match filter. }
        - { name: id, in: query, schema: { type: integer } }
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Per"
      responses:
        "200":
          description: A page of locations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pagination"
                  - type: object
                    required: [data]
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Location" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/servers:
    get:
      tags: [VPN]
      operationId: listServers
      summary: Servers the apps can connect to, with transports in the recommended order
      parameters:
        - { name: country_id, in: query, schema: { type: integer }, description: Only servers in that country. }
        - name: transport
          in: query
          schema: { $ref: "#/components/schemas/TransportProtocol" }
          description: Only servers offering that protocol.
        - name: country
          in: query
          schema: { type: string }
          description: ISO code of where the user connects from; drives `recommended_transports` (falls back to Cloudflare's CF-IPCountry).
      responses:
        "200":
          description: The server list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServerList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/devices:
    get:
      tags: [VPN]
      operationId: listDevices
      summary: The user's signed-in app installs
      responses:
        "200":
          description: The devices.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Device" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [VPN]
      operationId: registerDevice
      summary: Register this app install and get device-bound tokens
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, platform]
              properties:
                name: { type: string, maxLength: 60 }
                platform: { $ref: "#/components/schemas/Platform" }
                app_version: { type: string, maxLength: 30 }
      responses:
        "201":
          description: Registered. Replace the stored tokens with these — tunnels need a device-bound token.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/TokenPair"
                  - type: object
                    required: [device]
                    properties:
                      device: { $ref: "#/components/schemas/Device" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/devices/{uuid}:
    delete:
      tags: [VPN]
      operationId: signOutDevice
      summary: Sign an install out (its tokens stop working, its tunnel is released)
      parameters:
        - $ref: "#/components/parameters/Uuid"
      responses:
        "204": { description: Signed out. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tunnels:
    get:
      tags: [VPN]
      operationId: listTunnels
      summary: The user's tunnels (without config)
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [active] }
          description: "`active` — connected only."
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Per"
      responses:
        "200":
          description: A page of tunnels.
          content:
            application/json:
              schema:
                type: object
                required: [data, current_page, total_count]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Tunnel" } }
                  current_page: { type: integer }
                  next_page: { type: [integer, "null"] }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [VPN]
      operationId: connectTunnel
      summary: Connect this device — returns the tunnel with its config
      description: |
        Needs a device-bound token. The plan applies (connections at once, monthly data, Premium-only servers); the
        calling device's own previous tunnel doesn't count — it's replaced once the new one is up.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                server_id: { type: integer, description: The server the app chose… }
                country_id: { type: integer, description: …or let the balancer pick a server in that country. }
                public_key:
                  type: string
                  description: Base64 WireGuard public key of a keypair generated on the device. Required for wireguard/amneziawg.
                transport:
                  allOf: [{ $ref: "#/components/schemas/TransportProtocol" }]
                  default: wireguard
                name: { type: string, maxLength: 40 }
      responses:
        "201":
          description: Connected. Hysteria2's `auth` and REALITY's `id`/`uri` are returned only here — store them on the device.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelWithConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`device_required` — register the device first."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: "`public_key_required`, `invalid_public_key`, `unsupported_transport`, `location_required`, `name_too_long`, `device_limit`, `data_limit`, `premium_location`, `no_server_available` or `connect_failed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tunnels/{uuid}:
    parameters:
      - $ref: "#/components/parameters/Uuid"
    get:
      tags: [VPN]
      operationId: getTunnel
      summary: One tunnel (config only while connected)
      responses:
        "200":
          description: The tunnel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TunnelWithConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [VPN]
      operationId: renameTunnel
      summary: Rename a tunnel
      requestBody: { $ref: "#/components/requestBodies/TunnelRename" }
      responses:
        "200": { $ref: "#/components/responses/TunnelResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [VPN]
      operationId: patchTunnel
      summary: Rename a tunnel (same as PUT)
      requestBody: { $ref: "#/components/requestBodies/TunnelRename" }
      responses:
        "200": { $ref: "#/components/responses/TunnelResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [VPN]
      operationId: disconnectTunnel
      summary: Disconnect (release) a tunnel
      description: The released tunnel is deleted on our side; the response is its last state.
      responses:
        "200": { $ref: "#/components/responses/TunnelResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/subscription:
    get:
      tags: [Billing]
      operationId: getSubscription
      summary: The user's plan, its limits and this month's usage
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: An access token from POST /v1/auth, /v1/registrations, /v1/devices or /v1/tokens/refresh.

  parameters:
    UserId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Ignored — always the token's user. Use `me`.
    Uuid:
      name: uuid
      in: path
      required: true
      schema: { type: string }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1 }
    Per:
      name: per
      in: query
      schema: { type: integer, minimum: 1 }

  requestBodies:
    UserUpdate:
      content:
        application/json:
          schema:
            type: object
            properties:
              name: { type: string }
              username: { type: string }
              email: { type: string, description: Starts an email change (confirmation link). }
              password: { type: string }
              password_confirmation: { type: string }
              usage_emails: { type: boolean }
    TunnelRename:
      content:
        application/json:
          schema:
            type: object
            properties:
              name: { type: [string, "null"], maxLength: 40, description: Blank clears it. }

  responses:
    BadRequest:
      description: "`bad_request` — a required parameter is missing."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: "`token_expired` (refresh and retry) or `invalid_token` (sign in again)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: "`not_found`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationFailed:
      description: "`validation_failed` (see `invalid`), or a specific code such as `email_taken`."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        Production only. The app's per-endpoint quota answers with `Error` (`rate_limited`, plus the X-RateLimit-*
        headers); the edge throttle on sign-in/sign-up/password endpoints answers with `Throttled` and `Retry-After`.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait (edge throttle). }
        X-RateLimit-Reset: { schema: { type: integer }, description: Unix time the quota resets (app quota). }
      content:
        application/json:
          schema:
            anyOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/Throttled"
    UserUpdated:
      description: The updated user; after a password change also new tokens (the old ones are revoked).
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/User"
              - type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
    TunnelResponse:
      description: The tunnel.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TunnelWithConfig" }

  schemas:
    Error:
      type: object
      required: [code, message, errors, invalid]
      properties:
        code:
          type: string
          description: |
            Stable, machine-readable. Known codes: token_expired, invalid_token, refresh_token_reused,
            invalid_credentials, two_factor_required, two_factor_invalid, device_required, device_limit, data_limit,
            premium_location, no_server_available, public_key_required, invalid_public_key, unsupported_transport,
            location_required, name_too_long, connect_failed, email_taken, email_invalid, email_unchanged,
            link_invalid, confirmation_mismatch, validation_failed, bad_request, unauthorized, forbidden, not_found,
            conflict, rate_limited, payment_provider_unavailable, upstream_unavailable, unavailable, server_error,
            error. New codes may be added — treat unknown ones by their HTTP status.
        message: { type: string, description: For people; don't parse it. }
        errors: { type: array, items: { type: string } }
        invalid:
          type: object
          additionalProperties: { type: array, items: { type: string } }
          description: Field => messages, for validation errors; empty otherwise.
        two_factor_required: { type: boolean, description: Present on the two_factor_* errors. }

    Throttled:
      type: object
      description: The edge throttle's (Rack::Attack) 429 body — not the `Error` shape.
      required: [error]
      properties:
        error:
          type: object
          required: [message]
          properties:
            message: { type: string }

    Success:
      type: object
      required: [success]
      properties:
        success: { type: boolean, const: true }

    TokenPair:
      type: object
      required: [access_token, refresh_token]
      properties:
        access_token: { type: string, description: JWT, ~1 hour. }
        refresh_token: { type: string, description: JWT — 90 days and single-use when device-bound, ~3 hours otherwise. }

    User:
      type: object
      required: [id, email, username, usage_emails, confirmed, created_at, updated_at]
      properties:
        id: { type: integer }
        name: { type: [string, "null"] }
        email: { type: string }
        phone: { type: [string, "null"] }
        username: { type: string }
        usage_emails: { type: boolean, description: Usage and inactivity emails on. }
        confirmed: { type: boolean }
        confirmed_at: { type: [string, "null"], format: date-time }
        confirmation_sent_at: { type: [string, "null"], format: date-time }
        unconfirmed_email: { type: [string, "null"], description: A requested email change awaiting confirmation. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Platform:
      type: string
      enum: [ios, android, macos, windows, linux]

    Device:
      type: object
      required: [uuid, name, platform, current, connected, created_at]
      properties:
        uuid: { type: string }
        name: { type: string }
        platform: { $ref: "#/components/schemas/Platform" }
        app_version: { type: [string, "null"] }
        current: { type: boolean, description: This is the install making the request. }
        connected: { type: boolean, description: It has a connected tunnel. }
        last_seen_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Pagination:
      type: object
      required: [current_page, total_pages, total_count]
      properties:
        current_page: { type: integer }
        previous_page: { type: [integer, "null"] }
        next_page: { type: [integer, "null"] }
        limit: { type: integer }
        total_pages: { type: integer }
        total_count: { type: integer }

    Location:
      type: object
      required: [id, name, code, available, premium_only, allowed]
      properties:
        id: { type: integer }
        name: { type: string }
        code: { type: string, description: ISO 3166-1 alpha-2. }
        available: { type: boolean }
        premium_only: { type: boolean }
        allowed: { type: boolean, description: Open and allowed by the user's plan. }

    TransportProtocol:
      type: string
      enum: [wireguard, amneziawg, hysteria2, reality]

    ServerList:
      type: object
      required: [network_profile, recommended_transports, data]
      properties:
        network_profile:
          type: string
          description: "`default`, `restricted`, `censored` (config/transports.yml; more may be added)."
        recommended_transports:
          type: array
          items: { $ref: "#/components/schemas/TransportProtocol" }
          description: The order to try protocols in for the user's network.
        data:
          type: array
          items: { $ref: "#/components/schemas/Server" }

    Server:
      type: object
      required: [id, name, country, premium, allowed, available, load, transports]
      properties:
        id: { type: integer }
        name: { type: string }
        country:
          type: object
          required: [id, name, code]
          properties:
            id: { type: integer }
            name: { type: string }
            code: { type: string }
        city: { type: [string, "null"] }
        premium: { type: boolean }
        allowed: { type: boolean, description: The user's plan may use it. }
        available: { type: boolean, description: Taking new tunnels now (open, healthy, capacity left). }
        load: { type: integer, minimum: 0, description: Percent of capacity in use. }
        transports:
          type: array
          description: In `recommended_transports` order.
          items: { $ref: "#/components/schemas/ServerTransport" }

    ServerTransport:
      type: object
      required: [protocol, host, port]
      properties:
        protocol: { $ref: "#/components/schemas/TransportProtocol" }
        host: { type: string, description: Hostname, IPv4 or IPv6 (no brackets); ping it for latency. }
        port: { type: integer }
        public_key: { type: [string, "null"] }
        available: { type: boolean }

    Tunnel:
      type: object
      required: [uuid, display_name, status, transport, server, usage]
      properties:
        uuid: { type: string }
        name: { type: [string, "null"] }
        display_name: { type: string, description: The name, or the location when there's none. }
        status: { type: string, enum: [online, offline] }
        transport: { $ref: "#/components/schemas/TransportProtocol" }
        device_uuid: { type: [string, "null"], description: The app install it belongs to; null for website tunnels. }
        server:
          type: object
          required: [id, name, location]
          properties:
            id: { type: integer }
            name: { type: string }
            location: { type: string }
        connected_at: { type: [string, "null"], format: date-time }
        disconnected_at: { type: [string, "null"], format: date-time }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Always null — tunnels don't expire (kept for compatibility).
        usage:
          type: object
          required: [bytes_downloaded, bytes_uploaded]
          properties:
            bytes_downloaded: { type: integer }
            bytes_uploaded: { type: integer }

    TunnelWithConfig:
      allOf:
        - $ref: "#/components/schemas/Tunnel"
        - type: object
          properties:
            config:
              description: Only while connected; shape depends on `transport`.
              oneOf:
                - $ref: "#/components/schemas/WireguardConfig"
                - $ref: "#/components/schemas/HysteriaConfig"
                - $ref: "#/components/schemas/RealityConfig"

    WireguardConfig:
      type: object
      description: WireGuard / AmneziaWG — the device adds its own private key.
      required: [interface, peer]
      properties:
        interface:
          type: object
          required: [address, dns]
          properties:
            address: { type: [string, "null"] }
            dns: { type: array, items: { type: [string, "null"] } }
            obfuscation:
              type: object
              description: AmneziaWG only (Jc, Jmin, Jmax, S1, S2, H1–H4).
              additionalProperties: true
        peer:
          type: object
          required: [public_key, endpoint, allowed_ips, persistent_keepalive]
          properties:
            public_key: { type: [string, "null"] }
            endpoint: { type: string }
            allowed_ips: { type: array, items: { type: string } }
            persistent_keepalive: { type: integer }

    HysteriaConfig:
      type: object
      description: A Hysteria2 client config in Hysteria's own keys. `auth` only in the connect response.
      required: [server, tls]
      properties:
        server: { type: string }
        auth: { type: string }
        tls:
          type: object
          properties:
            sni: { type: [string, "null"] }
            insecure: { type: boolean }
            pinSHA256: { type: [string, "null"] }
        obfs:
          type: object
          properties:
            type: { type: string }
            salamander: { type: object, properties: { password: { type: string } } }

    RealityConfig:
      type: object
      description: A VLESS+REALITY Xray outbound. `id`/`uri` only in the connect response.
      required: [protocol, address, port, security, reality]
      properties:
        protocol: { type: string, const: vless }
        address: { type: string }
        port: { type: integer }
        id: { type: string }
        flow: { type: string }
        encryption: { type: string }
        network: { type: string }
        security: { type: string, const: reality }
        reality:
          type: object
          properties:
            serverName: { type: [string, "null"] }
            publicKey: { type: [string, "null"] }
            shortId: { type: [string, "null"] }
            fingerprint: { type: string }
        uri: { type: string }

    Subscription:
      type: object
      required: [plan, plan_name, cancel_at_period_end, payment_failed, limits, usage, upgrade_url]
      properties:
        plan: { type: string, description: "`free` or `premium`." }
        plan_name: { type: string }
        status: { type: [string, "null"], description: Stripe subscription status while entitled. }
        current_period_end: { type: [string, "null"], format: date-time }
        cancel_at_period_end: { type: boolean }
        payment_failed: { type: boolean }
        grace_ends_at: { type: [string, "null"], format: date-time }
        limits:
          type: object
          required: [max_devices, premium_locations]
          properties:
            max_devices: { type: integer }
            inactive_release_days:
              type: [integer, "null"]
              description: Website tunnels unused this long are released; null = never.
            monthly_data_bytes: { type: [integer, "null"], description: null = unlimited. }
            premium_locations: { type: boolean }
        usage:
          type: object
          required: [active_tunnels, data_used_bytes, data_period_start]
          properties:
            active_tunnels: { type: integer }
            data_used_bytes: { type: integer }
            data_period_start: { type: string, format: date }
        upgrade_url: { type: string, description: Where to upgrade (web checkout). }

    AccountExport:
      type: object
      required: [exported_at, account, plan, data_usage, devices, active_tunnels, invoices]
      properties:
        exported_at: { type: string, format: date-time }
        account:
          type: object
          required: [email]
          properties:
            email: { type: string }
            name: { type: [string, "null"] }
            created_at: { type: [string, "null"], format: date-time }
            email_confirmed_at: { type: [string, "null"], format: date-time }
            sign_in_count: { type: [integer, "null"] }
            current_sign_in_at: { type: [string, "null"], format: date-time }
            last_sign_in_at: { type: [string, "null"], format: date-time }
            google_sign_in: { type: boolean }
            apple_sign_in: { type: boolean }
        plan:
          type: object
          required: [name]
          properties:
            name: { type: string }
            subscription:
              oneOf:
                - type: "null"
                - type: object
                  properties:
                    status: { type: string }
                    current_period_end: { type: [string, "null"], format: date-time }
                    cancel_at_period_end: { type: [boolean, "null"] }
                    paid_with_stripe: { type: boolean }
        data_usage:
          type: object
          properties:
            period_start: { type: string, format: date }
            bytes_used: { type: integer }
            monthly_limit_bytes: { type: [integer, "null"] }
        devices:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              platform: { type: string }
              app_version: { type: [string, "null"] }
              last_seen_at: { type: [string, "null"], format: date-time }
              signed_in_at: { type: string, format: date-time }
              signed_out_at: { type: [string, "null"], format: date-time }
        active_tunnels:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              location: { type: string }
              server: { type: string }
              protocol: { type: string }
              private_address: { type: [string, "null"] }
              public_key: { type: [string, "null"] }
              connected_at: { type: [string, "null"], format: date-time }
              expires_at: { type: [string, "null"], format: date-time }
              bytes_downloaded: { type: integer }
              bytes_uploaded: { type: integer }
        invoices:
          type: array
          items:
            type: object
            properties:
              number: { type: [string, "null"] }
              total: { type: number }
              tax: { type: number }
              paid_at: { type: [string, "null"], format: date-time }
