# VPSNine API

The JSON API behind the VPSNine panel. The same reference as a web page: https://my.vpsnine.com/api

OpenAPI 3.1: https://my.vpsnine.com/api/openapi.json (or .yaml). Changelog: https://my.vpsnine.com/api/changelog

## Base URL

Every endpoint lives under this URL. Requests and answers are JSON.

```
https://my.vpsnine.com/api/v1
```

## Authentication

Send an API token as a bearer token. Make one in the panel at https://my.vpsnine.com/account/api-tokens (the form asks for your password); it is shown only once, so save it. A read-only token can call every GET endpoint; anything that changes something needs a read-and-write token. A revoked, expired or unknown token gets 401. An account can hold 10 tokens at a time, and making one needs a confirmed email address. A token can be limited to address ranges when it is made; from any other address it gets 403 ip_not_allowed. The examples below assume these two variables.

```
export VPS9_TOKEN="$(cat ~/.vps9-token)"
export API=https://my.vpsnine.com/api/v1
curl -s -H "Authorization: Bearer $VPS9_TOKEN" "$API/me"
```

## Errors

An error is JSON with a stable code for scripts and a sentence for people. Any endpoint can answer unauthorized (401), insufficient_scope (403, the token lacks the scope), forbidden_role (403, your role on the account can't do this; see Teams), not_found (404, no such endpoint or object), method_not_allowed (405, the path exists but not with that method; the Allow header lists the ones it takes), rate_limited (429) and server_error (500, our fault). Any endpoint that takes a body can also answer invalid_json (400, the body isn't JSON or couldn't be read), body_too_large (413, bodies are 16 KB at most; 64 KB for POST /servers and POST /servers/:id/actions, which can carry a setup script), unsupported_media_type (415, see Conventions) and invalid_request (422, the body isn't a JSON object, has a field we don't know, or a field of the wrong type). Each endpoint below lists its own codes too. A 4xx means the request needs changing before you send it again; only 429, 500 and 503 are worth retrying as they are. A 503 means nothing changed; firewall_unavailable, snapshots_unavailable and orders_unavailable pass quickly, so try again in a moment.

```
{ "error": { "code": "not_found", "message": "No such server.", "requestId": "req_7d2kq4m5x3n6p2r4s7t2v3w4" } }
```

## Rate limits

Each token gets 60 requests a minute, and each account 120 a minute across all its tokens, so more tokens don't mean more requests. Past either you get 429 rate_limited with a Retry-After header in seconds. Two things have their own limits as well: 10 reverse DNS changes an hour, and 10 orders an hour (tries that fail count, and orders placed in the panel count too). Every answer to a call with a token says where you stand: X-RateLimit-Limit (60, or 120 once the account's limit is the nearer one), X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the minute starts again). GET /prices sends them too, for its own limit.

## Request ids

Every answer has an X-Request-Id header (req_ and 24 letters and digits), and an error repeats it as error.requestId. We log each request under its id with the route, the status and the time it took (never the token or the body), so if something looks wrong, quote the id in a support ticket and we can find exactly that request.

```
{ "error": { "code": "not_found", "message": "No such server.", "requestId": "req_7d2kq4m5x3n6p2r4s7t2v3w4" } }
```

## Conventions

Ids, states, addresses and networks are plain strings. Sizes and amounts are integers: memoryMiB, diskGiB, totalCents. Times are ISO 8601 in UTC. Work that happens on the server answers 202 and finishes in the background: read the server again to follow it. Types are strict. A request with a body must say Content-Type: application/json (charset utf-8 if any; gzip and deflate compression are fine), or it gets 415 unsupported_media_type: curl -d without that header sends a form, which is refused rather than read as empty. The body must be a JSON object; a field we don't know, or a field of the wrong type (a number where a string goes, null, a list), gets 422 invalid_request and nothing changes. Ids are lowercase, exactly as the API gives them out.

## Ordering and retries

POST /servers places an order and answers with an invoice; the server is built once that invoice is paid. So that a retry after a timeout can't order twice, send an Idempotency-Key header (up to 60 letters, digits, dots, colons, dashes or underscores; a UUID works) and keep it until you have an answer. The same key sent again with the same order answers with the order it already placed, even if you are now at the limit of unpaid orders or the plan is no longer on sale. The same key with a different order is refused with 422 idempotency_key_reused. Keys are case-sensitive: Order-A and order-a are two keys. Without the header every POST is a new order. Other endpoints ignore the header. Ordering needs a confirmed email address: until then POST /servers answers 403 email_unverified.

```
export ORDER_KEY="$(uuidgen)"
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $ORDER_KEY" \
  -d '{"plan":"standard","image":"debian-12","hostname":"web-1","sshKeyIds":["key_m2q7x4abcdefgh2s"]}' \
  "$API/servers"
```

## Webhooks

Instead of polling, register an HTTPS endpoint (up to 5 per account, on a public address) and we POST each event to it as JSON: { "id", "type", "createdAt", "data" }. Every delivery has the headers VPSNine-Event, VPSNine-Delivery and VPSNine-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t, a dot and the raw body, keyed with the endpoint's signing secret (shown once when you make the endpoint or rotate it). Check it, and refuse a t more than 5 minutes from your clock. Answer with any 2xx within 10 seconds; anything else is retried after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours. Redirects are not followed. An endpoint that keeps failing for a day is turned off and you are told. The same event can arrive twice (a retry after a lost answer, a redelivery): use its id to skip repeats. Event types: server.ready, server.suspended, server.unsuspended, server.action_failed, snapshot.completed, snapshot.failed, backup.failed, backup.restored, invoice.issued, invoice.paid, invoice.overdue, payment.received, credit.added, ticket.replied, alert.triggered, alert.resolved, monitor.down, monitor.up, monitor.tls_expiring, and ping for a test event. Code samples: https://vpsnine.com/help/webhooks

## Uptime monitors

Monitors check your own services from outside, from our control plane: http (a URL, its status code, an optional keyword, the TLS certificate's expiry), tcp (a host and port), ping (TCP reachability: ports 443, 80 and 22; we can't send ICMP), dns (a record through public resolvers) and heartbeat (your job calls its pingUrl; missed pings mean down). intervalS is 60, 120, 180, 240, 300 seconds (accounts without a server: 300). A failed check is confirmed by a second one 20 seconds later before the monitor is down; then you get one monitor.down notice (in the panel, by email when email is true, and to webhooks subscribed to it) and one monitor.up when it recovers. Only public addresses can be checked: private, loopback, link-local and metadata addresses are refused when you save and before every check. Checks send the user agent VPSNine-Monitor/1.0. Status pages show the monitors you pick at https://vpsnine.com/s/<slug>. Guide: https://vpsnine.com/help/uptime-monitoring

## Notification channels

Channels send the notifications you pick to Slack, Discord, Telegram, ntfy, Microsoft Teams or another email address (up to 10 per account), formatted for each service. categories is any of servers, billing, support, alerts, developer, monitors; security notices are never sent to a channel. A channel's URL, token, chat or address is kept encrypted and never returned: target shows a masked form. A Telegram channel starts pending with pairing links (open one to connect a chat, within 30 minutes); an email channel starts pending until the address confirms the link we email it. Failed sends are retried on the webhook schedule, and a channel failing for a day is turned off and you are told. Push devices are browsers with push on, added from the panel's notification settings. Guide: https://vpsnine.com/help/notification-channels

## Teams

An account can have several people, each with a role: owner, admin, billing, technical or read only. A token belongs to the person who made it and acts on the account they had open in the panel when they made it; GET /me says which, and their role there. A token never does more than its maker's role allows now: an endpoint the role can't use answers 403 forbidden_role (a role lowered later applies at once), and a read-only member can only make read-only tokens. If the account requires two-factor authentication and the person has it off, every call answers 403 two_factor_required. Removing someone from the account revokes their tokens for it. Ownership transfer is panel-only: it needs the owner's password and 2FA code.

## Catalog files

Without the API: https://vpsnine.com/plans.json has every plan's specs and live price, the add-ons and what every plan includes; https://vpsnine.com/locations.json lists where servers run. Both are plain JSON for scripts and comparison sites, cached for five minutes, and readable from any origin. A field is only ever added; schemaVersion changes if one is removed or retyped.

## AI tools (MCP)

The API is also an MCP server, for Claude, Cursor, VS Code and other AI tools: https://my.vpsnine.com/mcp over Streamable HTTP with the same bearer token. Every tool call is an API request with the token's scopes, role, address limit and rate limits. A read-only token sees only the read tools; ordering and add-on changes answer with the price until called with confirm: true, and reinstall, delete and snapshot revert or delete also need the server's hostname typed back. Setup for each tool: https://vpsnine.com/help/mcp.

## Prices

### GET /prices

Prices. What is on sale and what it costs, in integer US cents: each plan a month and 12 months at once (yearlyCents; null while a plan has no yearly price), and each add-on per step (step is in its unit: GB, TB, or a count) with its caps per plan. Needs no token (nor does GET /status). 60 requests a minute per address.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/prices"
```

```json
{
  "currency": "USD",
  "plans": [
    {
      "id": "standard",
      "name": "Standard",
      "vcpu": 2,
      "ramGb": 4,
      "nvmeGb": 80,
      "transferTb": 4,
      "monthlyCents": 1699,
      "yearlyCents": 16900
    }
  ],
  "addons": [
    {
      "key": "ram_gb",
      "name": "Extra RAM",
      "unit": "GB",
      "step": 1,
      "caps": {
        "budget": 2,
        "standard": 4,
        "extreme": 8
      },
      "monthlyCents": 350,
      "yearlyCents": 3500
    }
  ]
}
```

## Status

### GET /status

Service status. What vpsnine.com/status shows, as JSON: the overall level and each component's (operational, maintenance, degraded, partial_outage or major_outage) with its uptime over 90 days in percent (null before there is data), open incidents and maintenance in progress or scheduled, each with its updates newest first. Needs no token; 60 requests a minute per address; cached for 30 seconds. The same document is at https://vpsnine.com/status.json.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status"
```

```json
{
  "status": {
    "level": "degraded",
    "description": "Some systems are degraded"
  },
  "updatedAt": "2026-10-21T14:05:00.000Z",
  "page": "https://vpsnine.com/status",
  "components": [
    {
      "id": "panel",
      "name": "Control panel & API",
      "status": "operational",
      "uptime90d": 99.98
    },
    {
      "id": "billing",
      "name": "Billing and payments",
      "status": "operational",
      "uptime90d": 100
    },
    {
      "id": "nyc",
      "name": "New York",
      "status": "degraded",
      "uptime90d": 99.95
    },
    {
      "id": "network",
      "name": "Network",
      "status": "operational",
      "uptime90d": 100
    }
  ],
  "incidents": [
    {
      "id": "inc_t4q2m7abcdefgh2v",
      "kind": "incident",
      "title": "Slow disk on some New York servers",
      "impact": "minor",
      "status": "monitoring",
      "components": [
        "nyc"
      ],
      "createdAt": "2026-10-21T13:10:00.000Z",
      "updatedAt": "2026-10-21T14:00:00.000Z",
      "startsAt": null,
      "endsAt": null,
      "resolvedAt": null,
      "url": "https://vpsnine.com/status/incidents/inc_t4q2m7abcdefgh2v",
      "updates": [
        {
          "at": "2026-10-21T14:00:00.000Z",
          "status": "monitoring",
          "body": "A failed drive was replaced; the array is rebuilding and speeds are back to normal."
        },
        {
          "at": "2026-10-21T13:10:00.000Z",
          "status": "investigating",
          "body": "Some servers in New York see slow disk writes. We are looking into it."
        }
      ]
    }
  ],
  "maintenance": []
}
```

Errors: `status_unavailable`.

## Account

### GET /me

Who am I. The person the token belongs to, the account it acts on with their role there (see Teams), and the token itself: its id, name, scopes and expiry (null: never).

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/me"
```

```json
{
  "id": "usr_a2b3c4d5e6f7g2h3",
  "email": "you@example.com",
  "emailVerified": true,
  "createdAt": "2026-10-01T09:00:00.000Z",
  "account": {
    "id": "usr_a2b3c4d5e6f7g2h3",
    "name": "you@example.com",
    "role": "owner"
  },
  "token": {
    "id": "tok_q4m7x2abcdefgh2r",
    "name": "laptop CLI",
    "scopes": [
      "read",
      "write"
    ],
    "expiresAt": "2027-01-19T09:00:00.000Z"
  }
}
```

## SSH keys

### GET /ssh-keys

List SSH keys. The SSH keys on your account, oldest first.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/ssh-keys"
```

```json
{
  "data": [
    {
      "id": "key_m2q7x4abcdefgh2s",
      "name": "laptop",
      "type": "ssh-ed25519",
      "fingerprint": "SHA256:Zx9eQ1nO4sV7kq2Lr8wYc3TmA5uBf6Hd0jPgXyN2Ke4",
      "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… you@laptop",
      "createdAt": "2026-10-01T09:05:00.000Z"
    }
  ]
}
```

### POST /ssh-keys

Add an SSH key. Adds a public key (ed25519, ECDSA or RSA of 2048 bits or more). You pick keys when you order a server, and a reinstall puts all of the account's keys on it.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"laptop","publicKey":"your public key here: the one line in ~/.ssh/id_ed25519.pub"}' \
  "$API/ssh-keys"
```

```json
{
  "id": "key_m2q7x4abcdefgh2s",
  "name": "laptop",
  "type": "ssh-ed25519",
  "fingerprint": "SHA256:Zx9eQ1nO4sV7kq2Lr8wYc3TmA5uBf6Hd0jPgXyN2Ke4",
  "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… you@laptop",
  "createdAt": "2026-10-09T12:00:00.000Z"
}
```

Errors: `invalid_request`, `invalid_name`, `invalid_key`, `too_many_keys`, `duplicate_key`.

### DELETE /ssh-keys/:id

Delete an SSH key. Removes the key from your account. Servers keep the keys they already have.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/ssh-keys/key_m2q7x4abcdefgh2s"
```

## Servers

### GET /images

List images. The operating systems you can order or reinstall, in the order form's order. image in POST /servers and in a reinstall takes an id, or a key (the newest image of that system, e.g. ubuntu-24.04). family (debian, rhel or arch, or null) decides which one-click apps an image can take.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/images"
```

```json
{
  "data": [
    {
      "id": "ubuntu-24.04-20261009",
      "key": "ubuntu-24.04",
      "name": "Ubuntu 24.04 LTS",
      "os": "ubuntu",
      "version": "24.04",
      "family": "debian"
    }
  ]
}
```

### GET /apps

List one-click apps. The one-click apps a new server or a reinstall can install on first boot: app in POST /servers and in a reinstall takes an id. families lists the image families (GET /images) each runs on, minPlan the smallest plan it runs well on, ports what it listens on publicly, and url its page with the steps it leaves to you.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/apps"
```

```json
{
  "data": [
    {
      "id": "docker",
      "name": "Docker",
      "category": "containers",
      "summary": "Docker Engine and the Compose plugin from Docker's own repository.",
      "families": [
        "debian",
        "rhel"
      ],
      "minPlan": "budget",
      "ports": [],
      "url": "https://vpsnine.com/apps/docker"
    }
  ]
}
```

### GET /servers

List servers. Your servers, newest first. Deleted ones are left out. label is the name you gave it in the panel (null: none; the hostname is its name) and tags its tags.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers"
```

```json
{
  "data": [
    {
      "id": "srv_k3j5m2abcdefgh2q",
      "hostname": "web-1",
      "plan": "standard",
      "image": "debian-12",
      "state": "running",
      "pendingAction": null,
      "ipv4": "203.0.113.10",
      "ipv6": "2001:db8:10::2",
      "vcpus": 2,
      "memoryMiB": 4096,
      "diskGiB": 80,
      "createdAt": "2026-10-09T12:00:00.000Z",
      "stateSince": "2026-10-09T12:01:00.000Z",
      "app": "docker",
      "deletionProtected": false,
      "purgeAt": null,
      "label": "Shop front",
      "tags": [
        "prod",
        "web"
      ]
    }
  ]
}
```

### GET /servers/:id

Show a server. One server, its name, tags and private notes, and its recent events.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q"
```

```json
{
  "id": "srv_k3j5m2abcdefgh2q",
  "hostname": "web-1",
  "plan": "standard",
  "image": "debian-12",
  "state": "running",
  "pendingAction": null,
  "ipv4": "203.0.113.10",
  "ipv6": "2001:db8:10::2",
  "vcpus": 2,
  "memoryMiB": 4096,
  "diskGiB": 80,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "stateSince": "2026-10-09T12:01:00.000Z",
  "app": "docker",
  "deletionProtected": false,
  "purgeAt": null,
  "label": "Shop front",
  "tags": [
    "prod",
    "web"
  ],
  "notes": "Runs the shop. Deploys from main.",
  "events": [
    {
      "at": "2026-10-09T12:01:00.000Z",
      "actor": "system",
      "action": "create.done",
      "detail": null
    }
  ]
}
```

### PUT /servers/:id/details

Name, tag or annotate a server. Sets the server's display name (label: up to 63 characters, or null for none), its tags (up to 10, each 1 to 24 lowercase letters, digits, dots, dashes, underscores or colons; duplicates are dropped) and private notes (plain text, up to 2,000 characters). Each field you send replaces the stored one; the others are kept. It doesn't change the hostname inside the server: that is set on the first boot (change it there with hostnamectl).

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"label":"Shop front","tags":["prod","web"]}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/details"
```

```json
{
  "label": "Shop front",
  "tags": [
    "prod",
    "web"
  ],
  "notes": "Runs the shop. Deploys from main."
}
```

Errors: `invalid_request`, `invalid_label`, `invalid_tags`, `invalid_notes`.

### POST /servers

Order a server. Places an order and answers with the invoice to pay at payUrl; the server is built once it is paid. plan is budget, standard or extreme. You can have 3 unpaid orders at a time and try 10 orders an hour. Send an Idempotency-Key header to make retries safe. Your account's email must be confirmed (403 email_unverified until it is). Until orders open at launch this answers 403 staff_only. Optional, installed on first boot: app (a one-click app id such as docker, wordpress or wireguard; it must suit the image's family and the plan), packages (up to 20 distribution package names) and setupScript (up to 16 KiB, run once as root after them; its output goes to /var/log/vps9-setup.log and its exit code to /var/lib/vps9-setup.exit). The script is stored encrypted and never shown back; GET answers only app. term is "month" (the default) or "year": 12 months at once at the yearly price. addons adds resources on top of the plan, in their units: ram_gb, disk_gb (steps of 20), transfer_tb, ipv4 and vcpu, each up to the plan's cap (GET /prices lists them); they are billed for the same term. backups: 1 orders it with off-site backups (priced per plan). restoreFrom (optional): one of your backups (bkp_…), restored into the new server once it is built; the plan's disk (with disk_gb) must be at least the backup's. rootPassword (optional): "generate" makes a random root password and answers with it in rootPassword, this once (it is never stored, so a retry with the same Idempotency-Key can't show it again); or send your own (12 to 128 printable ASCII characters, not a common password). Only its SHA-512 crypt hash reaches the server. With a root password, sshKeyIds may be empty; SSH keys stay the safer way in, and with a key root's password works on the serial console only. promoCode (optional): a promo code; its discount is a line on the invoice. A code that can't be used for this order (unknown, expired, used up, for other plans or yearly billing only, for new customers only) is 422 invalid_promo_code with the reason, and nothing is ordered; 10 wrong codes an hour, with the order form.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"plan":"standard","image":"ubuntu-24.04","hostname":"web-1","sshKeyIds":["key_m2q7x4abcdefgh2s"],"app":"docker","packages":["htop"],"setupScript":"docker run -d --restart unless-stopped -p 80:80 nginx","term":"year","addons":{"ram_gb":2,"disk_gb":40,"transfer_tb":0,"ipv4":1,"vcpu":0},"promoCode":"SPRING25"}' \
  "$API/servers"
```

```json
{
  "order": {
    "id": "ord_x2m4q7abcdefgh2u"
  },
  "invoice": {
    "id": "inv_q4m7x2abcdefgh2t",
    "number": "VPS9-000042",
    "kind": "order",
    "status": "open",
    "currency": "USD",
    "subtotalCents": 1200,
    "creditAppliedCents": 0,
    "totalCents": 1200,
    "issuedAt": "2026-10-09T12:00:00.000Z",
    "dueAt": "2026-10-09T12:00:00.000Z",
    "paidAt": null,
    "voidedAt": null,
    "serverId": null,
    "term": "month"
  },
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_q4m7x2abcdefgh2t"
}
```

Errors: `staff_only`, `email_unverified`, `invalid_request`, `invalid_idempotency_key`, `invalid_plan`, `invalid_term`, `invalid_addon`, `invalid_image`, `invalid_hostname`, `ssh_key_required`, `invalid_ssh_key`, `invalid_root_password`, `invalid_app`, `app_unsupported_os`, `app_needs_bigger_plan`, `invalid_packages`, `invalid_setup_script`, `idempotency_key_reused`, `too_many_open_orders`, `rate_limited`, `orders_unavailable`, `invalid_promo_code`, `invalid_restore`, `backup_not_found`, `backup_too_large`.

### POST /servers/:id/actions

Start, stop, reboot, reinstall or delete. Queues one action: start, stop, reboot, reinstall or delete. A server runs one action at a time. Reinstall wipes the disk and puts your account's SSH keys on it; delete is final. The API doesn't ask you to confirm, so check the id before you send either. A reinstall installs the same image, app, packages and setup script as last time, unless you send image (another image id or key), app (another app, or null for none), packages (replaces them) or setupScript (replaces it; null or "" removes it). A reinstall also takes rootPassword: "generate" (answered once in rootPassword) or your own, as for an order; without it root has no password. Other actions take only action.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"action":"reboot"}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/actions"
```

```json
{
  "id": "srv_k3j5m2abcdefgh2q",
  "hostname": "web-1",
  "plan": "standard",
  "image": "debian-12",
  "state": "running",
  "pendingAction": "reboot",
  "ipv4": "203.0.113.10",
  "ipv6": "2001:db8:10::2",
  "vcpus": 2,
  "memoryMiB": 4096,
  "diskGiB": 80,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "stateSince": "2026-10-09T12:01:00.000Z",
  "app": "docker",
  "deletionProtected": false,
  "purgeAt": null
}
```

Errors: `invalid_action`, `action_pending`, `invalid_state`, `protected`, `ssh_key_required`, `invalid_root_password`, `image_unavailable`, `invalid_image`, `invalid_app`, `app_unsupported_os`, `app_needs_bigger_plan`, `invalid_packages`, `invalid_setup_script`.

## Add-ons

### GET /servers/:id/addons

Show add-ons. The server's add-ons now, and what the next renewal bills (lower where you lowered one), its term, its extra IPv4 addresses (configure them on the server: a reinstall does it for you) and rebootPending: new vCPU or RAM is set and waits for a reboot from the panel or the API (a reboot from inside the server keeps the old size). restartAt: when a smaller size a renewal set is applied by a restart we make (72 hours after the renewal is paid), unless you reboot sooner; null when none is planned.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/addons"
```

```json
{
  "billed": true,
  "term": "month",
  "nextTerm": "month",
  "paidUntil": "2026-11-09T12:00:00.000Z",
  "addons": {
    "ram_gb": 2,
    "disk_gb": 40,
    "transfer_tb": 0,
    "ipv4": 1,
    "vcpu": 0
  },
  "nextRenewal": {
    "ram_gb": 1,
    "disk_gb": 40,
    "transfer_tb": 0,
    "ipv4": 1,
    "vcpu": 0
  },
  "extraIpv4": [
    {
      "address": "203.0.113.20",
      "prefix": 24,
      "gateway": "203.0.113.1",
      "state": "active"
    }
  ],
  "rebootPending": false,
  "restartAt": null
}
```

### PUT /servers/:id/addons

Change add-ons. Sets add-on totals; keys you leave out stay as they are. More is invoiced now for the days left in the paid period (and the next period too when its renewal invoice is already out), at least $0.50, and applied once paid: answer invoice and payUrl. Less takes effect from the next renewal invoice, with no refund (scheduled); once that invoice is issued at the lower size, taking the decrease back waits until it is paid (renewal_issued). Less vCPU or RAM is applied at your next reboot from the panel or the API, or by a restart we make 72 hours after the renewal is paid. A disk never shrinks (disk_shrink). apply is "now" (the default: new vCPU or RAM restarts the server once paid) or "reboot" (it waits for your next reboot). One unpaid add-on invoice per server at a time.

Scope: write. Success: 202.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"addons":{"ram_gb":4},"apply":"reboot"}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/addons"
```

```json
{
  "invoice": {
    "id": "inv_q4m7x2abcdefgh2t",
    "number": "VPS9-000042",
    "kind": "addon",
    "status": "open",
    "currency": "USD",
    "subtotalCents": 474,
    "creditAppliedCents": 0,
    "totalCents": 474,
    "issuedAt": "2026-11-02T12:00:00.000Z",
    "dueAt": "2026-11-09T12:00:00.000Z",
    "paidAt": null,
    "voidedAt": null,
    "serverId": "srv_k3j5m2abcdefgh2q",
    "term": "month"
  },
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_q4m7x2abcdefgh2t",
  "scheduled": []
}
```

Errors: `invalid_request`, `invalid_addon`, `disk_shrink`, `nothing_changed`, `no_billing`, `cancelling`, `period_ended`, `addon_invoice_open`, `addons_unavailable`, `no_room`, `node_vcpu_limit`, `node_ipv4_limit`, `no_ipv4`, `renewal_issued`.

## Firewall

### GET /servers/:id/firewall

Show the firewall. The inbound firewall: the default policy, the rules in order (the first match wins) and whether the latest change is applied. A failed or degraded (partly applied) status comes with a message saying what of the rules shown is in effect. The ETag header (like "r3") changes with every save, not when the status changes; send it back as If-Match on a PUT so you never overwrite a change you haven't seen. This endpoint ignores If-None-Match and always sends the firewall, so you never miss a status change.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/firewall"
```

```json
{
  "policy": "deny",
  "rules": [
    {
      "action": "allow",
      "protocol": "tcp",
      "ports": "22",
      "source": "198.51.100.0/24"
    },
    {
      "action": "allow",
      "protocol": "tcp",
      "ports": "80-443",
      "source": null
    }
  ],
  "status": "applied",
  "errorCode": null,
  "updatedAt": "2026-10-09T13:05:00.000Z"
}
```

### PUT /servers/:id/firewall

Replace the firewall. Sets the default policy and the whole rule list, up to 50 rules. action is allow or deny; protocol is tcp, udp, icmp or any; ports (tcp and udp only) is one port or a range like 80-443, or null; source is an address or network, or null for anywhere. Replies to connections the server opened always come back in. With If-Match (the ETag from a GET; a weak W/"r3" from a proxy works too), the save happens only if nobody saved since; otherwise it is refused with 412 firewall_changed and nothing changes. If-Match: * saves over any version. Any other If-Match value is refused with 400 invalid_if_match. The answer carries the new ETag.

Scope: write. Success: 202.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"policy":"deny","rules":[{"action":"allow","protocol":"tcp","ports":"22","source":"198.51.100.0/24"},{"action":"allow","protocol":"tcp","ports":"80-443","source":null}]}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/firewall"
```

```json
{
  "policy": "deny",
  "rules": [
    {
      "action": "allow",
      "protocol": "tcp",
      "ports": "22",
      "source": "198.51.100.0/24"
    },
    {
      "action": "allow",
      "protocol": "tcp",
      "ports": "80-443",
      "source": null
    }
  ],
  "status": "applying",
  "errorCode": null,
  "updatedAt": "2026-10-09T13:05:00.000Z"
}
```

Errors: `invalid_firewall`, `invalid_if_match`, `invalid_state`, `firewall_changed`, `firewall_unavailable`.

## Snapshots

### GET /servers/:id/snapshots

List snapshots. The server's snapshots, oldest first. A server keeps at most 3.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshots"
```

```json
{
  "data": [
    {
      "id": "snap_7xq2m4abcdefgh2r",
      "label": "before-upgrade",
      "state": "ready",
      "createdAt": "2026-10-09T13:00:00.000Z",
      "automatic": false
    }
  ]
}
```

### POST /servers/:id/snapshots

Take a snapshot. Takes a disk snapshot. name labels it: up to 40 letters, digits, spaces, dots, dashes or underscores. A running server shuts down for it and starts again, usually within a minute.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"before-upgrade"}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshots"
```

```json
{
  "snapshot": {
    "id": "snap_7xq2m4abcdefgh2r",
    "label": "before-upgrade",
    "state": "creating",
    "createdAt": "2026-10-09T13:00:00.000Z",
    "automatic": false
  },
  "server": {
    "id": "srv_k3j5m2abcdefgh2q",
    "hostname": "web-1",
    "plan": "standard",
    "image": "debian-12",
    "state": "running",
    "pendingAction": "snapshot",
    "ipv4": "203.0.113.10",
    "ipv6": "2001:db8:10::2",
    "vcpus": 2,
    "memoryMiB": 4096,
    "diskGiB": 80,
    "createdAt": "2026-10-09T12:00:00.000Z",
    "stateSince": "2026-10-09T12:01:00.000Z",
    "app": "docker",
    "deletionProtected": false,
    "purgeAt": null
  }
}
```

Errors: `invalid_request`, `invalid_label`, `limit_reached`, `action_pending`, `invalid_state`, `snapshots_unavailable`.

### POST /servers/:id/snapshots/:snapshotId/revert

Revert to a snapshot. Puts the disk back the way it was in the snapshot, then starts the server. Everything written since the snapshot is lost. No body.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshots/snap_7xq2m4abcdefgh2r/revert"
```

```json
{
  "snapshot": {
    "id": "snap_7xq2m4abcdefgh2r",
    "label": "before-upgrade",
    "state": "ready",
    "createdAt": "2026-10-09T13:00:00.000Z",
    "automatic": false
  },
  "server": {
    "id": "srv_k3j5m2abcdefgh2q",
    "hostname": "web-1",
    "plan": "standard",
    "image": "debian-12",
    "state": "running",
    "pendingAction": "revert",
    "ipv4": "203.0.113.10",
    "ipv6": "2001:db8:10::2",
    "vcpus": 2,
    "memoryMiB": 4096,
    "diskGiB": 80,
    "createdAt": "2026-10-09T12:00:00.000Z",
    "stateSince": "2026-10-09T12:01:00.000Z",
    "app": "docker",
    "deletionProtected": false,
    "purgeAt": null
  }
}
```

Errors: `snapshot_not_found`, `not_ready`, `action_pending`, `invalid_state`, `snapshots_unavailable`.

### DELETE /servers/:id/snapshots/:snapshotId

Delete a snapshot. Deletes the snapshot. A running server shuts down briefly while it is removed.

Scope: write. Success: 202.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshots/snap_7xq2m4abcdefgh2r"
```

```json
{
  "snapshot": {
    "id": "snap_7xq2m4abcdefgh2r",
    "label": "before-upgrade",
    "state": "deleting",
    "createdAt": "2026-10-09T13:00:00.000Z",
    "automatic": false
  },
  "server": {
    "id": "srv_k3j5m2abcdefgh2q",
    "hostname": "web-1",
    "plan": "standard",
    "image": "debian-12",
    "state": "running",
    "pendingAction": "snapshot_delete",
    "ipv4": "203.0.113.10",
    "ipv6": "2001:db8:10::2",
    "vcpus": 2,
    "memoryMiB": 4096,
    "diskGiB": 80,
    "createdAt": "2026-10-09T12:00:00.000Z",
    "stateSince": "2026-10-09T12:01:00.000Z",
    "app": "docker",
    "deletionProtected": false,
    "purgeAt": null
  }
}
```

Errors: `snapshot_not_found`, `not_ready`, `action_pending`, `invalid_state`, `snapshots_unavailable`.

### GET /servers/:id/snapshot-schedule

Show the automatic snapshot schedule. The server's automatic snapshot schedule, or null when automatic snapshots are off. nextRunAt is when the next one starts (within 30 minutes after the hour); lastStatus is running, ok, failed or skipped, with the reason in lastError.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshot-schedule"
```

```json
{
  "schedule": {
    "frequency": "daily",
    "hour": 3,
    "weekday": null,
    "timezone": "Europe/Berlin",
    "keep": 3,
    "nextRunAt": "2026-10-22T01:17:00.000Z",
    "lastRunAt": "2026-10-21T01:17:00.000Z",
    "lastStatus": "ok",
    "lastError": null
  }
}
```

### PUT /servers/:id/snapshot-schedule

Set the automatic snapshot schedule. Turns automatic snapshots on, or changes the schedule. frequency: daily or weekly; hour: 0 to 23 in timezone (an IANA name, default UTC); weekday (weekly only): 0 to 6 from Sunday, or sun to sat; keep: how many automatic snapshots to keep, 1 to 4 (default 3). Each new one replaces the oldest automatic one. They don't count toward the 3 you take yourself. Like any snapshot, each pauses the server for about a minute. A suspended server, or one in rescue, is skipped until the next run.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"frequency":"daily","hour":3,"timezone":"Europe/Berlin","keep":3}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshot-schedule"
```

```json
{
  "schedule": {
    "frequency": "daily",
    "hour": 3,
    "weekday": null,
    "timezone": "Europe/Berlin",
    "keep": 3,
    "nextRunAt": "2026-10-22T01:17:00.000Z",
    "lastRunAt": "2026-10-21T01:17:00.000Z",
    "lastStatus": "ok",
    "lastError": null
  }
}
```

Errors: `invalid_request`, `invalid_schedule`.

### DELETE /servers/:id/snapshot-schedule

Turn automatic snapshots off. Turns automatic snapshots off. The automatic snapshots already taken are kept until you delete them. No body.

Scope: write. Success: 200.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/snapshot-schedule"
```

```json
{
  "schedule": null
}
```

## Backups

### GET /servers/:id/backups

Show backups. The server's off-site backups: whether the add-on is on (and when it ends, if you turned it off), its price for this plan, the daily hour and time zone, the retention (keepDaily, keepWeekly), the next and last run (lastStatus ok, failed or skipped, with the reason in lastError), the stored size after deduplication and compression, what is queued or running, and the backups, oldest first (sizeBytes: the disk's size; addedBytes: the new data that backup stored). runs: the 10 latest backups, restores and deletes.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups"
```

```json
{
  "status": {
    "enabled": true,
    "endsAt": null,
    "price": {
      "monthlyCents": 260,
      "yearlyCents": 2600
    },
    "hour": 3,
    "timezone": "Europe/Berlin",
    "keepDaily": 7,
    "keepWeekly": 4,
    "nextRunAt": "2026-10-22T01:14:00.000Z",
    "lastRunAt": "2026-10-21T01:14:00.000Z",
    "lastStatus": "ok",
    "lastError": null,
    "lastSuccessAt": "2026-10-21T01:14:00.000Z",
    "activeRun": null,
    "storedBytes": 6210000000,
    "purgeAt": null,
    "manualLeft": 3
  },
  "backups": [
    {
      "id": "bkp_q4m2x7abcdefgh3k",
      "state": "ready",
      "trigger": "scheduled",
      "takenAt": "2026-10-21T01:16:31.000Z",
      "sizeBytes": 85899345920,
      "addedBytes": 412000000
    }
  ],
  "runs": [
    {
      "id": "brun_x7q2m4abcdefgh3m",
      "kind": "backup",
      "state": "done",
      "serverId": "srv_k3j5m2abcdefgh2q",
      "sourceServerId": "srv_k3j5m2abcdefgh2q",
      "backupId": "bkp_q4m2x7abcdefgh3k",
      "error": null,
      "errorText": null,
      "createdAt": "2026-10-21T01:14:00.000Z",
      "startedAt": "2026-10-21T01:14:02.000Z",
      "finishedAt": "2026-10-21T01:19:40.000Z"
    }
  ]
}
```

Errors: `backups_unavailable`.

### POST /servers/:id/backups/enable

Turn backups on. Adds the backup add-on (by default 20% of the plan's price): an invoice for the rest of the current period, paid from your credit first. enabled is true once it is paid; otherwise pay it at payUrl. Turning it on again before a scheduled turn-off takes effect cancels the turn-off at no charge. No body.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups/enable"
```

```json
{
  "enabled": false,
  "invoiceId": "inv_m2q7x4abcdefgh2s",
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_m2q7x4abcdefgh2s"
}
```

Errors: `backups_unavailable`, `backups_not_on_sale`, `addon_change_refused`.

### POST /servers/:id/backups/disable

Turn backups off. Turns the add-on off from the next renewal (endsAt). Backups keep running until then; after it they are kept 14 days, then deleted. No body.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups/disable"
```

```json
{
  "endsAt": "2026-11-09T12:00:00.000Z"
}
```

Errors: `backups_unavailable`, `backups_off`, `addon_change_refused`.

### PUT /servers/:id/backups/settings

Set the backup schedule. The daily hour (0 to 23) in timezone (an IANA name), and how many backups to keep: keepDaily (1 to 14, the newest of each day) and keepWeekly (0 to 8, the newest of each week after those). Fields left out keep their value. A run starts within 30 minutes after the hour.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"hour":2,"timezone":"Europe/Berlin","keepDaily":14,"keepWeekly":4}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups/settings"
```

```json
{
  "status": {
    "enabled": true,
    "endsAt": null,
    "price": {
      "monthlyCents": 260,
      "yearlyCents": 2600
    },
    "hour": 2,
    "timezone": "Europe/Berlin",
    "keepDaily": 14,
    "keepWeekly": 4,
    "nextRunAt": "2026-10-22T01:14:00.000Z",
    "lastRunAt": "2026-10-21T01:14:00.000Z",
    "lastStatus": "ok",
    "lastError": null,
    "lastSuccessAt": "2026-10-21T01:14:00.000Z",
    "activeRun": null,
    "storedBytes": 6210000000,
    "purgeAt": null,
    "manualLeft": 3
  },
  "backups": [
    {
      "id": "bkp_q4m2x7abcdefgh3k",
      "state": "ready",
      "trigger": "scheduled",
      "takenAt": "2026-10-21T01:16:31.000Z",
      "sizeBytes": 85899345920,
      "addedBytes": 412000000
    }
  ],
  "runs": [
    {
      "id": "brun_x7q2m4abcdefgh3m",
      "kind": "backup",
      "state": "done",
      "serverId": "srv_k3j5m2abcdefgh2q",
      "sourceServerId": "srv_k3j5m2abcdefgh2q",
      "backupId": "bkp_q4m2x7abcdefgh3k",
      "error": null,
      "errorText": null,
      "createdAt": "2026-10-21T01:14:00.000Z",
      "startedAt": "2026-10-21T01:14:02.000Z",
      "finishedAt": "2026-10-21T01:19:40.000Z"
    }
  ]
}
```

Errors: `backups_unavailable`, `invalid_request`, `invalid_hour`, `invalid_timezone`, `invalid_keep_daily`, `invalid_keep_weekly`.

### POST /servers/:id/backups

Back up now. Queues a backup now (3 a day by hand; the schedule's own don't count). It runs when the server's host is free: one backup at a time per host. While it runs the server keeps running and its other actions wait. No body.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups"
```

```json
{
  "run": {
    "id": "brun_x7q2m4abcdefgh3m",
    "kind": "backup",
    "state": "queued",
    "serverId": "srv_k3j5m2abcdefgh2q",
    "sourceServerId": "srv_k3j5m2abcdefgh2q",
    "backupId": "bkp_t2m7q4abcdefgh3n",
    "error": null,
    "errorText": null,
    "createdAt": "2026-10-21T01:14:00.000Z",
    "startedAt": null,
    "finishedAt": null
  }
}
```

Errors: `backups_unavailable`, `backups_off`, `backup_busy`, `invalid_state`, `rate_limited`.

### POST /servers/:id/backups/:backupId/restore

Restore a backup. Restores the backup over this server: it is shut down, its disk replaced by the backup and started again. Everything written since the backup is lost. To restore into a new server instead, order one with restoreFrom (POST /servers). No body.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups/bkp_q4m2x7abcdefgh3k/restore"
```

```json
{
  "run": {
    "id": "brun_x7q2m4abcdefgh3m",
    "kind": "restore",
    "state": "queued",
    "serverId": "srv_k3j5m2abcdefgh2q",
    "sourceServerId": "srv_k3j5m2abcdefgh2q",
    "backupId": "bkp_q4m2x7abcdefgh3k",
    "error": null,
    "errorText": null,
    "createdAt": "2026-10-21T01:14:00.000Z",
    "startedAt": null,
    "finishedAt": null
  }
}
```

Errors: `backups_unavailable`, `backup_not_found`, `backup_busy`, `invalid_state`, `too_large`, `held`.

### DELETE /servers/:id/backups/:backupId

Delete a backup. Deletes one backup from off-site storage for good. The others are kept.

Scope: write. Success: 202.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/backups/bkp_q4m2x7abcdefgh3k"
```

```json
{
  "run": {
    "id": "brun_x7q2m4abcdefgh3m",
    "kind": "delete",
    "state": "queued",
    "serverId": "srv_k3j5m2abcdefgh2q",
    "sourceServerId": "srv_k3j5m2abcdefgh2q",
    "backupId": "bkp_q4m2x7abcdefgh3k",
    "error": null,
    "errorText": null,
    "createdAt": "2026-10-21T01:14:00.000Z",
    "startedAt": null,
    "finishedAt": null
  }
}
```

Errors: `backups_unavailable`, `backup_not_found`, `backup_busy`, `held`.

## Rescue

### GET /servers/:id/rescue

Show rescue mode. The server's rescue: state is off, entering, on, leaving or stuck (switching failed part way: leave rescue to boot the server's own disk). startedAt and expiresAt while it isn't off; available is false while the server is suspended or rescue isn't set up.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/rescue"
```

```json
{
  "rescue": {
    "state": "on",
    "startedAt": "2026-10-21T09:12:00.000Z",
    "expiresAt": "2026-10-22T09:12:00.000Z",
    "available": true
  }
}
```

### POST /servers/:id/rescue

Boot into rescue. Shuts the server down and boots a rescue Linux system, with the server's disk attached as /dev/vdb (not mounted), its IP addresses and its firewall. rootPassword is a one-time root password for the console and SSH: it is in this answer only, so keep it. Your account's SSH keys work too. Rescue ends by itself after 24 hours. A suspended server can't use rescue. 10 an hour. No body.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/rescue"
```

```json
{
  "rescue": {
    "state": "entering",
    "startedAt": "2026-10-21T09:12:00.000Z",
    "expiresAt": "2026-10-22T09:12:00.000Z",
    "available": true
  },
  "rootPassword": "Kp7mQ-x3RtN-9wZcA-hG2vE",
  "server": {
    "id": "srv_k3j5m2abcdefgh2q",
    "hostname": "web-1",
    "plan": "standard",
    "image": "debian-12",
    "state": "running",
    "pendingAction": "rescue",
    "ipv4": "203.0.113.10",
    "ipv6": "2001:db8:10::2",
    "vcpus": 2,
    "memoryMiB": 4096,
    "diskGiB": 80,
    "createdAt": "2026-10-09T12:00:00.000Z",
    "stateSince": "2026-10-09T12:01:00.000Z",
    "app": "docker",
    "deletionProtected": false,
    "purgeAt": null
  }
}
```

Errors: `action_pending`, `invalid_state`, `rescue_unavailable`, `rate_limited`, `rescue_not_queued`.

### DELETE /servers/:id/rescue

Leave rescue. Boots the server from its own disk again. The rescue system and anything changed on it (not on your disk) is thrown away. No body.

Scope: write. Success: 202.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/rescue"
```

```json
{
  "rescue": {
    "state": "leaving",
    "startedAt": "2026-10-21T09:12:00.000Z",
    "expiresAt": "2026-10-22T09:12:00.000Z",
    "available": true
  },
  "server": {
    "id": "srv_k3j5m2abcdefgh2q",
    "hostname": "web-1",
    "plan": "standard",
    "image": "debian-12",
    "state": "rescue",
    "pendingAction": "rescue_exit",
    "ipv4": "203.0.113.10",
    "ipv6": "2001:db8:10::2",
    "vcpus": 2,
    "memoryMiB": 4096,
    "diskGiB": 80,
    "createdAt": "2026-10-09T12:00:00.000Z",
    "stateSince": "2026-10-09T12:01:00.000Z",
    "app": "docker",
    "deletionProtected": false,
    "purgeAt": null
  }
}
```

Errors: `action_pending`, `not_in_rescue`, `invalid_state`, `rescue_not_queued`.

## Reverse DNS

### GET /servers/:id/rdns

List reverse DNS. One entry per address of the server, with its PTR name and state (pending, active, failed, removing, or null when none is set).

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/rdns"
```

```json
{
  "data": [
    {
      "address": "203.0.113.10",
      "family": 4,
      "hostname": "mail.example.com",
      "state": "active",
      "errorCode": null
    },
    {
      "address": "2001:db8:10::2",
      "family": 6,
      "hostname": null,
      "state": null,
      "errorCode": null
    }
  ]
}
```

### PUT /servers/:id/rdns/:address

Set reverse DNS. Asks for a PTR name on one of the server's addresses. The name must already resolve to that address (an A record for IPv4, AAAA for IPv6); the lookup gives up after 3 seconds. The request is pending until it is applied. Ten changes an hour.

Scope: write. Success: 202.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"hostname":"mail.example.com"}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/rdns/203.0.113.10"
```

```json
{
  "address": "203.0.113.10",
  "family": 4,
  "hostname": "mail.example.com",
  "state": "pending",
  "errorCode": null
}
```

Errors: `invalid_request`, `address_not_found`, `invalid_hostname`, `no_record`, `forward_mismatch`, `dns_timeout`, `dns_error`, `rate_limited`, `invalid_state`.

### DELETE /servers/:id/rdns/:address

Clear reverse DNS. Takes the PTR name off the address. A name that was never applied just goes; an applied one shows removing until it is reset. Counts toward the ten changes an hour.

Scope: write. Success: 202.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/rdns/203.0.113.10"
```

```json
{
  "address": "203.0.113.10",
  "family": 4,
  "hostname": "mail.example.com",
  "state": "removing",
  "errorCode": null
}
```

Errors: `address_not_found`, `rate_limited`, `invalid_state`.

## Invoices

### GET /invoices

List invoices. Your invoices, newest first, a page at a time. Amounts are integer cents. limit sets the page size: 50 unless you say, 200 at most. next is null on the last page; otherwise ask for the following page with ?before= and that value, like /invoices?limit=50&before=VPS9-000042.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/invoices"
```

```json
{
  "data": [
    {
      "id": "inv_q4m7x2abcdefgh2t",
      "number": "VPS9-000042",
      "kind": "renewal",
      "status": "open",
      "currency": "USD",
      "subtotalCents": 1200,
      "creditAppliedCents": 0,
      "totalCents": 1200,
      "issuedAt": "2026-11-02T12:00:00.000Z",
      "dueAt": "2026-11-09T12:00:00.000Z",
      "paidAt": null,
      "voidedAt": null,
      "serverId": "srv_k3j5m2abcdefgh2q",
      "term": "month"
    }
  ],
  "next": null
}
```

Errors: `invalid_request`.

### GET /invoices/:id

Show an invoice. One invoice with its lines.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/invoices/inv_q4m7x2abcdefgh2t"
```

```json
{
  "id": "inv_q4m7x2abcdefgh2t",
  "number": "VPS9-000042",
  "kind": "renewal",
  "status": "open",
  "currency": "USD",
  "subtotalCents": 1200,
  "creditAppliedCents": 0,
  "totalCents": 1200,
  "issuedAt": "2026-11-02T12:00:00.000Z",
  "dueAt": "2026-11-09T12:00:00.000Z",
  "paidAt": null,
  "voidedAt": null,
  "serverId": "srv_k3j5m2abcdefgh2q",
  "term": "month",
  "lines": [
    {
      "description": "Standard server web-1, 2026-11-09 to 2026-12-09",
      "periodStart": "2026-11-09T12:00:00.000Z",
      "periodEnd": "2026-12-09T12:00:00.000Z",
      "amountCents": 1200,
      "serverId": "srv_k3j5m2abcdefgh2q",
      "addon": null,
      "quantity": null
    },
    {
      "description": "Extra RAM +2 GB, web-1, 2026-11-09 to 2026-12-09",
      "periodStart": "2026-11-09T12:00:00.000Z",
      "periodEnd": "2026-12-09T12:00:00.000Z",
      "amountCents": 700,
      "serverId": "srv_k3j5m2abcdefgh2q",
      "addon": "ram",
      "quantity": 2
    }
  ]
}
```

## Billing

### GET /billing

Balance and upcoming charges. Your account credit, what is due in the next 30 days (open invoices plus renewals not invoiced yet) and how much of it credit covers, each billed server's next charge, your open invoices, and what switching a monthly server to yearly would save. upcoming[].state is estimated (no renewal invoice yet: today's prices for the plan and each add-on), invoiced (its renewal is open), paid, or cancelling (deleted at dueAt; nothing more is charged). Credit pays each new invoice first.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/billing"
```

```json
{
  "currency": "USD",
  "creditCents": 1500,
  "next30Days": {
    "totalCents": 1899,
    "coveredByCreditCents": 1500,
    "leftToPayCents": 399
  },
  "upcoming": [
    {
      "serverId": "srv_k3j5m2abcdefgh2q",
      "hostname": "web-1",
      "plan": "Standard",
      "term": "month",
      "nextTerm": "month",
      "dueAt": "2026-11-09T12:00:00.000Z",
      "state": "estimated",
      "amountCents": 1899,
      "lines": [
        {
          "description": "Standard server web-1, 2026-11-09 to 2026-12-09",
          "amountCents": 1299
        },
        {
          "description": "Extra RAM +2 GB, web-1, 2026-11-09 to 2026-12-09",
          "amountCents": 700
        }
      ],
      "invoiceId": null
    }
  ],
  "openInvoices": [],
  "yearlySavings": [
    {
      "serverId": "srv_k3j5m2abcdefgh2q",
      "hostname": "web-1",
      "monthlyCents": 1999,
      "twelveMonthsCents": 23988,
      "yearlyCents": 20900,
      "saveCents": 3088
    }
  ],
  "topup": {
    "available": true,
    "minCents": 500,
    "maxCents": 100000
  }
}
```

### GET /billing/credit

Credit history. Every change to your account credit, newest first, with the balance after it. reason is topup, overpayment (paid over an invoice, or after it closed), refund (to credit), manual (from VPSNine staff), applied (negative: paid an invoice; positive: came back from a voided one) or reversal (a top-up taken back after its payment was reversed). limit: 100 unless you say, 500 at most; more is true when older movements were left out.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/billing/credit"
```

```json
{
  "creditCents": 1500,
  "data": [
    {
      "at": "2026-11-02T12:00:00.000Z",
      "reason": "applied",
      "description": "Paid invoice VPS9-000042",
      "amountCents": -1000,
      "balanceCents": 1500,
      "invoiceId": "inv_q4m7x2abcdefgh2t"
    },
    {
      "at": "2026-10-20T09:30:00.000Z",
      "reason": "topup",
      "description": "Top-up",
      "amountCents": 2500,
      "balanceCents": 2500,
      "invoiceId": "inv_t2m4q7abcdefgh2v"
    }
  ],
  "more": false
}
```

Errors: `invalid_request`.

### POST /billing/topups

Add funds (a credit top-up). Buys account credit: makes a top-up invoice for amountCents (from $5.00 to $1,000.00 unless the limits in GET /billing say otherwise) and a checkout for it. method is btcpay or paypal; left out, the first available. Open checkoutUrl to pay (null when the provider did not answer: pay from payUrl instead). Once paid, the amount is added to your credit, which pays open invoices and renewals first. At most 3 unpaid top-ups at a time (they lapse after 7 days), 10 tries an hour. Needs a confirmed email.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"method":"btcpay","amountCents":2500}' \
  "$API/billing/topups"
```

```json
{
  "invoice": {
    "id": "inv_q4m7x2abcdefgh2t",
    "number": "VPS9-000042",
    "kind": "topup",
    "status": "open",
    "currency": "USD",
    "subtotalCents": 2500,
    "creditAppliedCents": 0,
    "totalCents": 2500,
    "issuedAt": "2026-10-20T09:30:00.000Z",
    "dueAt": "2026-10-20T09:30:00.000Z",
    "paidAt": null,
    "voidedAt": null,
    "serverId": null,
    "term": "month"
  },
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_q4m7x2abcdefgh2t",
  "checkoutUrl": "https://btcpay.node4.io/i/AbC123xYz",
  "provider": "btcpay"
}
```

Errors: `invalid_amount`, `invalid_request`, `email_unverified`, `topups_closed`, `provider_unavailable`, `too_many_open_topups`, `rate_limited`.

### GET /billing/profile

Show billing details. Who your invoices are made out to. Every field is a string, "" when not given; country is an ISO 3166 code like GB. updatedAt is null before you first save.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/billing/profile"
```

```json
{
  "name": "Ada Lovelace",
  "company": "Analytical Engines Ltd",
  "address1": "12 Byron Street",
  "address2": "",
  "city": "London",
  "region": "",
  "postalCode": "W1 2AB",
  "country": "GB",
  "taxId": "GB123456789",
  "updatedAt": "2026-10-20T09:30:00.000Z"
}
```

### PUT /billing/profile

Replace billing details. Sets the whole profile: a field left out is cleared. Fields: name, company, address1, address2, city, region, postalCode (one line of text each), country (an ISO 3166 code or "") and taxId (a VAT number or tax ID; its format is checked for your country and it is stored normalized, like GB123456789; it is not verified with any registry). Invoices issued from now on carry these details; invoices already issued keep the ones they were issued with.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Ada Lovelace","company":"Analytical Engines Ltd","address1":"12 Byron Street","city":"London","postalCode":"W1 2AB","country":"GB","taxId":"GB 123 456 789"}' \
  "$API/billing/profile"
```

```json
{
  "name": "Ada Lovelace",
  "company": "Analytical Engines Ltd",
  "address1": "12 Byron Street",
  "address2": "",
  "city": "London",
  "region": "",
  "postalCode": "W1 2AB",
  "country": "GB",
  "taxId": "GB123456789",
  "updatedAt": "2026-10-20T09:30:00.000Z"
}
```

Errors: `invalid_request`, `invalid_profile`, `invalid_country`, `invalid_tax_id`, `rate_limited`.

### GET /gift-cards

List gift cards. The gift cards your account bought, newest first (100 at most). state is pending (unpaid), active (paid, not redeemed yet), redeemed, void or lapsed (never paid). The code itself is never in the API: it is on the card's page in the panel and in the recipient's email; hint is its last four characters.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/gift-cards"
```

```json
{
  "data": [
    {
      "id": "gft_r4m7x2abcdefgh2w",
      "amountCents": 2500,
      "state": "active",
      "hint": "R3TB",
      "recipientEmail": "sam@example.com",
      "recipientName": "Sam",
      "fromName": "Alex",
      "message": "Happy birthday! Build something fun.",
      "invoiceId": "inv_q4m7x2abcdefgh2t",
      "createdAt": "2026-10-21T09:30:00.000Z",
      "paidAt": "2026-10-21T09:41:00.000Z",
      "sentAt": "2026-10-21T09:41:00.000Z",
      "redeemedAt": null
    }
  ]
}
```

### POST /gift-cards

Buy a gift card. Makes a gift card and the invoice for it, with a checkout. amountCents is one of 1000, 2500, 5000, 10000 or 20000; recipientEmail is where the card is emailed once the invoice is paid; recipientName, fromName (up to 80 characters each) and message (up to 500) are optional and appear on the card. method is btcpay or paypal; left out, the first available. Account credit can't pay a gift card. At most 3 unpaid gift cards at a time (they lapse after 7 days), 10 tries an hour. Needs a confirmed email.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"amountCents":2500,"recipientEmail":"sam@example.com","recipientName":"Sam","fromName":"Alex","message":"Happy birthday! Build something fun.","method":"btcpay"}' \
  "$API/gift-cards"
```

```json
{
  "giftCard": {
    "id": "gft_r4m7x2abcdefgh2w",
    "amountCents": 2500,
    "state": "pending",
    "hint": "R3TB",
    "recipientEmail": "sam@example.com",
    "recipientName": "Sam",
    "fromName": "Alex",
    "message": "Happy birthday! Build something fun.",
    "invoiceId": "inv_q4m7x2abcdefgh2t",
    "createdAt": "2026-10-21T09:30:00.000Z",
    "paidAt": null,
    "sentAt": null,
    "redeemedAt": null
  },
  "invoice": {
    "id": "inv_q4m7x2abcdefgh2t",
    "number": "VPS9-000042",
    "kind": "gift",
    "status": "open",
    "currency": "USD",
    "subtotalCents": 2500,
    "creditAppliedCents": 0,
    "totalCents": 2500,
    "issuedAt": "2026-10-21T09:30:00.000Z",
    "dueAt": "2026-10-21T09:30:00.000Z",
    "paidAt": null,
    "voidedAt": null,
    "serverId": null,
    "term": "month"
  },
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_q4m7x2abcdefgh2t",
  "checkoutUrl": "https://btcpay.node4.io/i/AbC123xYz",
  "provider": "btcpay"
}
```

Errors: `invalid_amount`, `invalid_email`, `invalid_request`, `email_unverified`, `gift_cards_closed`, `too_many_open_gift_cards`, `rate_limited`.

### POST /gift-cards/redeem

Redeem a gift card. Adds a gift card's whole amount to your account credit, which then pays your open invoices first. code is as printed (GIFT-XXXX-XXXX-XXXX-XXXX), in any case, with or without dashes. A code works once. 10 wrong codes an hour per account (and per address); a right one doesn't count. Needs a confirmed email.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"code":"GIFT-7K2M-Q9XD-4HNP-R3TB"}' \
  "$API/gift-cards/redeem"
```

```json
{
  "redeemedCents": 2500,
  "creditCents": 2500
}
```

Errors: `invalid_request`, `invalid_gift_code`, `gift_card_not_found`, `gift_card_not_paid`, `gift_card_redeemed`, `gift_card_void`, `email_unverified`, `rate_limited`.

## Notifications

### GET /notifications

List notifications. Your notifications, newest first, 50 a page (page=2 for the next; ten pages at most), the unread count, and whether there are more. unread=true lists only unread ones. Kept 90 days, 500 at most.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/notifications"
```

```json
{
  "data": [
    {
      "id": "ntf_r2t7k4abcdefgh3a",
      "type": "alert.triggered",
      "category": "alerts",
      "title": "web-1: CPU usage above 90% for 10 minutes",
      "body": "Alert triggered: CPU usage above 90% for 10 minutes. Reading: 97%.",
      "link": "/servers/srv_k3j5m2abcdefgh2q/alerts",
      "createdAt": "2026-10-21T09:14:00.000Z",
      "read": false
    }
  ],
  "unread": 1,
  "page": 1,
  "more": false
}
```

### POST /notifications/:id/read

Mark one read. Marks the notification read and answers with it.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/notifications/ntf_r2t7k4abcdefgh3a/read"
```

```json
{
  "id": "ntf_r2t7k4abcdefgh3a",
  "type": "alert.triggered",
  "category": "alerts",
  "title": "web-1: CPU usage above 90% for 10 minutes",
  "body": "Alert triggered: CPU usage above 90% for 10 minutes. Reading: 97%.",
  "link": "/servers/srv_k3j5m2abcdefgh2q/alerts",
  "createdAt": "2026-10-21T09:14:00.000Z",
  "read": true
}
```

### POST /notifications/read-all

Mark all read. Marks every notification read; marked is how many were unread.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/notifications/read-all"
```

```json
{
  "marked": 3
}
```

## Alert rules

### GET /servers/:id/alerts

List a server's alert rules. The server's resource alert rules, oldest first, with their state (ok or firing) and when they last fired.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/alerts"
```

```json
{
  "data": [
    {
      "id": "alr_m4q2x7abcdefgh3b",
      "metric": "cpu",
      "threshold": 90,
      "durationMin": 10,
      "email": true,
      "enabled": true,
      "state": "ok",
      "text": "CPU usage above 90% for 10 minutes",
      "firedAt": null,
      "resolvedAt": null,
      "lastValue": null,
      "serverId": "srv_k3j5m2abcdefgh2q",
      "createdAt": "2026-10-21T09:00:00.000Z"
    }
  ]
}
```

### POST /servers/:id/alerts

Add an alert rule. metric: cpu, memory, transfer (this month's traffic against the allowance) or down (the host hasn't reported the server, or it used no CPU, for durationMin). threshold: a whole percentage from 1 to 100 (not for down). durationMin: 5, 10, 15, 30 or 60 (not for transfer). email: also email you (default true). Checked every minute; one notice when it fires and one when it clears, then quiet for 30 minutes. 10 rules per server.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"metric":"cpu","threshold":90,"durationMin":10,"email":true}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/alerts"
```

```json
{
  "id": "alr_m4q2x7abcdefgh3b",
  "metric": "cpu",
  "threshold": 90,
  "durationMin": 10,
  "email": true,
  "enabled": true,
  "state": "ok",
  "text": "CPU usage above 90% for 10 minutes",
  "firedAt": null,
  "resolvedAt": null,
  "lastValue": null,
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-21T09:00:00.000Z"
}
```

Errors: `invalid_request`, `invalid_metric`, `invalid_threshold`, `invalid_duration`, `too_many_alert_rules`.

### PUT /servers/:id/alerts/:ruleId

Change an alert rule. Send any of enabled, email, threshold and durationMin. A change ends an incident in progress quietly; the rule is judged afresh from the next minute. The metric can't change.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled":false,"email":true}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/alerts/alr_m4q2x7abcdefgh3b"
```

```json
{
  "id": "alr_m4q2x7abcdefgh3b",
  "metric": "cpu",
  "threshold": 90,
  "durationMin": 10,
  "email": true,
  "enabled": false,
  "state": "ok",
  "text": "CPU usage above 90% for 10 minutes",
  "firedAt": null,
  "resolvedAt": null,
  "lastValue": null,
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-21T09:00:00.000Z"
}
```

Errors: `invalid_request`, `invalid_threshold`, `invalid_duration`.

### DELETE /servers/:id/alerts/:ruleId

Delete an alert rule. Deletes the rule. Nothing more is sent for it.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/alerts/alr_m4q2x7abcdefgh3b"
```

## Webhooks

### GET /webhooks

List webhook endpoints. Your endpoints, oldest first. A secret is never listed.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks"
```

```json
{
  "data": [
    {
      "id": "whk_x7m2q4abcdefgh3c",
      "url": "https://hooks.example.com/vpsnine",
      "description": "deploy bot",
      "events": [
        "server.ready",
        "alert.triggered",
        "alert.resolved"
      ],
      "enabled": true,
      "disabledReason": null,
      "failureStreak": 0,
      "lastSuccessAt": "2026-10-21T09:14:01.000Z",
      "lastFailureAt": null,
      "createdAt": "2026-10-20T08:00:00.000Z",
      "rotatedUntil": null
    }
  ]
}
```

### POST /webhooks

Add a webhook endpoint. url: https only, on a public address (private, loopback, link-local and metadata addresses are refused, and checked again before every delivery). events: the types to send. The answer carries the signing secret: the only time it is shown.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/vpsnine","description":"deploy bot","events":["server.ready","alert.triggered","alert.resolved"]}' \
  "$API/webhooks"
```

```json
{
  "id": "whk_x7m2q4abcdefgh3c",
  "url": "https://hooks.example.com/vpsnine",
  "description": "deploy bot",
  "events": [
    "server.ready",
    "alert.triggered",
    "alert.resolved"
  ],
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "rotatedUntil": null,
  "secret": "whsec_shown-once-keep-it-somewhere-safe"
}
```

Errors: `invalid_request`, `invalid_url`, `invalid_description`, `invalid_events`, `too_many_webhooks`.

### GET /webhooks/:id

Show a webhook endpoint. One endpoint, without its secret.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c"
```

```json
{
  "id": "whk_x7m2q4abcdefgh3c",
  "url": "https://hooks.example.com/vpsnine",
  "description": "deploy bot",
  "events": [
    "server.ready",
    "alert.triggered",
    "alert.resolved"
  ],
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": "2026-10-21T09:14:01.000Z",
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "rotatedUntil": null
}
```

### PUT /webhooks/:id

Change a webhook endpoint. Send any of description, events, url and enabled. Turning an endpoint off fails what is still waiting; turning it on clears its failures.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"description":"deploy bot (staging)","events":["server.ready"],"enabled":true}' \
  "$API/webhooks/whk_x7m2q4abcdefgh3c"
```

```json
{
  "id": "whk_x7m2q4abcdefgh3c",
  "url": "https://hooks.example.com/vpsnine",
  "description": "deploy bot (staging)",
  "events": [
    "server.ready"
  ],
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": "2026-10-21T09:14:01.000Z",
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "rotatedUntil": null
}
```

Errors: `invalid_request`, `invalid_url`, `invalid_description`, `invalid_events`.

### DELETE /webhooks/:id

Delete a webhook endpoint. Deletes the endpoint and its delivery log. Nothing more is sent to it.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c"
```

### POST /webhooks/:id/rotate-secret

Rotate the signing secret. A new secret, in the answer only. For 24 hours each delivery carries a second v1 signature made with the old one, so your receiver can switch over without dropping events.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c/rotate-secret"
```

```json
{
  "id": "whk_x7m2q4abcdefgh3c",
  "url": "https://hooks.example.com/vpsnine",
  "description": "deploy bot",
  "events": [
    "server.ready",
    "alert.triggered",
    "alert.resolved"
  ],
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": "2026-10-21T09:14:01.000Z",
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "rotatedUntil": "2026-10-22T09:20:00.000Z",
  "secret": "whsec_shown-once-keep-it-somewhere-safe"
}
```

### POST /webhooks/:id/test

Send a test event. Queues a ping event to the endpoint now (one attempt, whatever it subscribes to); read the delivery log for the answer. 5 a minute.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c/test"
```

```json
{
  "id": "whd_q2x7m4abcdefgh3d",
  "eventId": "evt_t4k2r7abcdefgh3e",
  "eventType": "ping",
  "state": "pending",
  "attempts": 0,
  "status": null,
  "error": null,
  "latencyMs": null,
  "response": null,
  "payload": "{\"id\":\"evt_t4k2r7abcdefgh3e\",\"type\":\"ping\",\"createdAt\":\"2026-10-21T09:20:00.000Z\",\"data\":{\"message\":\"A test event from VPSNine. Your endpoint is set up.\",\"endpoint\":{\"id\":\"whk_x7m2q4abcdefgh3c\"}}}",
  "createdAt": "2026-10-21T09:20:00.000Z",
  "nextAttemptAt": "2026-10-21T09:20:00.000Z"
}
```

Errors: `endpoint_disabled`, `rate_limited`.

### GET /webhooks/:id/deliveries

List deliveries. The endpoint's last 50 deliveries, newest first: state, attempts, the last answer's status, latency and first 1 KiB, and the exact body sent.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c/deliveries"
```

```json
{
  "data": [
    {
      "id": "whd_q2x7m4abcdefgh3d",
      "eventId": "evt_t4k2r7abcdefgh3e",
      "eventType": "alert.triggered",
      "state": "succeeded",
      "attempts": 1,
      "status": 200,
      "error": null,
      "latencyMs": 182,
      "response": "ok",
      "payload": "{\"id\":\"evt_t4k2r7abcdefgh3e\",\"type\":\"alert.triggered\",\"createdAt\":\"2026-10-21T09:14:00.000Z\",\"data\":{\"rule\":{\"id\":\"alr_m4q2x7abcdefgh3b\",\"metric\":\"cpu\",\"threshold\":90,\"durationMin\":10},\"server\":{\"id\":\"srv_k3j5m2abcdefgh2q\",\"hostname\":\"web-1\"},\"value\":97,\"state\":\"firing\"}}",
      "createdAt": "2026-10-21T09:14:00.000Z",
      "nextAttemptAt": null
    }
  ]
}
```

### POST /webhooks/:id/deliveries/:deliveryId/redeliver

Redeliver. Sends the same event (same id and body) again as a new delivery. 5 a minute.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/webhooks/whk_x7m2q4abcdefgh3c/deliveries/whd_q2x7m4abcdefgh3d/redeliver"
```

```json
{
  "id": "whd_m7q2x4abcdefgh3f",
  "eventId": "evt_t4k2r7abcdefgh3e",
  "eventType": "alert.triggered",
  "state": "pending",
  "attempts": 0,
  "status": null,
  "error": null,
  "latencyMs": null,
  "response": null,
  "payload": "{\"id\":\"evt_t4k2r7abcdefgh3e\",\"type\":\"alert.triggered\",\"createdAt\":\"2026-10-21T09:14:00.000Z\",\"data\":{\"rule\":{\"id\":\"alr_m4q2x7abcdefgh3b\",\"metric\":\"cpu\",\"threshold\":90,\"durationMin\":10},\"server\":{\"id\":\"srv_k3j5m2abcdefgh2q\",\"hostname\":\"web-1\"},\"value\":97,\"state\":\"firing\"}}",
  "createdAt": "2026-10-21T09:14:00.000Z",
  "nextAttemptAt": "2026-10-21T09:25:00.000Z"
}
```

Errors: `endpoint_disabled`, `rate_limited`.

## Monitors

### GET /monitors

List monitors. Your monitors, oldest first, each with uptime over 24 hours and 30 days, the average response time over 24 hours and hourly averages (spark). limits: how many you may have, and the shortest interval.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors"
```

```json
{
  "data": [
    {
      "id": "mon_k4q2m7abcdefgh4a",
      "name": "Shop",
      "kind": "http",
      "targetText": "https://shop.example.com/",
      "target": "https://shop.example.com/",
      "port": null,
      "intervalS": 60,
      "timeoutS": 10,
      "email": true,
      "state": "up",
      "stateSince": "2026-10-20T08:00:12.000Z",
      "lastCheckAt": "2026-10-21T09:14:03.000Z",
      "lastLatencyMs": 182,
      "lastStatus": 200,
      "lastError": null,
      "tlsExpiresAt": "2026-12-30T23:59:59.000Z",
      "pingUrl": null,
      "lastPingAt": null,
      "settings": {
        "method": "GET",
        "statusMin": 200,
        "statusMax": 399,
        "keyword": "Add to cart",
        "keywordAbsent": false,
        "followRedirects": true,
        "hasBody": false,
        "tlsWarnDays": 14,
        "dnsType": "A",
        "dnsExpect": null,
        "graceS": 300
      },
      "createdAt": "2026-10-20T08:00:00.000Z",
      "uptime24h": 100,
      "uptime30d": 99.95,
      "avgLatency24h": 176,
      "spark": [
        171,
        168,
        180,
        175,
        179,
        182
      ]
    }
  ],
  "limits": {
    "monitors": 15,
    "used": 1,
    "minIntervalS": 60,
    "statusPages": 3
  }
}
```

### POST /monitors

Add a monitor. kind: http, tcp, ping, dns or heartbeat; name. http: url, and optionally method (GET, HEAD, POST, PUT), statusMin and statusMax (default 200-399), keyword with keywordAbsent, followRedirects (default true), body (POST and PUT, at most 1 KiB), tlsWarnDays (0, 7, 14, 21 or 30). tcp: host and port. ping: host. dns: host, dnsType (A, AAAA, CNAME, MX, TXT, NS) and optionally dnsExpect. heartbeat: periodS (60, 300, 600, 900, 1800, 3600, 7200, 21600, 43200, 86400, 604800) and graceS (60, 300, 600, 1800, 3600, 7200); the answer carries its pingUrl. Others: intervalS, timeoutS (1 to 30), email (default true). The first check runs within seconds.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"http","name":"Shop","url":"https://shop.example.com/","keyword":"Add to cart","intervalS":60}' \
  "$API/monitors"
```

```json
{
  "id": "mon_k4q2m7abcdefgh4a",
  "name": "Shop",
  "kind": "http",
  "targetText": "https://shop.example.com/",
  "target": "https://shop.example.com/",
  "port": null,
  "intervalS": 60,
  "timeoutS": 10,
  "email": true,
  "state": "pending",
  "stateSince": "2026-10-20T08:00:12.000Z",
  "lastCheckAt": null,
  "lastLatencyMs": null,
  "lastStatus": null,
  "lastError": null,
  "tlsExpiresAt": null,
  "pingUrl": null,
  "lastPingAt": null,
  "settings": {
    "method": "GET",
    "statusMin": 200,
    "statusMax": 399,
    "keyword": "Add to cart",
    "keywordAbsent": false,
    "followRedirects": true,
    "hasBody": false,
    "tlsWarnDays": 14,
    "dnsType": "A",
    "dnsExpect": null,
    "graceS": 300
  },
  "createdAt": "2026-10-20T08:00:00.000Z"
}
```

Errors: `invalid_request`, `invalid_kind`, `invalid_name`, `invalid_url`, `invalid_host`, `invalid_port`, `invalid_interval_s`, `invalid_period_s`, `too_many_monitors`, `rate_limited`.

### GET /monitors/:id

Show a monitor. One monitor with its uptime over 24 hours, 7, 30 and 90 days, its response time (range=24h, 7d, 30d or 90d; average, slowest and the series, oldest first) and its last 90 days.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a"
```

```json
{
  "id": "mon_k4q2m7abcdefgh4a",
  "name": "Shop",
  "kind": "http",
  "targetText": "https://shop.example.com/",
  "target": "https://shop.example.com/",
  "port": null,
  "intervalS": 60,
  "timeoutS": 10,
  "email": true,
  "state": "up",
  "stateSince": "2026-10-20T08:00:12.000Z",
  "lastCheckAt": "2026-10-21T09:14:03.000Z",
  "lastLatencyMs": 182,
  "lastStatus": 200,
  "lastError": null,
  "tlsExpiresAt": "2026-12-30T23:59:59.000Z",
  "pingUrl": null,
  "lastPingAt": null,
  "settings": {
    "method": "GET",
    "statusMin": 200,
    "statusMax": 399,
    "keyword": "Add to cart",
    "keywordAbsent": false,
    "followRedirects": true,
    "hasBody": false,
    "tlsWarnDays": 14,
    "dnsType": "A",
    "dnsExpect": null,
    "graceS": 300
  },
  "createdAt": "2026-10-20T08:00:00.000Z",
  "uptime": {
    "24h": 100,
    "7d": 99.98,
    "30d": 99.95,
    "90d": 99.97
  },
  "responseTime": {
    "range": "24h",
    "averageMs": 176,
    "maxMs": 412,
    "series": [
      171,
      168,
      174,
      182
    ]
  },
  "days": [
    {
      "date": "2026-10-21",
      "uptime": 100,
      "level": "operational",
      "incidents": [],
      "maintenance": 0
    }
  ]
}
```

### PUT /monitors/:id

Change a monitor. Send any of the fields you can add a monitor with, except kind. A change to what is checked starts the monitor afresh (pending), and ends an outage in progress without a notice.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Shop (EU)","intervalS":120}' \
  "$API/monitors/mon_k4q2m7abcdefgh4a"
```

```json
{
  "id": "mon_k4q2m7abcdefgh4a",
  "name": "Shop (EU)",
  "kind": "http",
  "targetText": "https://shop.example.com/",
  "target": "https://shop.example.com/",
  "port": null,
  "intervalS": 120,
  "timeoutS": 10,
  "email": true,
  "state": "pending",
  "stateSince": "2026-10-20T08:00:12.000Z",
  "lastCheckAt": "2026-10-21T09:14:03.000Z",
  "lastLatencyMs": 182,
  "lastStatus": 200,
  "lastError": null,
  "tlsExpiresAt": "2026-12-30T23:59:59.000Z",
  "pingUrl": null,
  "lastPingAt": null,
  "settings": {
    "method": "GET",
    "statusMin": 200,
    "statusMax": 399,
    "keyword": "Add to cart",
    "keywordAbsent": false,
    "followRedirects": true,
    "hasBody": false,
    "tlsWarnDays": 14,
    "dnsType": "A",
    "dnsExpect": null,
    "graceS": 300
  },
  "createdAt": "2026-10-20T08:00:00.000Z"
}
```

Errors: `invalid_request`, `invalid_kind`, `invalid_name`, `invalid_url`, `invalid_interval_s`, `rate_limited`.

### DELETE /monitors/:id

Delete a monitor. Deletes the monitor with its history, and takes it off your status pages.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a"
```

### POST /monitors/:id/pause

Pause a monitor. No checks and no notices until it is resumed; an outage in progress ends without a notice. Status pages show it as maintenance.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a/pause"
```

```json
{
  "id": "mon_k4q2m7abcdefgh4a",
  "name": "Shop",
  "kind": "http",
  "targetText": "https://shop.example.com/",
  "target": "https://shop.example.com/",
  "port": null,
  "intervalS": 60,
  "timeoutS": 10,
  "email": true,
  "state": "paused",
  "stateSince": "2026-10-20T08:00:12.000Z",
  "lastCheckAt": "2026-10-21T09:14:03.000Z",
  "lastLatencyMs": 182,
  "lastStatus": 200,
  "lastError": null,
  "tlsExpiresAt": "2026-12-30T23:59:59.000Z",
  "pingUrl": null,
  "lastPingAt": null,
  "settings": {
    "method": "GET",
    "statusMin": 200,
    "statusMax": 399,
    "keyword": "Add to cart",
    "keywordAbsent": false,
    "followRedirects": true,
    "hasBody": false,
    "tlsWarnDays": 14,
    "dnsType": "A",
    "dnsExpect": null,
    "graceS": 300
  },
  "createdAt": "2026-10-20T08:00:00.000Z"
}
```

### POST /monitors/:id/resume

Resume a monitor. Checks again from now, starting afresh.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a/resume"
```

```json
{
  "id": "mon_k4q2m7abcdefgh4a",
  "name": "Shop",
  "kind": "http",
  "targetText": "https://shop.example.com/",
  "target": "https://shop.example.com/",
  "port": null,
  "intervalS": 60,
  "timeoutS": 10,
  "email": true,
  "state": "pending",
  "stateSince": "2026-10-20T08:00:12.000Z",
  "lastCheckAt": "2026-10-21T09:14:03.000Z",
  "lastLatencyMs": 182,
  "lastStatus": 200,
  "lastError": null,
  "tlsExpiresAt": "2026-12-30T23:59:59.000Z",
  "pingUrl": null,
  "lastPingAt": null,
  "settings": {
    "method": "GET",
    "statusMin": 200,
    "statusMax": 399,
    "keyword": "Add to cart",
    "keywordAbsent": false,
    "followRedirects": true,
    "hasBody": false,
    "tlsWarnDays": 14,
    "dnsType": "A",
    "dnsExpect": null,
    "graceS": 300
  },
  "createdAt": "2026-10-20T08:00:00.000Z"
}
```

### GET /monitors/:id/checks

List recent checks. The monitor's checks of the last 48 hours, newest first (500 at most): passed or not, the response time, the status code and why it failed. For a heartbeat, its pings.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a/checks"
```

```json
{
  "data": [
    {
      "at": "2026-10-21T09:14:03.000Z",
      "ok": true,
      "latencyMs": 182,
      "status": 200,
      "error": null
    }
  ]
}
```

### GET /monitors/:id/incidents

List outages. Confirmed outages of the last 90 days, newest first. quiet: no notice was sent (the monitor went down more than three times that hour).

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/monitors/mon_k4q2m7abcdefgh4a/incidents"
```

```json
{
  "data": [
    {
      "id": "mni_q7m2x4abcdefgh4c",
      "startedAt": "2026-10-18T22:41:05.000Z",
      "resolvedAt": "2026-10-18T22:52:40.000Z",
      "cause": "HTTP 502 (expected 200-399)",
      "quiet": false
    }
  ]
}
```

## Status pages

### GET /status-pages

List status pages. Your public status pages, oldest first, with their addresses.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status-pages"
```

```json
{
  "data": [
    {
      "id": "spg_m2k7q4abcdefgh4d",
      "slug": "northwind",
      "title": "Northwind Labs status",
      "logoText": "Northwind",
      "about": "Our shop and API.",
      "monitorIds": [
        "mon_k4q2m7abcdefgh4a"
      ],
      "indexable": false,
      "url": "https://vpsnine.com/s/northwind",
      "createdAt": "2026-10-20T08:10:00.000Z",
      "updatedAt": "2026-10-20T08:10:00.000Z"
    }
  ]
}
```

### POST /status-pages

Publish a status page. slug (3 to 40 lowercase letters, digits and dashes: the page is at https://vpsnine.com/s/<slug>), title, and optionally logoText, about, monitorIds (up to 20, in display order) and indexable (default false: the page asks search engines not to list it). The page shows names, states and uptime, never what a monitor checks.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"slug":"northwind","title":"Northwind Labs status","logoText":"Northwind","about":"Our shop and API.","monitorIds":["mon_k4q2m7abcdefgh4a"]}' \
  "$API/status-pages"
```

```json
{
  "id": "spg_m2k7q4abcdefgh4d",
  "slug": "northwind",
  "title": "Northwind Labs status",
  "logoText": "Northwind",
  "about": "Our shop and API.",
  "monitorIds": [
    "mon_k4q2m7abcdefgh4a"
  ],
  "indexable": false,
  "url": "https://vpsnine.com/s/northwind",
  "createdAt": "2026-10-20T08:10:00.000Z",
  "updatedAt": "2026-10-20T08:10:00.000Z",
  "notes": []
}
```

Errors: `invalid_request`, `invalid_slug`, `invalid_title`, `invalid_logo_text`, `invalid_about`, `invalid_monitor_ids`, `invalid_indexable`, `slug_taken`, `too_many_status_pages`.

### GET /status-pages/:id

Show a status page. One page with its notes, newest first.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status-pages/spg_m2k7q4abcdefgh4d"
```

```json
{
  "id": "spg_m2k7q4abcdefgh4d",
  "slug": "northwind",
  "title": "Northwind Labs status",
  "logoText": "Northwind",
  "about": "Our shop and API.",
  "monitorIds": [
    "mon_k4q2m7abcdefgh4a"
  ],
  "indexable": false,
  "url": "https://vpsnine.com/s/northwind",
  "createdAt": "2026-10-20T08:10:00.000Z",
  "updatedAt": "2026-10-20T08:10:00.000Z",
  "notes": [
    {
      "id": "spn_x4q7m2abcdefgh4e",
      "kind": "maintenance",
      "title": "Database upgrade",
      "body": "Sunday 02:00 to 02:30 UTC. Checkout may be slow.",
      "createdAt": "2026-10-21T09:00:00.000Z",
      "resolvedAt": null
    }
  ]
}
```

### PUT /status-pages/:id

Change a status page. Send any of the fields you can publish a page with. A new slug moves the page; the old address stops working.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Northwind Labs: service status","indexable":true}' \
  "$API/status-pages/spg_m2k7q4abcdefgh4d"
```

```json
{
  "id": "spg_m2k7q4abcdefgh4d",
  "slug": "northwind",
  "title": "Northwind Labs: service status",
  "logoText": "Northwind",
  "about": "Our shop and API.",
  "monitorIds": [
    "mon_k4q2m7abcdefgh4a"
  ],
  "indexable": true,
  "url": "https://vpsnine.com/s/northwind",
  "createdAt": "2026-10-20T08:10:00.000Z",
  "updatedAt": "2026-10-20T08:10:00.000Z",
  "notes": [
    {
      "id": "spn_x4q7m2abcdefgh4e",
      "kind": "maintenance",
      "title": "Database upgrade",
      "body": "Sunday 02:00 to 02:30 UTC. Checkout may be slow.",
      "createdAt": "2026-10-21T09:00:00.000Z",
      "resolvedAt": null
    }
  ]
}
```

Errors: `invalid_request`, `invalid_slug`, `invalid_title`, `invalid_monitor_ids`, `slug_taken`.

### DELETE /status-pages/:id

Delete a status page. Deletes the page and its notes. Its address stops working at once.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status-pages/spg_m2k7q4abcdefgh4d"
```

### POST /status-pages/:id/notes

Post a note. kind: incident, maintenance or notice; title (120 characters) and body (2000). Open notes show at the top of the page; an open incident marks the page as partly down.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"maintenance","title":"Database upgrade","body":"Sunday 02:00 to 02:30 UTC. Checkout may be slow."}' \
  "$API/status-pages/spg_m2k7q4abcdefgh4d/notes"
```

```json
{
  "id": "spn_x4q7m2abcdefgh4e",
  "kind": "maintenance",
  "title": "Database upgrade",
  "body": "Sunday 02:00 to 02:30 UTC. Checkout may be slow.",
  "createdAt": "2026-10-21T09:00:00.000Z",
  "resolvedAt": null
}
```

Errors: `invalid_request`, `invalid_kind`, `invalid_title`, `invalid_body`.

### POST /status-pages/:id/notes/:noteId/resolve

Resolve a note. Marks the note resolved: it moves to the page's past 14 days.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status-pages/spg_m2k7q4abcdefgh4d/notes/spn_x4q7m2abcdefgh4e/resolve"
```

```json
{
  "id": "spn_x4q7m2abcdefgh4e",
  "kind": "maintenance",
  "title": "Database upgrade",
  "body": "Sunday 02:00 to 02:30 UTC. Checkout may be slow.",
  "createdAt": "2026-10-21T09:00:00.000Z",
  "resolvedAt": "2026-10-26T02:31:00.000Z"
}
```

### DELETE /status-pages/:id/notes/:noteId

Delete a note. Removes the note from the page.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/status-pages/spg_m2k7q4abcdefgh4d/notes/spn_x4q7m2abcdefgh4e"
```

## Notification channels

### GET /channels

List channels. Your notification channels, oldest first (push devices are under /push-devices).

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/channels"
```

```json
{
  "data": [
    {
      "id": "chn_s4k7q2abcdefgh3f",
      "kind": "slack",
      "name": "#ops",
      "target": "hooks.slack.com/services/…k3Xz",
      "categories": [
        "servers",
        "alerts"
      ],
      "state": "active",
      "enabled": true,
      "disabledReason": null,
      "failureStreak": 0,
      "lastSuccessAt": "2026-10-21T09:14:01.000Z",
      "lastFailureAt": null,
      "createdAt": "2026-10-20T08:00:00.000Z",
      "pairing": null
    }
  ]
}
```

### POST /channels

Add a channel. kind: slack, discord, teams or ntfy (send url: an incoming webhook, a channel webhook, a Teams Workflows URL, or an ntfy topic URL on any public server, with an optional token for a protected topic), telegram (no url; the answer has the pairing links), or email (send email; it gets a confirmation link first). name is optional. Slack, Discord and Teams URLs must be on their service's own hosts; every URL must be https on a public address.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"slack","name":"#ops","url":"https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXX","categories":["servers","alerts"]}' \
  "$API/channels"
```

```json
{
  "id": "chn_s4k7q2abcdefgh3f",
  "kind": "slack",
  "name": "#ops",
  "target": "hooks.slack.com/services/…k3Xz",
  "categories": [
    "servers",
    "alerts"
  ],
  "state": "active",
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": "2026-10-21T09:14:01.000Z",
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "pairing": null
}
```

Errors: `invalid_request`, `invalid_kind`, `invalid_name`, `invalid_url`, `invalid_token`, `invalid_email`, `invalid_categories`, `too_many_channels`, `channel_kind_unavailable`.

### GET /channels/:id

Show a channel. One channel. A pending Telegram channel carries its pairing links until they expire.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/channels/chn_t7m2x4abcdefgh3g"
```

```json
{
  "id": "chn_t7m2x4abcdefgh3g",
  "kind": "telegram",
  "name": "on-call phone",
  "target": "not paired yet",
  "categories": [
    "servers",
    "alerts"
  ],
  "state": "pending",
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "pairing": {
    "link": "https://t.me/VpsNineBot?start=q2x7m4k3j5abcdefgh2r7t4k",
    "groupLink": "https://t.me/VpsNineBot?startgroup=q2x7m4k3j5abcdefgh2r7t4k",
    "expiresAt": "2026-10-21T09:44:00.000Z"
  }
}
```

### PUT /channels/:id

Change a channel. Send any of name, categories, enabled, and for Slack, Discord, Teams and ntfy a new url (and token). Turning a channel on clears its failure count.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"categories":["servers","alerts","billing"],"enabled":true}' \
  "$API/channels/chn_s4k7q2abcdefgh3f"
```

```json
{
  "id": "chn_s4k7q2abcdefgh3f",
  "kind": "slack",
  "name": "#ops",
  "target": "hooks.slack.com/services/…k3Xz",
  "categories": [
    "servers",
    "alerts",
    "billing"
  ],
  "state": "active",
  "enabled": true,
  "disabledReason": null,
  "failureStreak": 0,
  "lastSuccessAt": "2026-10-21T09:14:01.000Z",
  "lastFailureAt": null,
  "createdAt": "2026-10-20T08:00:00.000Z",
  "pairing": null
}
```

Errors: `invalid_request`, `invalid_name`, `invalid_url`, `invalid_token`, `invalid_categories`.

### DELETE /channels/:id

Delete a channel. Deletes the channel and its delivery log. Nothing more is sent to it.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/channels/chn_s4k7q2abcdefgh3f"
```

### POST /channels/:id/test

Send a test message. Queues a test message to the channel now (one attempt, whatever its categories); 5 a minute per account. The channel must be on and confirmed or paired.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/channels/chn_s4k7q2abcdefgh3f/test"
```

```json
{
  "id": "chd_d4t2k7abcdefgh3j",
  "eventType": "test",
  "title": "Test message from VPSNine",
  "state": "pending",
  "attempts": 1,
  "status": null,
  "error": null,
  "latencyMs": null,
  "createdAt": "2026-10-21T09:14:00.000Z",
  "nextAttemptAt": null
}
```

Errors: `rate_limited`, `channel_not_ready`.

### GET /channels/:id/deliveries

List channel deliveries. The channel's last 50 messages, newest first: state, attempts, the service's HTTP status and the error, if any.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/channels/chn_s4k7q2abcdefgh3f/deliveries"
```

```json
{
  "data": [
    {
      "id": "chd_d4t2k7abcdefgh3j",
      "eventType": "alert.triggered",
      "title": "web-1: CPU usage above 90% for 10 minutes",
      "state": "succeeded",
      "attempts": 1,
      "status": 200,
      "error": null,
      "latencyMs": 214,
      "createdAt": "2026-10-21T09:14:00.000Z",
      "nextAttemptAt": null
    }
  ]
}
```

### GET /push-devices

List push devices. Browsers and installed panels with push notifications on (up to 10), oldest first. A push carries a title and a panel link only.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/push-devices"
```

```json
{
  "data": [
    {
      "id": "chn_p2q7m4abcdefgh3h",
      "name": "Chrome on Windows",
      "categories": [
        "servers",
        "alerts",
        "billing"
      ],
      "createdAt": "2026-10-20T18:00:00.000Z",
      "lastSuccessAt": "2026-10-21T09:14:02.000Z",
      "failureStreak": 0
    }
  ]
}
```

### PUT /push-devices/:id

Change a push device. Send name and/or categories.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"categories":["alerts"]}' \
  "$API/push-devices/chn_p2q7m4abcdefgh3h"
```

```json
{
  "id": "chn_p2q7m4abcdefgh3h",
  "name": "Chrome on Windows",
  "categories": [
    "alerts"
  ],
  "createdAt": "2026-10-20T18:00:00.000Z",
  "lastSuccessAt": "2026-10-21T09:14:02.000Z",
  "failureStreak": 0
}
```

Errors: `invalid_request`, `invalid_name`, `invalid_categories`.

### DELETE /push-devices/:id

Remove a push device. The device gets no more push notifications. The browser keeps its permission; turning push on again from the panel adds it back.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/push-devices/chn_p2q7m4abcdefgh3h"
```

## Servers

### POST /servers/:id/lock

Turn deletion protection on. Locks the server against deletion: while it is on, delete, reinstall and going back to a snapshot answer 409 protected (in the panel, the API and the CLI). Power actions, snapshots and everything else work as before. Billing and our staff can still act on the server. No body.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/lock"
```

```json
{
  "id": "srv_k3j5m2abcdefgh2q",
  "hostname": "web-1",
  "plan": "standard",
  "image": "debian-12",
  "state": "running",
  "pendingAction": null,
  "ipv4": "203.0.113.10",
  "ipv6": "2001:db8:10::2",
  "vcpus": 2,
  "memoryMiB": 4096,
  "diskGiB": 80,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "stateSince": "2026-10-09T12:01:00.000Z",
  "app": "docker",
  "deletionProtected": true,
  "purgeAt": null
}
```

Errors: `invalid_state`.

### POST /servers/:id/unlock

Turn deletion protection off. Unlocks the server. Send your account password, and code (from your authenticator app, or a recovery code) when the account has one. Wrong answers count toward the same limits as the panel's forms. An account that uses passkeys without an authenticator app gets 403 unlock_in_panel: unlock it in the panel, where the passkey can confirm it's you.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"password":"your account password","code":"123456"}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/unlock"
```

```json
{
  "id": "srv_k3j5m2abcdefgh2q",
  "hostname": "web-1",
  "plan": "standard",
  "image": "debian-12",
  "state": "running",
  "pendingAction": null,
  "ipv4": "203.0.113.10",
  "ipv6": "2001:db8:10::2",
  "vcpus": 2,
  "memoryMiB": 4096,
  "diskGiB": 80,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "stateSince": "2026-10-09T12:01:00.000Z",
  "app": "docker",
  "deletionProtected": false,
  "purgeAt": null
}
```

Errors: `invalid_request`, `wrong_password`, `code_required`, `wrong_code`, `unlock_in_panel`, `rate_limited`, `invalid_state`.

### GET /servers/:id/power-schedule

Show the power schedule. The server's power schedule, or null when it has none: a reboot (daily or weekly) and off hours (stopped at stopHour on each of days, started again at startHour, the next morning when it is earlier), in timezone. next has when each runs next (within 5 minutes after the hour); last is the latest run: ok, failed, skipped (with why) or running. heldOff is true while the schedule has the server stopped.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/power-schedule"
```

```json
{
  "schedule": {
    "timezone": "Europe/Berlin",
    "reboot": {
      "frequency": "weekly",
      "hour": 4,
      "weekday": "sun"
    },
    "offHours": {
      "stopHour": 20,
      "startHour": 8,
      "days": [
        "mon",
        "tue",
        "wed",
        "thu",
        "fri"
      ]
    },
    "next": {
      "reboot": "2026-10-25T03:02:41.000Z",
      "stop": "2026-10-21T18:02:41.000Z",
      "start": "2026-10-22T06:02:41.000Z"
    },
    "last": {
      "action": "start",
      "at": "2026-10-21T06:02:41.000Z",
      "status": "ok",
      "detail": null
    },
    "heldOff": false
  }
}
```

### PUT /servers/:id/power-schedule

Set the power schedule. Sets the schedule, replacing any. timezone: an IANA name; reboot: null or { frequency: daily or weekly, hour: 0 to 23, weekday: sun to sat (weekly) }; offHours: null or { stopHour, startHour: 0 to 23, different, days: the weekdays the off hours start on }. At least one of reboot and offHours. A reboot runs only on a running server; the stop is a clean shutdown (forced after a minute); the start at the end of the off hours runs only if the schedule stopped it. A suspended server, one in rescue or one busy for over 30 minutes is skipped until the next time.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"timezone":"Europe/Berlin","reboot":{"frequency":"weekly","hour":4,"weekday":"sun"},"offHours":{"stopHour":20,"startHour":8,"days":["mon","tue","wed","thu","fri"]}}' \
  "$API/servers/srv_k3j5m2abcdefgh2q/power-schedule"
```

```json
{
  "schedule": {
    "timezone": "Europe/Berlin",
    "reboot": {
      "frequency": "weekly",
      "hour": 4,
      "weekday": "sun"
    },
    "offHours": {
      "stopHour": 20,
      "startHour": 8,
      "days": [
        "mon",
        "tue",
        "wed",
        "thu",
        "fri"
      ]
    },
    "next": {
      "reboot": "2026-10-25T03:02:41.000Z",
      "stop": "2026-10-21T18:02:41.000Z",
      "start": "2026-10-22T06:02:41.000Z"
    },
    "last": {
      "action": "start",
      "at": "2026-10-21T06:02:41.000Z",
      "status": "ok",
      "detail": null
    },
    "heldOff": false
  }
}
```

Errors: `invalid_request`, `invalid_schedule`.

### DELETE /servers/:id/power-schedule

Turn the power schedule off. Removes the schedule. A server the schedule had stopped stays stopped: start it yourself. No body.

Scope: write. Success: 200.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/servers/srv_k3j5m2abcdefgh2q/power-schedule"
```

```json
{
  "schedule": null
}
```

## Recycle bin

### GET /recycle-bin

List the recycle bin. Servers you deleted that are still kept: state deleted, purgeAt when the disk is wiped for good. Their addresses stay reserved for them until then. restore says whether it can be restored now, and the open invoice of a restore already ordered.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/recycle-bin"
```

```json
{
  "data": [
    {
      "id": "srv_k3j5m2abcdefgh2q",
      "hostname": "web-1",
      "plan": "standard",
      "image": "debian-12",
      "state": "deleted",
      "pendingAction": null,
      "ipv4": "203.0.113.10",
      "ipv6": "2001:db8:10::2",
      "vcpus": 2,
      "memoryMiB": 4096,
      "diskGiB": 80,
      "createdAt": "2026-10-09T12:00:00.000Z",
      "stateSince": "2026-10-21T09:00:00.000Z",
      "app": "docker",
      "deletionProtected": false,
      "purgeAt": "2026-10-28T09:00:00.000Z",
      "deletedAt": "2026-10-21T09:00:00.000Z",
      "restore": {
        "available": true,
        "reason": null,
        "invoiceId": null
      }
    }
  ]
}
```

### POST /recycle-bin/:id/restore

Restore a deleted server. Orders the server back, with its disk and its addresses: an invoice for a new billing period (term month or year, at today's prices, with the add-ons it had), paid like any order; account credit applies. Once it is paid the server boots again, billed from that day. Asking again while that invoice is unpaid answers the same invoice.

Scope: write. Success: 202.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"term":"month"}' \
  "$API/recycle-bin/srv_k3j5m2abcdefgh2q/restore"
```

```json
{
  "order": {
    "id": "ord_x2m4q7abcdefgh2u"
  },
  "invoice": {
    "id": "inv_q4m7x2abcdefgh2t",
    "number": "VPS9-000043",
    "kind": "order",
    "status": "open",
    "currency": "USD",
    "subtotalCents": 1299,
    "creditAppliedCents": 0,
    "totalCents": 1299,
    "issuedAt": "2026-10-21T09:30:00.000Z",
    "dueAt": "2026-10-21T09:30:00.000Z",
    "paidAt": null,
    "voidedAt": null,
    "serverId": null,
    "term": "month"
  },
  "payUrl": "https://my.vpsnine.com/billing/invoices/inv_q4m7x2abcdefgh2t"
}
```

Errors: `invalid_request`, `invalid_term`, `not_restorable`, `too_many_open_orders`, `staff_only`, `email_unverified`, `rate_limited`.

### DELETE /recycle-bin/:id

Delete a server forever. Wipes the kept disk now instead of at purgeAt, and releases the addresses. It can't be undone. No body.

Scope: write. Success: 202.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/recycle-bin/srv_k3j5m2abcdefgh2q"
```

```json
{
  "id": "srv_k3j5m2abcdefgh2q",
  "hostname": "web-1",
  "plan": "standard",
  "image": "debian-12",
  "state": "deleted",
  "pendingAction": "purge",
  "ipv4": "203.0.113.10",
  "ipv6": "2001:db8:10::2",
  "vcpus": 2,
  "memoryMiB": 4096,
  "diskGiB": 80,
  "createdAt": "2026-10-09T12:00:00.000Z",
  "stateSince": "2026-10-21T09:00:00.000Z",
  "app": "docker",
  "deletionProtected": false,
  "purgeAt": "2026-10-28T09:00:00.000Z"
}
```

Errors: `action_pending`, `invalid_state`.

## AI assistant

### GET /assistant

Show the AI assistant setting. Your own account's AI assistant setting. mode: on (answers questions in the panel and adds a short explanation to alert and failure notifications), on_demand (only when someone asks) or off. available: whether the assistant runs right now; questionsLeftToday and dailyLimit: your account's questions (UTC day).

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/assistant"
```

```json
{
  "mode": "on",
  "available": true,
  "questionsLeftToday": 28,
  "dailyLimit": 30
}
```

### PUT /assistant

Change the AI assistant setting. Sets mode (on, on_demand or off) for your own account. off also stops questions from team members acting on it. Answers with the setting.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"mode":"on_demand"}' \
  "$API/assistant"
```

```json
{
  "mode": "on_demand",
  "available": true,
  "questionsLeftToday": 28,
  "dailyLimit": 30
}
```

Errors: `invalid_mode`, `assistant_unavailable`.

## Support

### GET /tickets

List tickets. Your support tickets, latest activity first (100 at most). status is open (waiting on us), pending (we answered) or closed.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/tickets"
```

```json
{
  "data": [
    {
      "id": "tkt_m4q2x7abcdefgh2u",
      "subject": "web-1 doesn't answer on port 443",
      "category": "technical",
      "priority": "high",
      "status": "open",
      "serverId": "srv_k3j5m2abcdefgh2q",
      "createdAt": "2026-10-12T09:00:00.000Z",
      "updatedAt": "2026-10-12T09:00:00.000Z",
      "closedAt": null,
      "rating": null
    }
  ]
}
```

### POST /tickets

Open a ticket. Opens a ticket and emails you a receipt. category is technical, billing, sales, abuse, account or other (other when left out); priority is low, normal (the default), high or urgent; serverId (optional) links one of your servers. Plain text; put logs between lines of ``` to keep their layout. 5 new tickets an hour and 20 a day per account.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"subject":"web-1 doesn't answer on port 443","category":"technical","priority":"high","serverId":"srv_k3j5m2abcdefgh2q","body":"Nginx is running and ufw allows 443, but curl from outside times out."}' \
  "$API/tickets"
```

```json
{
  "id": "tkt_m4q2x7abcdefgh2u",
  "subject": "web-1 doesn't answer on port 443",
  "category": "technical",
  "priority": "high",
  "status": "open",
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-12T09:00:00.000Z",
  "updatedAt": "2026-10-12T09:00:00.000Z",
  "closedAt": null,
  "rating": null,
  "messages": [
    {
      "id": 41,
      "at": "2026-10-12T09:00:00.000Z",
      "from": "you",
      "body": "Nginx is running and ufw allows 443, but curl from outside times out."
    }
  ]
}
```

Errors: `invalid_request`, `invalid_subject`, `invalid_category`, `invalid_priority`, `invalid_server`, `invalid_body`, `rate_limited`.

### GET /tickets/:id

Show a ticket. One ticket and its messages, oldest first. from is you or support.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/tickets/tkt_m4q2x7abcdefgh2u"
```

```json
{
  "id": "tkt_m4q2x7abcdefgh2u",
  "subject": "web-1 doesn't answer on port 443",
  "category": "technical",
  "priority": "high",
  "status": "pending",
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-12T09:00:00.000Z",
  "updatedAt": "2026-10-12T09:00:00.000Z",
  "closedAt": null,
  "rating": null,
  "messages": [
    {
      "id": 41,
      "at": "2026-10-12T09:00:00.000Z",
      "from": "you",
      "body": "Nginx is running and ufw allows 443, but curl from outside times out."
    },
    {
      "id": 44,
      "at": "2026-10-12T09:20:00.000Z",
      "from": "support",
      "body": "Your server's own firewall has 443 open, but the panel firewall doesn't: add a rule for tcp 443."
    }
  ]
}
```

### POST /tickets/:id/replies

Reply to a ticket. Adds your message and reopens the ticket for us. 30 replies an hour per account. A closed ticket takes no replies: open a new one.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"body":"That was it, thanks. Works now."}' \
  "$API/tickets/tkt_m4q2x7abcdefgh2u/replies"
```

```json
{
  "id": "tkt_m4q2x7abcdefgh2u",
  "subject": "web-1 doesn't answer on port 443",
  "category": "technical",
  "priority": "high",
  "status": "open",
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-12T09:00:00.000Z",
  "updatedAt": "2026-10-12T09:00:00.000Z",
  "closedAt": null,
  "rating": null,
  "messages": [
    {
      "id": 41,
      "at": "2026-10-12T09:00:00.000Z",
      "from": "you",
      "body": "Nginx is running and ufw allows 443, but curl from outside times out."
    },
    {
      "id": 45,
      "at": "2026-10-12T09:31:00.000Z",
      "from": "you",
      "body": "That was it, thanks. Works now."
    }
  ]
}
```

Errors: `invalid_request`, `invalid_body`, `ticket_closed`, `rate_limited`.

### POST /tickets/:id/close

Close a ticket. Closes the ticket. You can rate a closed ticket in the panel.

Scope: write. Success: 200.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/tickets/tkt_m4q2x7abcdefgh2u/close"
```

```json
{
  "id": "tkt_m4q2x7abcdefgh2u",
  "subject": "web-1 doesn't answer on port 443",
  "category": "technical",
  "priority": "high",
  "status": "closed",
  "serverId": "srv_k3j5m2abcdefgh2q",
  "createdAt": "2026-10-12T09:00:00.000Z",
  "updatedAt": "2026-10-12T09:00:00.000Z",
  "closedAt": "2026-10-12T09:32:00.000Z",
  "rating": null,
  "messages": [
    {
      "id": 41,
      "at": "2026-10-12T09:00:00.000Z",
      "from": "you",
      "body": "Nginx is running and ufw allows 443, but curl from outside times out."
    }
  ]
}
```

Errors: `ticket_closed`.

## Teams

### GET /team

List the team. The people on the account (the owner first) with their roles, and the invites still waiting. Every role can look.

Scope: read. Success: 200.

```bash
curl -s -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/team"
```

```json
{
  "account": {
    "id": "usr_a2b3c4d5e6f7g2h3",
    "name": "Acme ops",
    "role": "admin",
    "require2fa": true
  },
  "members": [
    {
      "id": "usr_a2b3c4d5e6f7g2h3",
      "email": "owner@example.com",
      "role": "owner",
      "since": "2026-10-01T09:00:00.000Z",
      "twoFactor": true
    },
    {
      "id": "usr_t4m7x2abcdefgh2u",
      "email": "dev@example.com",
      "role": "technical",
      "since": "2026-10-12T15:30:00.000Z",
      "twoFactor": true
    }
  ],
  "invites": [
    {
      "id": "tinv_r5n8x2abcdefgh2v",
      "email": "books@example.com",
      "role": "billing",
      "invitedBy": "owner@example.com",
      "createdAt": "2026-10-20T10:00:00.000Z",
      "expiresAt": "2026-10-27T10:00:00.000Z"
    }
  ]
}
```

### POST /team/invites

Invite someone. Emails an invite link that works once, for 7 days, for exactly this address. role: admin, billing, technical or readonly. Owners and admins only; 20 invites an hour per account.

Scope: write. Success: 201.

```bash
curl -s -X POST -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"email":"books@example.com","role":"billing"}' \
  "$API/team/invites"
```

```json
{
  "id": "tinv_r5n8x2abcdefgh2v",
  "email": "books@example.com",
  "role": "billing",
  "invitedBy": "owner@example.com",
  "createdAt": "2026-10-20T10:00:00.000Z",
  "expiresAt": "2026-10-27T10:00:00.000Z"
}
```

Errors: `invalid_email`, `invalid_role`, `already_member`, `invite_pending`, `too_many_invites`, `too_many_members`, `forbidden_role`, `rate_limited`, `invalid_request`.

### DELETE /team/invites/:id

Revoke an invite. Its link stops working. Owners and admins only.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/team/invites/tinv_r5n8x2abcdefgh2v"
```

Errors: `forbidden_role`.

### PUT /team/members/:userId

Change a role. Sets a member's role (not the owner's, and not your own). Their tokens follow the new role at once. Owners and admins only.

Scope: write. Success: 200.

```bash
curl -s -X PUT -H "Authorization: Bearer $VPS9_TOKEN" -H "Content-Type: application/json" \
  -d '{"role":"readonly"}' \
  "$API/team/members/usr_t4m7x2abcdefgh2u"
```

```json
{
  "id": "usr_t4m7x2abcdefgh2u",
  "email": "dev@example.com",
  "role": "readonly",
  "since": "2026-10-12T15:30:00.000Z",
  "twoFactor": true
}
```

Errors: `invalid_role`, `own_role`, `owner_role`, `forbidden_role`, `invalid_request`.

### DELETE /team/members/:userId

Remove a member. Removes them from the account, signs out their sessions on it and revokes their tokens for it. Not the owner, not yourself. Owners and admins only.

Scope: write. Success: 204.

```bash
curl -s -X DELETE -H "Authorization: Bearer $VPS9_TOKEN" \
  "$API/team/members/usr_t4m7x2abcdefgh2u"
```

Errors: `owner_role`, `remove_self`, `forbidden_role`.
