REST API
The base URL, authentication, JSON conventions, errors, request IDs, idempotency, pagination, waiting and rate limits.
The REST API is the interface everything else is built on: the CLI, the SDK and the MCP server all call it. Its base URL is https://phonebox.dev/v1, and the OpenAPI 3.1 document at /openapi.json describes every endpoint.
Requests
Authenticate every request with a project API key as a bearer token, and call the API from a server. Only the two terminal setup requests take no key. A request that carries an Origin header, as every browser request does, fails with 403 browser_requests_not_allowed. Authentication and keys covers keys and scopes.
Authorization: Bearer pbx_…
Content-Type: application/json- Send JSON bodies with
Content-Type: application/json. Most bodies may be up to 100 KB, and an action batch up to 200 KB. A file upload is the exception: its body is the file's raw bytes, up to 4 MB. - Bodies are checked strictly, so a field the endpoint doesn't know fails with
400 validation_failedinstead of being ignored. - The bodies of create, start, park and live links are optional. Leave them out to take every default.
- Give each query parameter at most once.
Timestamps are ISO 8601 in UTC. Durations are in seconds, except inside actions, where ms, duration_ms and timeout_ms are in milliseconds. Money is a string of US dollars with six decimal places, such as "0.060000", so that no amount is rounded.
Endpoints
| Method | Path | Scope | What it does |
|---|---|---|---|
POST | /v1/phones | phones:create | Creates a phone. |
GET | /v1/phones | phones:read | Lists phones, newest first. |
GET | /v1/phones/{id} | phones:read | Gets a phone, or waits for a status with wait. |
PATCH | /v1/phones/{id} | phones:control | Changes a phone's name or metadata. |
DELETE | /v1/phones/{id} | phones:delete | Deletes a phone permanently. |
POST | /v1/phones/{id}/start | phones:control | Starts a parked phone, or renews a running one. |
POST | /v1/phones/{id}/park | phones:control | Parks a phone. |
POST | /v1/phones/{id}/heartbeat | phones:control | Resets a phone's idle timer. |
GET | /v1/phones/{id}/sessions | phones:read | Lists a phone's sessions with their cost. |
GET | /v1/phones/{id}/observe | phones:read | Reads the screen. |
GET | /v1/phones/{id}/screenshot | phones:read | Returns a screenshot image. |
POST | /v1/phones/{id}/actions | phones:control | Runs a batch of actions. |
GET | /v1/apps/library | phones:read | Searches the app library: the apps a phone installs by package. |
GET | /v1/phones/{id}/apps | phones:read | Lists installed apps. |
POST | /v1/phones/{id}/apps | phones:control | Installs an app from Phonebox's app library, or your own upload, in the background. |
GET | /v1/phones/{id}/apps/installs | phones:read | Lists recent installs. |
DELETE | /v1/phones/{id}/apps/{package} | phones:control | Uninstalls an app. |
POST | /v1/apps/uploads | phones:control | Creates an upload of your own APK, and the URL its file goes to. |
GET | /v1/apps/uploads | phones:read | Lists the project's uploads. |
GET | /v1/apps/uploads/{id} | phones:read | Gets an upload, or waits until it is processed. |
POST | /v1/apps/uploads/{id}/complete | phones:control | Starts processing an upload once its file has arrived. |
DELETE | /v1/apps/uploads/{id} | phones:control | Deletes an upload. |
GET | /v1/phones/{id}/files | phones:read | Lists a directory. |
PUT | /v1/phones/{id}/files | phones:control | Uploads a file. |
GET | /v1/phones/{id}/files/content | phones:read | Downloads a file. |
DELETE | /v1/phones/{id}/files | phones:control | Deletes a file. Folders are deleted in the phone’s Files app. |
GET | /v1/phones/{id}/device | phones:read | Gets the brand, model, Android version, screen and carrier. |
GET | /v1/phones/{id}/clipboard | phones:control | Reads the clipboard. |
PUT | /v1/phones/{id}/clipboard | phones:control | Sets the clipboard. |
GET | /v1/phones/{id}/location | phones:read | Reads the location. |
PUT | /v1/phones/{id}/location | phones:control | Sets the location and, by default, the timezone found there. |
DELETE | /v1/phones/{id}/location | phones:control | Resets the location. |
GET | /v1/phones/{id}/locale | phones:read | Reads the locale. |
PUT | /v1/phones/{id}/locale | phones:control | Sets the locale. |
GET | /v1/phones/{id}/timezone | phones:read | Reads the timezone. |
PUT | /v1/phones/{id}/timezone | phones:control | Sets the timezone. |
POST | /v1/phones/{id}/reboot | phones:control | Reboots the phone. |
POST | /v1/phones/{id}/reset | phones:delete | Wipes the phone's apps, files, accounts and clipboard, keeping its ID. |
POST | /v1/phones/{id}/recordings | phones:control | Starts recording the screen. |
GET | /v1/phones/{id}/recordings | phones:read | Lists the phone's recordings. |
POST | /v1/phones/{id}/recordings/{rid}/stop | phones:control | Stops a recording. |
GET | /v1/phones/{id}/recordings/{rid}/video | phones:read | Downloads a recording's video. |
DELETE | /v1/phones/{id}/recordings/{rid} | phones:control | Deletes a recording. |
POST | /v1/phones/{id}/live | phones:control | Creates a live view link. |
GET | /v1/account | phones:read | Returns the balance, limits and price. |
POST | /v1/credits/checkout | phones:control | Creates a checkout link where a person buys credits. |
POST | /v1/setup | No key | Starts terminal setup: a code for a person to confirm in the browser. |
POST | /v1/setup/token | No key | Returns the new key once the person has confirmed. |
Every request that reaches into the phone, which means observing, screenshots, actions, apps, files, device settings and live links, needs the phone to be ready. For a phone in any other status, it fails with the reason and a next step, such as 409 phone_not_running with "next": "POST /v1/phones/ph_7kx2m6q4v3ta/start".
Responses
Responses are JSON, except the text form of observe, screenshots and file downloads. Every response, errors included, carries these headers:
| Header | Meaning |
|---|---|
X-Request-Id | The request's ID, such as req_5f7a2c4d6e3b. |
Cache-Control | Always no-store. |
Include the request ID when you contact support about a request. The console's activity page shows your requests by the same IDs.
A successful request answers 200 OK. A created phone, an uploaded file, a new live link and a new recording answer 201 Created. A phone that is still creating, starting or parking when a wait ends answers 202 Accepted, and so do an app install, a reboot and a reset, which continue in the background.
Errors
Every error has the same envelope:
{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "The request is invalid.",
"retryable": false,
"next": null,
"request_id": "req_k4m2n7p3q5r6",
"details": {
"issues": [{ "path": ["idle_timeout"], "message": "Too big: expected number to be <=3600" }]
}
}
}| Field | Meaning |
|---|---|
type | The error's family: authentication_error, permission_error, invalid_request_error, not_found_error, conflict_error, billing_error, rate_limit_error, provider_error or api_error. |
code | The exact error. Branch on this. |
message | A sentence for people. It can change, so don't parse it. |
retryable | Whether the same request may succeed later without changes. A failed phone's own failure, which names the phone in details.phone, is never retryable, and neither is an internal_error on a request other than a GET, because its write may have taken effect. |
next | The next step, such as the request to make, when there is a useful one. Otherwise null. |
request_id | The same ID as the X-Request-Id header. |
details | Facts about this error, such as issues for a validation error or phone for a failed phone. |
The HTTP status follows the code:
| Status | Codes |
|---|---|
| 400 | validation_failed, with details.issues naming each problem |
| 401 | invalid_api_key, key_expired, key_revoked |
| 402 | insufficient_credits, spend_limit_reached, project_frozen |
| 403 | insufficient_scope, phone_not_allowed, browser_requests_not_allowed |
| 404 | phone_not_found, file_not_found, app_not_found, and not_found for a path that doesn't exist |
| 405 | method_not_allowed, with an Allow header listing the path's methods |
| 409 | phone_not_running, phone_starting, phone_parking, phone_deleted, phone_failed, idempotency_conflict, install_in_progress, running_limit_reached, phone_limit_reached |
| 413 | payload_too_large |
| 422 | app_not_available |
| 429 | rate_limited, with Retry-After |
| 500 | internal_error |
| 502 | provider_error, action_outcome_unknown |
| 503 | capacity_unavailable with Retry-After (a failed phone's own failure has none), phone_unavailable, service_paused |
| 504 | provider_timeout |
An action that fails inside a batch reports its error in its own result, in the same shape without type and request_id, while the batch itself answers 200. Those results use further codes, such as target_not_found and wait_timeout. Actions and targets lists them.
A phone that doesn't exist and a phone in another project both answer 404 phone_not_found, so a key learns nothing about other projects.
Idempotency
Idempotency-Key makes a create safe to repeat. The key holds 8 to 128 characters, which are letters, digits, _, ., : and -, and it starts with a letter or digit. A repeat with the same key and the same body within 24 hours returns the phone the first request created, as it is now. If that phone has failed, or was deleted, a repeat only returns it again, so create the next phone with a new key. A repeat with a different body fails with 409 idempotency_conflict, which names the phone the key created in details.phone: send the original body with that key to get it, because a new key would create a second phone. wait isn't compared, so one key works across the REST API, MCP, the SDK and the CLI, whether each of them waits or not.
Every POST checks the format of an Idempotency-Key it is given, but only create acts on one. Park and heartbeat are safe to repeat as they are. A start on a running phone renews its session and reserves credit again, so read the phone before you repeat one. An action batch is never replayed, so a repeated batch runs again. Don't repeat one unless Phonebox's error says nothing ran.
Pagination
GET /v1/phones and GET /v1/phones/{id}/sessions return a page:
{
"data": [
{
"id": "ph_7kx2m6q4v3ta",
"object": "phone",
"name": "checkout-test",
"status": "parked",
"country": null,
"metadata": { "customer": "acme" },
"created_at": "2026-09-29T10:00:00.412Z",
"last_active_at": "2026-09-29T14:41:18.520Z",
"session": null,
"failure": null
}
],
"next_cursor": "Q1pXk7Rb2mTn8VwLc4Hy9sJf3GdA6uEo"
}limit sets the page size, from 1 to 100, with a default of 50. To get the next page, send next_cursor back as cursor. next_cursor is null on the last page. Treat cursors as opaque: an invalid or expired one fails with 400 validation_failed.
GET /v1/phones lists phones newest first and leaves out failed and deleted ones, which can't be used again, unless you ask for them with status. It takes two filters:
status, such as?status=ready;- one metadata pair, such as
?metadata[customer]=acme.
A key limited to certain phones gets all of its phones on one page.
Waiting
POST /v1/phones, POST /v1/phones/{id}/start and POST /v1/phones/{id}/park wait for the phone by default, and GET /v1/phones/{id}?wait=ready or ?wait=parked waits on request. Every wait answers within about 50 seconds. A poll's timeout goes up to 55 seconds, with a default of 50. When the wait ends before the phone gets there, the answer is 202 from a lifecycle request, or the phone in its current status from a poll. Poll again while the phone is creating or starting for ready, or parking for parked. Any other status ends the loop: read the phone's status and failure and act on them.
The limit exists because a waiting request sends nothing until it answers, and many proxies and load balancers close a connection that stays silent for 60 seconds. Waits answer within about 50 seconds, but an action batch can take much longer: it starts no new action after 90 seconds, an action that has started still runs to its end, and a request that runs a batch may take up to 300 seconds at the platform. Give your HTTP client a timeout of about 300 seconds for batches, as the SDK does with 310, because a client that gives up sooner can leave a batch's outcome unknown. For other requests, about 130 seconds is enough.
Lifecycle and timers describes every wait in detail.
Retries
- Phonebox retries its own reads of the phone for up to about 15 seconds, which covers the short restarts a phone can have right after it becomes ready. You may repeat any read.
- When an error's
retryableistrue, the same request may succeed later. Wait forRetry-Afterwhen the response has one, or a few seconds when it doesn't. For a write,retryabledoesn't mean that the first attempt did nothing, so follow the rules below. - Repeat a create only with the same
Idempotency-Key, never without one. After a create whose phone failed or was deleted, create the next phone with a new key. - Phonebox never retries an action, and neither should your code, because an action might have taken effect. A batch refused with any error in Phonebox's error envelope except
500 internal_errorran nothing, so you may send it again once you've dealt with the error, as When nothing ran explains. In a batch that ran, an action result whose error says the action didn't run, such asphone_unavailablewithretryableset totrue, lets you send that action and the ones after it again. Otherwise, observe the screen and decide. - Only an answer in Phonebox's error envelope says what happened. A
502or504without it, which a proxy or the hosting platform sends, a timeout or a dropped connection leaves the outcome unknown. Repeat reads, starts, parks and heartbeats, repeat a create only with the sameIdempotency-Key, and observe before you repeat anything else. Failures without the envelope has the details.
Rate limits
| Limit | Scope |
|---|---|
| 600 requests a minute | Each API key, across all its requests. |
| 60 lifecycle operations a minute | Each project, across creates, starts, parks and deletes. |
| 1 reboot every 10 minutes | Each phone. |
| 1 reset a minute | Each phone. |
A request over a limit fails with 429 rate_limited and a Retry-After header in seconds. When the wait is known exactly, as for a reboot, details.retry_after_seconds says it too. Phonebox counts the request limit on each of its servers, so treat it as approximate, while the lifecycle limit is exact.