# REST API

The REST API lets you create and manage VPN monitors, run tests, read results and incidents, and manage subscription URLs. Alert channels, outbound webhooks, team members, billing, and status pages are managed in the dashboard.

All endpoints are under `https://app.tunnelhq.com/api/v1`.

## Authentication

Send your API key in the `X-API-Key` header, or as `Authorization: Bearer <key>`. Endpoints that act on a project also need an `X-Project-Id` header, which accepts either `42` or `prj_42`. Without it, those endpoints return `400`.

Don't have a key yet? See [Getting an API key](/docs/api-keys/). API access needs the Starter plan or above.

## Example requests

Monitors are exposed as the `/servers` resource, with prefixed IDs such as `srv_916`.

### List monitors

```bash title="List monitors"
curl https://app.tunnelhq.com/api/v1/servers \
  -H "X-API-Key: $TUNNELHQ_API_KEY" \
  -H "X-Project-Id: 42"
```

### Create a monitor

`name` and `protocol` are required. Config-file protocols such as WireGuard take the full config in a `config` field, and the endpoint host and port are read from it.

```bash title="Create a WireGuard monitor"
curl -X POST https://app.tunnelhq.com/api/v1/servers \
  -H "X-API-Key: $TUNNELHQ_API_KEY" \
  -H "X-Project-Id: 42" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Helsinki-I",
    "protocol": "wireguard",
    "config": "[Interface]\nPrivateKey = ...\nAddress = 10.0.0.2/32\n\n[Peer]\nPublicKey = ...\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0"
  }'
```

You can also set `timeout`, `maxretries`, and `interval` in seconds. The interval can be anything from your plan's fastest interval up to `86400` (24 hours); a shorter one returns `403`. If you leave it out, it's 3 minutes or your plan's fastest interval, whichever is longer.

### Run an on-demand test

`POST /test` runs a check against an existing monitor and waits up to 75 seconds for the result.

```bash title="Run an on-demand test"
curl -X POST https://app.tunnelhq.com/api/v1/test \
  -H "X-API-Key: $TUNNELHQ_API_KEY" \
  -H "X-Project-Id: 42" \
  -H "Content-Type: application/json" \
  -d '{"server_id": "srv_916"}'
```

If the test finishes in time, the response is `200` with the result:

```json title="200: test result"
{
  "test_id": "…",
  "server_id": "srv_916",
  "status": "healthy",
  "latency_ms": 157,
  "connect_ms": 364,
  "error_message": null,
  "tested_at": "…"
}
```

`status` is `healthy`, `down`, or `degraded`. `connect_ms` is how long the tunnel took to come up, and `latency_ms` is one round trip through it, both in milliseconds. If the test is still running after 75 seconds, the response is `202` with `"status": "pending"` and the same `test_id`, `server_id`, and `tested_at`.

To test several monitors at once, send `POST /test/batch` with `{"server_ids": ["srv_916", "srv_917"]}`.

Both endpoints accept two optional fields:

| Field | Description |
| --- | --- |
| `region` | The two-letter region code of the location to test from. Needs the Pro or Business plan; other plans get `402`. |
| `region_fallback` | What happens if the location has no checker of its own and no residential route. `any` (the default) runs the test from the general pool instead, so the result isn't from that location. `fail` doesn't test from anywhere else and reports the location as unavailable. Scheduled checks from a chosen location always use `fail`. |

On-demand tests need a role that can edit monitors. Each monitor tested counts as one test against your plan's monthly on-demand quota.

## Endpoints

Each endpoint links to its page in the [API reference](/docs/api/reference/), with every parameter, response, and error message. Endpoints marked **Project** need the `X-Project-Id` header. **Key only** endpoints need just the API key.

| Method and path | Scope | Description |
| --- | --- | --- |
| [`GET /servers`](/docs/api/reference/operations/list-monitors/) | Project | List monitors. |
| [`POST /servers`](/docs/api/reference/operations/create-monitor/) | Project | Create a monitor. |
| [`GET /servers/:id`](/docs/api/reference/operations/get-monitor/) | Project | Get a monitor. |
| [`PUT /servers/:id`](/docs/api/reference/operations/update-monitor/) | Project | Update a monitor. |
| [`DELETE /servers/:id`](/docs/api/reference/operations/delete-monitor/) | Project | Delete a monitor. |
| [`GET /servers/search?q=`](/docs/api/reference/operations/search-monitors/) | Key only | Search monitors across every project the key can reach. |
| [`POST /test`](/docs/api/reference/operations/run-test/) | Project | Run an on-demand test of one monitor. |
| [`POST /test/batch`](/docs/api/reference/operations/run-batch-test/) | Project | Run on-demand tests of several monitors. |
| [`POST /check`](/docs/api/reference/operations/check-config/) | Key only | Check a config without saving a monitor. |
| [`GET /results`](/docs/api/reference/operations/list-results/) | Project | Recent check results. |
| [`GET /results/failures`](/docs/api/reference/operations/list-failures/) | Project | Recent failed checks. |
| [`GET /results/server/:id`](/docs/api/reference/operations/get-monitor-results/) | Project | Check history for one monitor. |
| [`GET /incidents`](/docs/api/reference/operations/list-incidents/) | Project | List incidents. |
| [`GET /incidents/:id`](/docs/api/reference/operations/get-incident/) | Project | Get an incident. |
| [`GET /incidents/:id/events`](/docs/api/reference/operations/list-incident-events/) | Project | An incident's activity log. |
| [`POST /incidents/:id/acknowledge`](/docs/api/reference/operations/acknowledge-incident/) | Project | Acknowledge an incident. |
| [`POST /incidents/:id/retest`](/docs/api/reference/operations/retest-incident/) | Project | Retest the monitor behind an incident. |
| [`GET /subscriptions`](/docs/api/reference/operations/list-subscriptions/) | Project | List subscription URLs. |
| [`POST /subscriptions`](/docs/api/reference/operations/add-subscription/) | Project | Add a subscription URL. |
| [`GET /subscriptions/:id`](/docs/api/reference/operations/get-subscription/) | Project | Get a subscription. |
| [`PUT /subscriptions/:id`](/docs/api/reference/operations/update-subscription/) | Project | Update a subscription. |
| [`DELETE /subscriptions/:id`](/docs/api/reference/operations/delete-subscription/) | Project | Remove a subscription. |
| [`POST /subscriptions/:id/sync`](/docs/api/reference/operations/sync-subscription/) | Project | Sync a subscription now. |
| [`GET /whoami`](/docs/api/reference/operations/whoami/) | Key only | Who the key belongs to, the workspaces (with your role) and projects it can reach, and the default workspace and project. |
| [`GET /organizations`](/docs/api/reference/operations/list-organizations/) | Key only | Workspaces the key can reach, with your role in each. |
| [`GET /projects`](/docs/api/reference/operations/list-projects/) | Key only | Projects the key can reach. Filter with `?organization_id=`. |
| [`GET /usage`](/docs/api/reference/operations/get-usage/) | Key only | API request usage and limits for this minute, today, and this month, with daily history and a per-endpoint breakdown. |
| [`GET /keys`](/docs/api/reference/operations/list-keys/) | Key only | List API keys. |
| [`POST /keys`](/docs/api/reference/operations/create-key/) | Key only | Create a workspace API key. Needs a [workspace key](/docs/api-keys/#kinds-of-key); an account-wide key gets `403`. |
| [`DELETE /keys/:id`](/docs/api/reference/operations/delete-key/) | Key only | Disable an API key. Add `?hard=1` to delete it for good. |

These endpoints don't need a key:

| Method and path | Description |
| --- | --- |
| [`GET /health`](/docs/api/reference/operations/get-health/) | Whether the API is up, with its version and uptime. |
| [`GET /health/deep`](/docs/api/reference/operations/get-deep-health/) | Also checks the database and the job queue. Returns `503` if either fails. |
| [`GET /regions`](/docs/api/reference/operations/list-regions/) | The locations you can test from, with the number of healthy checkers in each. |
| [`POST /check/public`](/docs/api/reference/operations/check-config-public/) | A one-off config check, limited to 5 per IP address per 24 hours. Nothing is saved. The [free tester](/test/) on this site uses it. |

In the dashboard, the [API reference](https://app.tunnelhq.com/api/reference) shows most endpoints with example requests and responses.

## Monitor status in the API

The API's status words differ from the dashboard's:

| API `status` | Dashboard shows | Meaning |
| --- | --- | --- |
| `healthy` | Healthy | The latest check passed. |
| `down` | Down | Confirmed down. |
| `partial` | Partial | Several locations, some up and some down. |
| `degraded` | Unknown or Degraded | The latest check has no verdict yet (retrying, inconclusive, or a fault on TunnelHQ's checker), shown as **Unknown**; or the server refused the monitor's credentials, shown as **Degraded**. |
| `paused` | Paused | The monitor is paused. |
| `unknown` | Pending | The monitor hasn't been checked yet. |

Per-location results in `regional_status` use their own words: `up`, `down`, `pending`, `degraded`, `unavailable`, and `unknown`. See the [API reference](/docs/api/reference/) for every field.

## Errors

Errors return a JSON body with the HTTP status repeated in `code`:

```json title="404: standard error"
{ "error": true, "code": 404, "message": "Server not found" }
```

Usage-limit errors (`429` when you exceed your plan's request limits) use a different shape, with `error` naming the limit you hit:

| Limit | `error` | `retry_after` |
| --- | --- | --- |
| Per minute | `Rate limit exceeded` | `60` |
| Per day | `Daily limit exceeded` | `3600` |
| Per month | `Monthly limit exceeded` | Not sent |

```json title="429: usage limit"
{ "error": "Rate limit exceeded", "message": "Per-minute limit of 60 requests exceeded", "retry_after": 60 }
```

Too many failed key attempts from one IP address also return `429`, in the standard shape:

```json title="429: too many failed keys"
{ "error": true, "code": 429, "message": "Too many failed API key attempts from this address. Wait a few seconds and try again.", "retry_after": 6 }
```

## Rate limits and usage

Requests are limited per workspace, according to its plan, in fixed per-minute, per-day, and per-month windows.

| Plan | Per minute | Per day | Per month | On-demand tests per month |
| --- | --- | --- | --- | --- |
| Free | — | — | — | 5 |
| Starter | 30 | 5,000 | 50,000 | 50 |
| Pro | 60 | 25,000 | 250,000 | 500 |
| Business | 120 | 100,000 | 1,000,000 | Unlimited |

Free has no API access. The on-demand test quota counts one test per monitor tested, whether from the dashboard, `POST /test`, `POST /test/batch`, or an incident retest. It resets at the start of each calendar month (UTC). `POST /check` doesn't count against it.

Authenticated responses report the per-minute window in three headers:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed per minute on your plan. |
| `X-RateLimit-Remaining` | Requests left in the current minute. |
| `X-RateLimit-Reset` | Unix time when the current minute ends. |

When you hit the per-minute limit, the `429` response includes `Retry-After: 60`. For the per-day limit it's `Retry-After: 3600`. The monthly limit has no `Retry-After`.

The **API Keys** page shows usage for each key and for the whole workspace.
