# ZyloVPN API v1

For the ZyloVPN native clients. Base URL: `https://panel.zylovpn.com/api/v1`

All requests must send `Accept: application/json`. Authenticated requests send `Authorization: Bearer <token>`.

> The node agent API at `/api/v1/node` is a **separate** surface with its own guard and credentials. It is documented in [DEPLOYMENT.md](DEPLOYMENT.md) and is not part of this API.

---

## Versioning

The version is in the path from day one. Native apps cannot be force-upgraded — an old build stays on a customer's phone for years — so `/v2` has to be able to exist beside `/v1` rather than replacing it. Treat `/v1` as frozen once clients ship.

---

## Authentication

### `POST /auth/login`

Unauthenticated. Throttled to 10 requests/minute per IP, and 5 failed attempts per email+IP before a 5-minute lockout.

```json
{
  "email": "customer@example.com",
  "password": "…",
  "device_name": "Pixel 9",
  "two_factor_code": "123456"
}
```

`device_name` is required and is shown to the customer when managing sessions. Use something recognisable — the model name, not a UUID.

**200**
```json
{
  "token": "17|abcdef…",
  "expires_at": "2027-08-10T12:00:00+00:00",
  "user": { "data": { "id": "…", "email": "…", "has_vpn_access": true } }
}
```

**403 — second factor required**
```json
{ "message": "Two-factor authentication required.", "two_factor_required": true }
```

Retry the same request with `two_factor_code`. The API enforces 2FA rather than bypassing it: an app that skipped it would make itself a bypass for the protection the customer deliberately enabled. A recovery code is accepted in place of a TOTP code.

**422** — bad credentials. Deliberately identical for unknown, wrong-password, and suspended accounts; the response does not reveal whether an address has an account.

**429** — rate limited.

### `POST /auth/logout`
Revokes the token used for this request.

### `POST /auth/logout-all`
Revokes every token on the account. This is "sign out everywhere".

Tokens expire after one year. Handle **401** by discarding the stored token and returning the user to sign-in.

---

## Account

### `GET /user`
```json
{ "data": {
  "id": "uuid", "name": "…", "email": "…",
  "email_verified": true, "two_factor_enabled": false,
  "status": "active", "has_vpn_access": true,
  "remaining_device_slots": 3
}}
```

### `GET /subscription`

Returns **200 with `data: null`** when there is no subscription — that is a valid state of an existing resource, not a missing one. Do not treat it as an error.

```json
{ "data": {
  "plan": "ZyloVPN Pro", "status": "active",
  "grants_access": true,
  "device_limit": 5,
  "data_limit_bytes": null, "data_used_bytes": 1048576,
  "unmetered": true,
  "ends_at": "2026-09-10T00:00:00+00:00", "days_remaining": 31,
  "auto_renew": true
}}
```

**Branch on `grants_access`.** Do not re-derive entitlement from `status` plus dates — that logic lives server-side and the two will drift.

### `GET /locations`

Already filtered by the customer's plan **and** live server health, so it can be rendered directly. A location that appears here can be used; one that cannot is not listed.

```json
{ "data": [
  { "id": "us-new-york", "country": "United States", "country_code": "US",
    "city": "New York", "display_name": "United States — New York", "flag": "🇺🇸" }
]}
```

Use `id` (the slug) wherever an endpoint takes `location`.

Server names, addresses, ports and capacity are deliberately absent. A client needs to know a location works, not the node inventory.

### `GET /vpn/status`

```json
{
  "has_access": true,
  "active_peers": 2,
  "appears_connected": true,
  "measurement": "inferred_from_handshake",
  "connections": [
    { "device_id": "uuid", "location": "United States — New York",
      "assigned_ip": "10.10.0.42",
      "last_handshake_at": "2026-08-10T11:59:12+00:00",
      "appears_connected": true, "rx_bytes": 10485760, "tx_bytes": 2097152 }
  ]
}
```

`measurement` is `inferred_from_handshake` and will stay that way. WireGuard is connectionless — "connected" means "completed a handshake recently". **Show the platform's own VPN state as the source of truth in your UI**, and use this only for cross-device context. Presenting `appears_connected` as definitive will eventually contradict the OS.

---

## Devices

### `GET /devices`

```json
{ "data": [
  { "id": "uuid", "name": "Pixel 9", "platform": "android",
    "platform_label": "Android", "status": "active",
    "created_at": "…", "last_connected_at": "…",
    "connection": {
      "assigned_ip": "10.10.0.42", "public_key": "…",
      "status": "active", "appears_connected": true,
      "rx_bytes": 0, "tx_bytes": 0,
      "location": { "id": "us-new-york", "…": "…" }
    }}
]}
```

Never contains a private key. Poll this freely.

### `POST /devices`

Requires an active subscription.

```json
{ "name": "Pixel 9", "platform": "android", "location": "us-new-york" }
```

`platform`: `windows`, `macos`, `linux`, `android`, `ios`, `router`, `other`.

**201**
```json
{ "device": { "…": "…" }, "config": "[Interface]\nPrivateKey = …" }
```

> **`config` is returned once and is not retrievable later** unless the operator has key retention enabled. Write it into the platform's WireGuard store before doing anything else. If the write fails, call `/regenerate` rather than asking the user to retry — the key from a failed attempt is gone.

**403** device limit reached, or no active subscription.
**409** no server has capacity in that location. Well-formed request, conflicting state — offer another location rather than retrying.

### `GET /devices/{id}/config`

Re-fetches an existing configuration. **409** when key retention is disabled and the key was never stored; the correct response is to regenerate.

### `POST /devices/{id}/regenerate`

Optional body: `{ "location": "gb-london" }`. Omit to keep the current location.

Issues new keys and returns a new `config`. **The previous configuration stops working immediately** — replace the stored tunnel before telling the user it succeeded.

### `DELETE /devices/{id}`

Revokes the device. Its plan slot is freed at once.

Deliberately **does not** require an active subscription: a customer whose card expired must still be able to cut off a stolen laptop.

---

## Errors

| Code | Meaning |
|---|---|
| 401 | Missing, invalid, or expired token — discard it and re-authenticate |
| 403 | Authenticated but not permitted: device limit, no subscription, 2FA required |
| 404 | Not found, or not yours — ownership failures are not distinguished |
| 409 | Valid request, conflicting state: no capacity, config not retrievable |
| 422 | Validation failed — see `errors` |
| 429 | Rate limited |

Validation errors follow Laravel's shape:

```json
{ "message": "The given data was invalid.",
  "errors": { "email": ["These credentials do not match our records."] } }
```

Error messages are written for end users and can be shown directly. Internal detail, stack traces and provider messages never appear in a response.

---

## Client implementation notes

**Private keys.** Store in the platform keystore — Keychain on iOS/macOS, EncryptedSharedPreferences or the Keystore on Android, DPAPI on Windows. Never in plain preferences, never in logs, never in crash reports. The server holds an encrypted copy only if the operator enabled retention; assume it does not.

**Do not implement WireGuard yourself.** Use `NEPacketTunnelProvider` with the official `WireGuardKit` on Apple platforms, and the official `wireguard-android` tunnel library on Android. The generated `.conf` is standard and imports directly.

**Offline behaviour.** The tunnel does not depend on this API. A client that cannot reach the panel should keep its existing tunnel working and retry in the background — never tear down a connection because a status call failed.

**Handling expiry.** When `grants_access` turns false, the server has already disabled the peers; the tunnel will stop passing traffic. Surface the subscription state rather than a generic connection error, or every lapsed customer becomes a support ticket about "the VPN is broken".
