Skip to content

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. 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:

{ "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 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

{ "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 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 POSTs (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

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.

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:

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 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 for its tools.

Updated 2 November 2026

Give your agent a computer

npx quivvo@latest sh
Get a free VM