# Quivvo REST API

The Quivvo REST API creates and drives machines over HTTPS. The base URL is `https://tryquivvo.com/api/v1`, it takes and returns JSON, and it's described by an [OpenAPI 3.1 document](https://tryquivvo.com/api/v1/openapi.json). A field a route doesn't take is ignored, not refused, so check names against that document. The MCP server refuses them.

## Auth

`Authorization: Bearer qvk_...` or `X-API-Key: qvk_...`. A key belongs to one workspace and carries scopes:

| Scope | Allows |
| --- | --- |
| `machines:read` | list and read machines, history, jobs, files (read), ps, ports, usage |
| `machines:write` | create, exec, files (write), lifecycle, ports, connect, snapshot save, fork, restore |
| `machines:delete` | delete machines and snapshots, and set or bring forward `delete_after_minutes` on a machine that already exists |
| `keys:write` | create and revoke API keys, approve device and agent sign-ins. A key can't create a key with scopes it doesn't have itself |

A read-only key can read every ordinary file on a machine, so whatever anyone saved there, and job logs. It can't read `/proc`, `/sys`, `/dev`, `/run` or the daemon's own state, even through a link, and gets `forbidden`: reading can't turn into running. A key with `machines:write` can run anything as root, so it has no such limit. `POST /machines/:id/connect` needs `machines:write`, so a read-only key reads through `GET /machines/:id/files`.

Keys made by `quivvo login` (kind `cli`) get `machines:read`, `machines:write` and `machines:delete`, plus `keys:write` if the person approving ticks it. Keys an agent gets from `login --agent` (kind `agent`)
get the scopes the owner approved, never `keys:write`. Routes marked **session** also accept the dashboard's
signed-in cookie. With that cookie, a request that changes something has to come from this site's own pages, or it's `forbidden`. Billing routes are session-only.

Only a key with `machines:write` wakes a machine. Reading files or jobs with a read-only key on a machine that isn't running answers `machine_not_running`, because waking one restarts its bill.

## Who am I?

`GET /whoami` needs a key with any scope, and shows what it may do: `kind`, `key`, `email`, `scopes`, `workspace` (`id`, `name`, `plan`, `plan_status`, `credit_balance`), `limits`, `exec`, `machines` (`total`, `running`) and `queue`.

## The queue

`GET /queue` needs a key with any scope, and shows your own workspace's place, the same object as `queue` in `whoami`:

```json
{ "waiting": true, "position": 412, "invites": 3, "moves_per_invite": 10, "max_counted_invites": 50, "invite_url": "https://tryquivvo.com/login?invite=abcd1234" }
```

Or `{ "waiting": false }` once it's let in. While it's waiting, a workspace gets [`queued`](https://tryquivvo.com/docs/errors#queued) from anything that creates a machine. Each new mailbox that signs up with `invite_url` and confirms its email moves it up `moves_per_invite` places, for up to `max_counted_invites` of them. Upgrading or topping up lets it in. `queue_open` in the catalog says whether a new sign-up waits.

## Errors

```json
{ "error": { "code": "not_found", "message": "No machine named dev.", "retryable": false, "retry_after_seconds": null,
             "docs_url": "https://tryquivvo.com/docs/errors#not_found", "request_id": "req_..." } }
```

Branch on `error.code`. When `retryable` is true, retry after `retry_after_seconds`. Every response carries an `x-request-id` header, and `request_id` repeats it. `owner_action`, when present, is `{ url, message }`: something only the workspace's owner can do, signed in on the dashboard. [Errors](https://tryquivvo.com/docs/errors) lists every code and what to do about it. A path that doesn't exist is a `not_found` like any other. A path that exists, asked with a method it doesn't take, answers 405 with no body.

## Idempotency

Lifecycle `POST`s (create, start, stop, suspend, resume, restart, snapshot, fork, restore) accept
`Idempotency-Key: <any string up to 200 chars>`. Repeating a request with the same key within 24 hours returns
the first response instead of acting again. A workspace keeps its newest 1,000 keys, so a retry of an older one acts again. `POST /machines` also takes it as `idempotency_key` in the body. `DELETE` on a machine or snapshot that's already gone succeeds with `already_deleted: true`, so retrying is safe. What's deleted is remembered for 35 days, and a workspace's newest 500 deleted machines. After that it's `not_found`.

## Objects

```json
Machine {
  "id": "m_8f3k2l9x", "name": "dev",
  "state": "creating"|"starting"|"running"|"suspending"|"suspended"|"stopping"|"stopped"|"failed"|"deleted",
  "template": "universal", "region": "ams", "vcpus": 2, "memory_mib": 4096, "disk_gib": 10,
  "url": "https://...",            // public URL for the last port published, null until one is
  "auto_suspend_minutes": 30,      // 0 = never
  "created_at": "...", "started_at": "..."|null, "last_activity_at": "..."|null,
  "error": null|"why it failed",
  "hourly_cost": 0.054,
  "delete_after": null|"...",      // set once the owner was warned that this abandoned machine will be deleted
  "auto_delete_at": null|"...",    // when the server deletes it, from delete_after_minutes
  "setup": null|{ "state": "pending"|"running"|"succeeded"|"failed", "exit_code": null|0, "job_id": null|"j_..." },
  "suspend_outcome": null|"suspended"|"stopped", // what the last suspend turned into; cleared on the next boot
  "resume_outcome": null|"resumed"|"cold_boot",  // what the last boot came back as; cleared when it next goes down
  "warnings": ["..."]                            // only when something went differently from what you asked
}
Snapshot { "id": "snap_...", "name": "base", "machine_id": "m_..."|null, "template": "universal",
           "disk_gib": 10, "state": "creating"|"ready"|"failed", "created_at": "...", "delete_after": null|"..." }
Job      { "id": "j_...", "cmd": "...", "argv": null, "cwd": "...", "pid": 1, "state": "running"|"exited"|"killed"|"lost",
           "exit_code": null|0, "started_at": "...", "finished_at": null|"...", "timeout_at": "...", "oom_killed": false }
Exec     { "id": "ex_...", "exit_code": 0, "stdout": "...", "stderr": "...", "truncated": false, "timed_out": false,
           "oom_killed": false, "duration_ms": 12, "resume_outcome": "resumed"|"cold_boot", "warnings": ["..."] } // these two only when it woke the machine, or something went differently
Port     { "port": 3000, "public": true, "default": true, "url": "https://..."|null, "replaced_port": null|8080, "warnings": ["..."] }
```

`:id` in a path is a machine id or its name.

- A job's `state` of `lost` means its process went away with its exit status unknown, for example after a reboot. `killed` with `exit_code` 124 means it hit its timeout.
- `oom_killed` means the kernel's out-of-memory killer fired while it ran. That's machine-wide, so it's strongest alongside exit code 137.

## Catalog (no auth)

`GET /catalog` → `{ "templates": [{name, description, default}], "sizes": [{name, vcpus, memory_mib, default}],
"regions": [{code, name, default}], "plans": [...], "rates": {...}, "topups": [...], "exec": {...}, "queue_open": false }`

`exec` holds the command limits: `sync_default_seconds` and `sync_max_seconds` (280), `job_default_seconds` (3600), `job_max_seconds` (86400).

## Machines

| Method & path | Body / query | Returns |
| --- | --- | --- |
| `GET /machines` | `?state=running`, `?all=1` to include what's deleted and still remembered | `{ "machines": [Machine] }` |
| `POST /machines` | `{ name?, template?, size?, vcpus?, memory_mib?, disk_gib?, region?, snapshot?, setup?, env?, auto_suspend_minutes?, delete_after_minutes?, idempotency_key? }` | `201 Machine` (state `starting`; poll or pass `?wait=1` to get it `running`) |
| `GET /machines/:id` | | `Machine` |
| `PATCH /machines/:id` | `{ name?, auto_suspend_minutes?, vcpus?, memory_mib?, disk_gib?, delete_after_minutes? }` (CPU/memory only while stopped; disk grows any time; `delete_after_minutes` counts from now, and null cancels it) | `Machine` |
| `DELETE /machines/:id` | header `X-Confirm-Delete: <id or exact name>` | `{ "deleted": true, "already_deleted"?: true }` |
| `POST /machines/:id/start` | `?wait=0` to answer before it's ready | `Machine` (boots fresh from stopped) |
| `POST /machines/:id/stop` | | `Machine` |
| `POST /machines/:id/suspend` | | `Machine`, with `suspend_outcome` |
| `POST /machines/:id/resume` | `?wait=0` | `Machine`, with `resume_outcome` |
| `POST /machines/:id/restart` | `?wait=0` | `Machine` (power-cycle) |
| `POST /machines/:id/exec` | `{ cmd?, argv?, cwd?, env?, timeout_ms?, stdin?, background?, tail_bytes? }` | `Exec`; `{ "job": Job }` when `background`; a stream with `Accept: text/event-stream` |
| `GET /machines/:id/jobs` | | `{ "jobs": [Job] }` |
| `GET /machines/:id/jobs/:job` | `?wait=<s>`, up to 280; ask again if it's still running | `Job` |
| `GET /machines/:id/jobs/:job/logs` | `?offset=`, `?tail_bytes=`, `?follow=1` | `text/plain`, `X-Next-Offset` (not with `follow`) |
| `POST /machines/:id/jobs/:job/kill` | `{ signal? }`: `TERM`, `KILL` or `INT` | `Job` |
| `GET /machines/:id/history` | `?limit=`, 100 by default | `{ "history": [{ id, cmd, argv, cwd, job_id, exit_code, timed_out, oom_killed, cancelled, started_at, finished_at }] }` (works while stopped) |
| `GET /machines/:id/history/:exec/output` | | `{ id, job_id, stdout, stderr, truncated, exit_code, timed_out, oom_killed, cancelled }` (the first 16 KiB and last 48 KiB of each stream) |
| `GET /machines/:id/ps` | | `{ "processes": [{ pid, ppid, user, command, rss_bytes, cpu_seconds, started_at }] }`. Doesn't wake a machine; answers `machine_not_running` |
| `GET /machines/:id/status` | | What the machine's daemon reports: `idle_seconds`, `sessions`, `jobs_running`, `memory`, `data_disk`, `oom_kills` and more. Doesn't wake a machine; answers `machine_not_running` if it isn't running |
| `GET /machines/:id/logs` | `?lines=`, `?format=json` | Boot and daemon logs as text. Never wakes a machine: for any other state it answers with its state, error and the provider's events. JSON: `{ machine, name, state, error, logs, events, note }` |
| `GET /machines/:id/files` | `?path=` | A directory as `{ path, entries: [{ name, type, size, mode, mtime }] }`, or a file's bytes, always as a download and never rendered |
| `PUT /machines/:id/files` | `?path=&mode=`, raw body up to 4 MB | `{ path, size }` |
| `DELETE /machines/:id/files` | `?path=&recursive=1` | `{ "deleted": true }` |
| `POST /machines/:id/connect` | | `{ url, token, expires_at, machine }`, a 15-minute token for the daemon inside the machine (terminal, TCP tunnels, large files). The CLI is the supported client for what it opens, and the routes behind it can change. Resumes or starts the machine if needed. Connections already open stay open. The CLI uses this for `ssh`, `cp`, `push` and `port-forward`, and they reach it at `*.fly.dev` |
| `GET /machines/:id/ports` | | `{ "ports": [Port] }` |
| `POST /machines/:id/ports` | `{ port, public? = true }` | `Port` (`url` carries `?box_token=` when private, good for 7 days. A key that can only read, and a webhook, get `url: null` for a private port). A machine can have 10, and an 11th is `limit_reached` |
| `DELETE /machines/:id/ports/:port` | | `{ "deleted": true }`. If the URL served that port, it now serves the highest one left |
| `POST /machines/:id/snapshots` | `{ name }` | `201 Snapshot` (`creating` if the copy isn't whole when the request answers; usable once `ready`) |
| `POST /machines/:id/restore` | `{ snapshot }` | `Machine` (puts the snapshot's disk back; same id, name, URL). `limit_reached` means nothing changed. If something takes the running slot while it works, the disk is restored and the answer is the machine, `stopped`, with `warnings` |
| `POST /machines/:id/fork` | `{ count? = 1, name? }`, up to 10 | `{ "machines": [Machine], "failed": [{ name, error }] }` (an error only if none could be made) |

Machines auto-resume when you exec, connect, or touch files or jobs on a suspended one, and auto-start when stopped. That restarts their bill, and so the answer says so: `resume_outcome` and `warnings` on an exec or connect (on the `exit` event when streaming), and an `X-Quivvo-Woke: resumed` or `cold_boot` header on files and jobs.

Suspend keeps memory and processes when the provider can, and otherwise stops the machine, which keeps only the disk. Both are a success. See [what suspend does](https://tryquivvo.com/docs/machines#what-does-suspend-actually-do).

A machine's `url` serves one port, the last published, and answers only while the machine runs: a request to it doesn't wake a suspended machine.

## Running commands

- `cmd` runs with `bash -lc`, and `argv` runs directly. Pass exactly one. `cwd` defaults to `/data/home`.
- Without `background`, `timeout_ms` is 280000 by default and at most. A command that hits it exits 124 with `timed_out` true. If waking the machine used up part of the request, the command gets less, and `warnings` says so. A waiting command whose caller goes away gets SIGTERM, then SIGKILL 3 seconds later, and its history entry has `cancelled` true.
- With `background`, `timeout_ms` is an hour by default and a day at most, and the job goes on if you disconnect. `stdin` can't be combined with it.
- `stdin` is text, up to 1 MB. For anything that isn't UTF-8, send `stdin_base64` instead.
- `stdout` and `stderr` keep at most 1 MiB each: the first 256 KiB and the last 768 KiB, joined by a line saying how many bytes were left out, with `truncated` true. `tail_bytes`, up to 1 MiB, keeps only the end.
- `cmd`, or `argv` in total, is limited to 128 KiB. Put anything longer in a file on the machine.
- Pass secrets in `env`. Command text is kept in history, and `env` isn't.
- `stdout` and `stderr` are text, so output that isn't UTF-8 comes back with replacement characters, and `warnings` says so. For binary data use the files API, or the stream below.
- History keeps a machine's newest 100 commands for 30 days, with the start of any command over 8 KiB, and goes when the machine is deleted. A workspace keeps the newest 8 MiB of it across its machines. A workspace can run 600 commands a minute through the API and make 1,200 changes of any kind, and past that gets `rate_limited`. So does creating more machines in an hour than 60 for each machine its plan runs at once: 60 on Free.

With `Accept: text/event-stream` you get Server-Sent Events with no output cap:

```text
event: stdout
data: {"data":"partial text"}

event: stderr
data: {"data":"..."}

event: exit
data: {"id":"ex_...","exit_code":0,"timed_out":false,"oom_killed":false,"duration_ms":812}
```

A chunk that isn't valid UTF-8 has replacement characters in `data` and the bytes the command wrote in `data_base64`. A comment line `: ping` keeps the stream open. `GET .../logs?follow=1` streams until the job ends or for about 280 seconds. If it's still running, follow on from the bytes you've got so far, with `offset`.

## When is a machine deleted?

Abandoned machines and snapshots are deleted, after an email warning and a final reminder 2 days before. At zero
credit everything goes 7 days after the warning (30 if the workspace has ever paid). On the welcome credit alone, a
machine not used for 14 days goes 7 days after the warning, and so do snapshots once no machine in the workspace has
been used for that long. Adding credit, resuming the machine or starting a machine from the snapshot cancels it. Active
paid plans are exempt. Without a working email sender, nothing is deleted.

## Snapshots

| Method & path | Body | Returns |
| --- | --- | --- |
| `GET /snapshots` | | `{ "snapshots": [Snapshot] }` |
| `DELETE /snapshots/:id` | header `X-Confirm-Delete` | `{ "deleted": true, "already_deleted"?: true }` |

Create a machine from one with `POST /machines { "snapshot": "base" }`.

## Usage and billing

- `GET /usage` → `{ "period_start", "period_end", "plan", "credit_balance", "cost_this_period", "machines": [{ id, name, deleted, running_seconds, cost }], "days": [{ date, cost }], "rates": {...} }`. Amounts are US dollars, to 8 decimal places.
- `GET /billing` (session) → plan, balance, ledger. `POST /billing/checkout` (session) `{ kind: "plan", plan } | { kind: "topup", amount }` → `{ url }`.

## Keys (`keys:write` or session)

- `GET /keys` → `{ "keys": [{ id, name, kind, prefix, scopes, created_at, last_used_at }] }`
- `POST /keys` `{ name, scopes? }` → `{ "key": {...}, "api_key": "qvk_..." }` (the secret is shown once)
- `DELETE /keys/:id` → `{ "deleted": true }`. Any key can revoke itself, with or without `keys:write`.

## Device sign-in (CLI and agents)

1. `POST /auth/device` `{ "kind": "cli"|"agent", "name": "agent@host", "scopes"?: [...] }` (no auth) →
   `{ "device_code", "user_code": "ABCD-EFGH", "verification_url", "verification_url_complete", "expires_in", "interval" }`.
   `cli` codes live 15 minutes, `agent` codes 24 hours.
2. The owner opens `verification_url_complete` signed in and approves, unticking any scopes, or runs
   `quivvo claim ABCD-EFGH` (needs `keys:write`), which calls `POST /auth/device/approve { user_code, scopes? }`.
   Without `scopes`, it grants what the sign-in asked for except `keys:write`, which has to be asked for by name. A key can't grant
   a scope it doesn't have. `POST /auth/device/deny { user_code }` refuses it. `GET /auth/device/request?code=` shows what's being
   approved, with `created_at` and `started_from`. A workspace can make 120 of these three calls an hour.
3. The device polls `POST /auth/device/token { device_code }` every `interval` seconds:
   `400 { error: { code: "authorization_pending" } }`, `400 access_denied`, `400 expired_token`, or
   `200 { "api_key": "qvk_...", "key": {...}, "workspace": { id, name }, "queue": {...} }`. Asking again within 10 minutes, if that answer got lost, returns a new key and revokes the first. That stops working once the key has been used or revoked, and a sign-in only ever holds one live key.

See [agent sign-up](https://tryquivvo.com/docs/agents) for how an agent does this without blocking.

## Webhooks

- `GET /webhooks`, `POST /webhooks { url, events? }` → `201 { webhook, secret }`, `DELETE /webhooks/:id`.
- `url` must be public `https`. Private, loopback and link-local addresses, internal names and a username or password in the URL are refused, and a name that resolves to a private address isn't delivered to. Redirects aren't followed.
- Events: `machine.ready`, `machine.suspended`, `machine.stopped`, `machine.failed`, `machine.deleted`.
- `POST` to your URL with `{ "id", "type", "created_at", "data": { "machine": Machine } }` and header
  `Quivvo-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>`.

## Feedback

`POST /feedback` `{ message, machine?, context? }` → `201 { id, received: true }`. `message` is up to 4000 bytes, `context` is any JSON up to 16 KiB, and a workspace can send 30 an hour. It keeps its newest 1 MiB of them, for 90 days. Any valid key can use it. Include a `request_id` if an error gave you one, and leave out secrets.

## MCP

Streamable HTTP at `https://tryquivvo.com/api/mcp`. Clients sign in with OAuth at the first tool call, or send `Authorization: Bearer qvk_...`. See [MCP server](https://tryquivvo.com/docs/mcp) for its tools.
