Requests and waiting
How asynchronous Cubicle HTTP API requests work, with 202 Accepted and desired state, following a request by polling a Box's state, generation and ssh.ready, failure and superseded signals, deletion, exec, snapshot and restore operations, Idempotency-Key retries and polling budgets.
Requests that change a Box return 202 Accepted as soon as Cubicle has recorded them. The Box's
Node carries them out afterwards, and you poll until the change is done.
| Request | Response | Poll | Done when |
|---|---|---|---|
| Create a Box | 202 with the Box | Get a Box | ssh.ready is true. With start: false: state is stopped and observed.generation equals desired.generation. |
| Start a Box | 202 with the Box | Get a Box | ssh.ready is true. |
| Stop a Box | 202 with the Box | Get a Box | state is stopped and observed.generation equals desired.generation. |
| Hibernate a Box | 202 with the Box | Get a Box | state is hibernated and observed.generation equals desired.generation. |
| Delete a Box | 202 with the Box | List an organization's Boxes | The Box's ID is no longer listed. |
| Run a command, Take a snapshot, Restore a Box | 202 with an operation | Get an operation | state is done, failed or unknown. |
Every other route finishes its work before it responds with 200, 201 or 204. Access changes,
such as a new grant or SSH key, are recorded at once and applied inside running Boxes shortly after:
a grant's appliedRevision equals its desiredRevision once the Box has applied it.
The 202 response shows the new desired state
A lifecycle response is the Box right after the request. desired holds the new state and
generation, but state and observed are still the Node's last report. A start of a stopped Box
returns, in part:
{
"state": "stopped",
"desired": { "state": "running", "generation": 3 },
"observed": {
"state": "stopped",
"generation": 2,
"observedAt": "2026-11-05T10:15:04.000Z",
"ageSeconds": 2,
"fresh": true,
"error": null
},
"ssh": { "ready": false, "reason": "not_running" }
}Do not read state in a 202 response as the result. Box lifecycle explains
desired and observed state, generations and every state.
Following a request
Poll Get a Box about once a second, and check each response in this order:
- Is the desired state still yours?
desired.statemust still be the state you asked for:runningafter a create or a start. If it has changed, another request replaced yours, such as someone else's stop or automatic hibernation. Stop waiting. A404means the Box has been deleted.- After a hibernate,
desired.stateback atrunningwithhibernationRetryAtset means saving the memory failed. The Box keeps running,observed.errorsays why, and Cubicle tries again athibernationRetryAt.
- After a hibernate,
- Is it done? Compare with the table above.
ssh.readyistrueonly at the current generation, so it needs no generation check. Astoppedorhibernatedreport counts only whenobserved.generationequalsdesired.generation: a report at a lower generation predates your request. - Did it fail?
state: "failed"withobserved.errormeans the start failed or the guest stopped on its own. Cubicle does not retry it; start the Box again, stop it or delete it. Afailedreport keeps the generation the Node last applied, so it can be left over from an earlier start. It belongs to your request when either holds:observed.ageSecondsis less than the seconds since you sent the request. Allow 1 s for rounding. The Node repeats a current failure about every 10 s, so a leftover report grows older than your request.- Since your request, you have seen a state other than
failed,unknownorpending.
- Does it need a new start? While you wait for
ssh.ready,restartRequired: truemeans the Node's host restarted. The Box does not start until you send another start. - Otherwise, keep polling.
pending,provisioning,starting,stoppingandhibernatingare progress.unknownmeans the Node has not reported recently: treat the Box as neither done nor failed.
The SDK's lifecycle methods follow these rules for you, up to 5 minutes by default:
// Waits until ssh.ready is true.
await cubicle.boxes.start("acme/job-1")
// Or accept the request now and wait separately, with your own deadline.
await cubicle.boxes.stop("acme/job-1", { wait: false })
const info = await cubicle.boxes.waitUntil("acme/job-1", "stopped", { timeoutMs: 600_000 })
console.log(info.state) // => "stopped"A wait that fails rejects with a CubicleBoxError or a CubicleTimeoutError; see
Waiting.
Set a deadline of your own. Preparing the Box's disk comes first and can take several minutes. After
that, a guest that does not answer within 180 s of its VM starting fails with guest did not become reachable.
Deletion
Delete a Box returns 202 with deletion: "pending", and
Get a Box returns 404 from then on. List an organization's
Boxes and List the caller's
Boxes keep showing the Box, with deletion: "pending",
until its Node has removed it and released its resources. Deleting a
Box explains what deletion does.
To wait for the removal, poll the listing for the Box's ID, not its name: a new Box may take the name.
api=https://api.cubicle.sh/v1
auth="Authorization: Bearer $CB_SDK_API_KEY"
id=0f6c2a5e-3b1d-4c8e-9a7f-2d4e6b8c1a3f
curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$api/boxes/$id" -H "$auth"
while :; do
listed=$(curl -sS "$api/orgs/acme/boxes" -H "$auth" |
jq --arg id "$id" 'if type == "array" then any(.[]; .id == $id) else true end')
[ "$listed" = true ] || break
sleep 2
doneThe SDK's boxes.delete() waits like this for you. cubicle boxes delete --wait returns as soon as
the Box stops resolving; see cubicle boxes delete.
Operations
Run a command,
Take a snapshot and
Restore a Box return 202 with an operation in state
pending. Poll Get an operation with its id until
state is done, failed or unknown. Operations record every
request explains each state, and each
endpoint page lists its preconditions and errors.
- Wait until
ssh.readyistruebefore you send an exec. An exec accepted while the Box is still starting fails at the Node. - After a
donesnapshot, List a Box's snapshots shows the snapshot with thisoperationIdandstatus: "complete". - After a
donerestore, the Box is still stopped. Start it. - A snapshot or restore interrupted by a restart of the Node's service is carried out again rather
than ending
unknown.
exec, snapshot and restore wait for their operation themselves: an exec for its timeoutMs
plus 2 minutes, a snapshot or restore for 15 minutes.
const result = await box.exec(["make", "test"])
console.log(result.exitCode) // => 0
// Follow an operation by its ID, for example after a CubicleTimeoutError.
const operation = await cubicle.operations.wait("9d2e4f6a-1b3c-4d5e-8f7a-6b8c0d2e4f1a")
console.log(operation.state) // => "done"Idempotency keys
A request can reach Cubicle even when its response is lost. An Idempotency-Key header lets you
send it again without doing the work twice.
| Route | First request | Same key, same body |
|---|---|---|
| Create a Box | 202 with the new Box | 200 with that Box as it is now, even if it has been deleted since. Nothing is created. |
| Run a command | 202 with an operation | 202 with the original operation, in its current state. Nothing runs. |
| Take a snapshot | 202 with an operation | 202 with the original operation. |
| Restore a Box | 202 with an operation | 202 with the original operation. |
- Only these four routes read the header. Every other route ignores it.
- A key is 1 to 200 characters; anything else fails with
400 invalid_request. A UUID per logical request works well. - A key is scoped to the organization, the caller and the route. The same key used by another caller, or on another of these routes, is unrelated.
- The body is compared after validation, ignoring key order and the fields the API ignores. For an
exec, only
argv,timeoutMs,maxOutputBytesandcwdcount, with defaults and caps applied, so leaving outtimeoutMsmatches sending60000. For an exec, a snapshot and a restore, the comparison includes the Box: the same key sent to another Box in the organization fails with409 idempotency_conflict. - The same key with a different body fails with
409 idempotency_conflict. Use a new key. - A request that fails, such as
409 conflictor503 capacity, records nothing. Retry it with the same key. - Two requests with the same key at the same time create one resource: the second waits for the first and returns its result.
- An exec, snapshot or restore replay still checks your access and that the Box exists. It returns
the original operation even if the Box has stopped or started since, but
404once the Box is deleted. - A create repeated after its Box was deleted still returns that Box: with
deletion: "pending"while its Node removes it, then withstate: "deleted"anddeletion: "complete". - Keys are kept for 7 days. Do not rely on a key after that.
- An exec that ended
unknowncomes backunknownfor the same key. To run the command again, use a new key.
Start, stop, hibernate and delete take no key. Repeating a start or stop leaves the same desired
state, but advances the generation
again, so a repeated start briefly keeps SSH and private-network traffic from reaching the Box. A
repeated delete returns 404. Send deletes by ID, so that a retry cannot delete a new Box that took
the name.
- The SDK sends a new key with every create, exec, snapshot and restore, and reuses it when it
retries that request. Pass
idempotencyKeyto keep the same key across processes. See Idempotency keys in the SDK. - The CLI sends a new key with each run of
cubicle boxes create, and none withcubicle exec, so runningcubicle execagain runs the command again. The CLI does not retry requests.
Polling budget
Every poll spends from the request budget that all API keys and sessions of
your user or service account share; Stay within budget
shows how to follow many Boxes with one request and what to do on a 429.
Authentication
Authenticate Cubicle HTTP API requests with an Authorization Bearer header and a service account API key (cbk_) or a sign-in session (jvs_), covering routes that accept keys, read and write scopes, the organization fence, authentication errors, token lifetimes and the device authorization flow for tools that sign people in.
Errors
The Cubicle HTTP API error envelope with code and message, every error code the public routes return (invalid_request, unauthenticated, forbidden, not_found, conflict, idempotency_conflict, rate_limited, capacity and more) with its HTTP status and what to do, conflict messages, and which errors are safe to retry.

