Errors
The error envelope, what next, details and retryable mean, and every code the API and the MCP server can return.
Every error that Phonebox sends has the same envelope, whatever the endpoint, and the HTTP status follows its code:
{
"error": {
"type": "conflict_error",
"code": "phone_not_running",
"message": "The phone isn't running.",
"retryable": false,
"next": "POST /v1/phones/ph_7kx2m6q4v3ta/start",
"request_id": "req_e2f5g3h6j4k7",
"details": { "status": "parked" }
}
}| 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 never parse it. |
retryable | Whether the same request may succeed later without any change. A failed phone's own failure, which names the phone in details.phone while the phone's status is failed, is never retryable, whatever its code. |
next | The next step, or null. |
request_id | The request's ID, the same as its X-Request-Id header. |
details | Facts about this error, or {}. |
next names the step that moves you forward when there is a useful one, often the exact request to make, such as POST /v1/phones/ph_7kx2m6q4v3ta/start, and it is null otherwise. details holds machine-readable facts about this particular error, such as issues, phone, required, candidates or retry_after_seconds, and a code may gain new fields in it over time.
A failure without this envelope doesn't come from Phonebox, and it leaves the outcome unknown, as Failures without the envelope explains.
For validation_failed, the message also names the first invalid field. details.issues includes path, code and message, plus expected types, received types, bounds or union alternatives when available. details.validation_stage identifies the check that rejected the request. Correct the fields before sending again; request validation failures ran no phone actions. Save the request ID when reporting a server error. SDK/CLI validation before a request is sent has no request ID.
Codes
These are all the codes the API and the MCP server can return. A code you don't recognize may be added later, so treat it by its HTTP status and retryable.
| Code | HTTP | Type | Retryable | Message | What to do |
|---|---|---|---|---|---|
validation_failed | 400 | invalid_request_error | false | The request is invalid. | Fix each problem that details.issues lists as {path, message}, then send the request again. With details.phone, the phone service refused the phone you asked for, usually its country. That phone has failed, so this error isn't retryable: create a new phone, with a new idempotency key if you use one, without that country or with another. |
invalid_request | 400 | invalid_request_error | false | The request is invalid. | The MCP server refuses a JSON-RPC batch with this code. Send one message per request. |
invalid_api_key | 401 | authentication_error | false | The API key is missing or invalid. | Send Authorization: Bearer pbx_… with a whole key from the console. |
key_expired | 401 | authentication_error | false | This API key has expired. | Create a new key in the console. |
key_revoked | 401 | authentication_error | false | This API key was revoked. | Use another key. If you didn't revoke this one, check the console's activity for calls you don't recognize. |
unauthorized | 401 | authentication_error | false | Sign in to continue. | On the API, this means Phonebox's own servers are misconfigured. Contact support with the request ID. |
insufficient_credits | 402 | billing_error | false | There aren't enough credits to reserve this session. | Add credits with phonebox setup or in the console, or ask for a shorter max_duration, at most details.max_affordable_seconds. |
spend_limit_reached | 402 | billing_error | false | This session would exceed the project's monthly spending limit. | Raise the monthly spending limit in Settings, or ask for a shorter max_duration. |
project_frozen | 402 | billing_error | false | This project is frozen. Contact support. | Contact support. A refund that leaves too little credit for running phones, or a review of how the project is used, freezes it. |
insufficient_scope | 403 | permission_error | false | This key doesn't have permission for this action. | Use a key with the scope that details.required names. A key limited to certain phones can't create phones. |
phone_not_allowed | 403 | permission_error | false | This key is restricted to other phones. | Use a key that may use this phone. |
browser_requests_not_allowed | 403 | permission_error | false | Call the API from a server, not a browser. | Call Phonebox from your server, and give a person a live view link instead of a key. |
setup_denied | 403 | permission_error | false | This setup request was declined in the browser. | The person chose Cancel on the confirmation page. Start terminal setup again only if they ask you to. |
not_found | 404 | not_found_error | false | The resource was not found. | No endpoint exists at this path. Check it against the endpoint list. |
phone_not_found | 404 | not_found_error | false | No phone with this ID exists in the project. | Check the ID in details.phone. A phone in another project answers the same way. |
file_not_found | 404 | not_found_error | false | No file exists at this path. | List the parent directory to see what is at details.path. |
app_not_found | 404 | not_found_error | false | This app isn't installed on the phone. | Install the app, or check the package name in details.package. An install that hasn't finished answers this too. |
upload_not_found | 404 | not_found_error | false | No upload with this ID exists in the project. | Check the ID in details.upload. An upload in another project answers the same way. |
recording_not_found | 404 | not_found_error | false | No recording with this ID exists for this phone. | Check the ID with GET /v1/phones/{id}/recordings. Recordings are deleted 7 days after they start, and an ID from another phone is no recording of this one. |
method_not_allowed | 405 | invalid_request_error | false | This method isn't supported on this path. | Use a method from the Allow header. |
phone_not_running | 409 | conflict_error | false | The phone isn't running. | Start the phone with the request in next. details.status is parked or unavailable. |
phone_starting | 409 | conflict_error | true | The phone is still starting. | Wait with GET /v1/phones/{id}?wait=ready, then send the request again. |
phone_parking | 409 | conflict_error | true | The phone is parking. | Wait with GET /v1/phones/{id}?wait=parked, then start the phone. |
phone_deleted | 409 | conflict_error | false | The phone was deleted. | Create a new phone. A deleted phone and its data can't be brought back. |
phone_failed | 409 | conflict_error | false | The phone failed and can't be used. Create a new phone. | Create a new phone, with a new idempotency key if you use one: the old key returns this phone. A failed phone holds no device, costs nothing from then on, and doesn't count toward your phone limit. |
idempotency_conflict | 409 | conflict_error | false | This Idempotency-Key was already used with a different request. | details.phone names the phone the key created. Send the original body with that key to get it back. A new key would create a second phone, so use one only when you want another phone. |
install_in_progress | 409 | conflict_error | true | The phone can't start another install yet. | Another install is still running on this phone, which runs at most two at once, and one per app. Installs take a few seconds, so send it again shortly, a few times at most. GET /v1/phones/{id}/apps/installs shows when the running install has finished, or failed. |
version_downgrade | 409 | conflict_error | false | A newer version of this app is installed on the phone. | The phone has details.installed_version_code of the app, newer than the upload's details.version_code. Build with a higher versionCode, or install with replace: true, which uninstalls the app first and deletes its data. |
upload_not_ready | 409 | conflict_error | false | This upload isn't ready to install. | details.status says why. Wait for a processing upload with GET /v1/apps/uploads/{id}?wait=ready, complete an awaiting_file one, and upload the APK again for any other status. A ready upload that answers this with retryable: true is being prepared for the phone: send the install again in a minute. |
storage_limit_reached | 409 | conflict_error | false | The project's uploads already use all of its storage. | Delete uploads you no longer need with DELETE /v1/apps/uploads/{id}. details.limit_bytes is the project's limit, and details.used_bytes what its uploads hold. |
clipboard_unavailable | 409 | conflict_error | false | The phone can't read its clipboard right now. | The phone's default text input was switched off, so its clipboard can't be read. Switch it back in the phone's Settings, or read the text on screen with GET /v1/phones/{id}/observe. Setting the clipboard still works. |
recording_in_progress | 409 | conflict_error | false | A recording is already running on this phone. | A phone records one video at a time. Find the running one with GET /v1/phones/{id}/recordings and stop it with POST /v1/phones/{id}/recordings/{rid}/stop, then start again. |
recording_not_ready | 409 | conflict_error | true | This recording's video isn't ready yet. | Stop the recording if it is still running, then list the phone's recordings until its status is ready, and download it again. |
recording_limit_reached | 409 | conflict_error | false | The project already keeps 20 recordings. | Delete a recording you no longer need with DELETE /v1/phones/{id}/recordings/{rid}, on any of the project's phones, or wait for one to expire 7 days after it started. A deleted phone's recordings count until they expire. |
duplicate_upload | 409 | conflict_error | false | This file is already one of the project's uploads. | An upload's error has this code when its file is another upload of the project, which is ready: next names that upload, and completing answered with it. Install that upload. |
setup_expired | 409 | conflict_error | false | This setup request expired. Start setup again. | The code wasn't confirmed, or the key wasn't collected, within 15 minutes. Start terminal setup again and show the new link and code. |
setup_used | 409 | conflict_error | false | This setup request already delivered its key. Start setup again. | A setup request hands out its key once. Use the key you stored. If its answer was lost, start setup again and revoke the lost key in the console. |
key_limit | 409 | conflict_error | false | The project already has 50 active keys. | Terminal setup couldn't add a key to the project the person chose. Revoke a key you no longer use on the console's API keys page, then start setup again. |
running_limit_reached | 409 | conflict_error | false | The project already has its maximum number of running phones. | Park only a phone you created for this task and no longer need, never another agent's. Otherwise stop and tell your user, who can ask us for a higher limit at team@phonebox.dev. details.limit is the current one, which counts all of your projects together. |
phone_limit_reached | 409 | conflict_error | false | The project already has its maximum number of phones. | Delete only a phone you created and no longer need. In a project that several agents share, tell your user instead, or ask us for a higher limit. details.limit is the current one. |
payload_too_large | 413 | invalid_request_error | false | The request body is too large. | Send less: at most 100 KB of JSON, 200 KB for an action batch, and 4 MB for a file. A file download over 4 MB fails the same way. An upload's error has this code, with the message "The file is larger than an upload takes.", when its APK is over 500 MiB: upload a smaller build. |
target_not_found | 422 | invalid_request_error | false | No element on the screen matches the target. | Observe, then use one of details.candidates, or scroll to bring the element into view. |
ambiguous_target | 422 | invalid_request_error | false | Several elements on the screen match the target. | Target a candidate by ref with details.snapshot_id, or add nth to a text target. Only text targets take nth, so narrow an ID or description target instead. |
stale_snapshot | 422 | invalid_request_error | false | The screen changed since that snapshot was taken. | Observe again and use the new refs. |
no_focused_field | 422 | invalid_request_error | false | No text field is focused on the screen. | Tap the text field, then type. |
wait_timeout | 422 | invalid_request_error | true | The condition wasn't met before the timeout. | Observe. If the screen is still loading, wait again with a longer timeout_ms. |
app_not_available | 422 | invalid_request_error | false | This app isn't available to install. | The app isn't in Phonebox's app library, where an install by package looks; it never reaches Google Play. Check the package name, or install the app from the Play Store app on the phone. |
invalid_apk | 422 | invalid_request_error | false | This file isn't an APK Phonebox can install. | An upload's error has this code when its file isn't an APK Phonebox can read, and next says why. Build an APK, for example with ./gradlew assembleDebug, and upload that. An Android App Bundle (.aab) or a split APK can't be installed on its own. |
install_failed | 422 | invalid_request_error | false | The phone couldn't install this app. | An install by upload failed on the phone: its row in GET /v1/phones/{id}/apps/installs has this code, and next says what to try. When the phone already had the app, a build signed with another key is the usual cause: uninstall the app, or install with replace: true. |
feature_unavailable | 422 | invalid_request_error | false | This phone doesn't support this feature. | details.feature names the feature this phone doesn't offer, such as recordings, reset or timezone. Nothing was sent to the phone. Carry on without it. |
rate_limited | 429 | rate_limit_error | true | Too many requests. Wait a moment and try again. | Wait for Retry-After, then try again. A reboot's limit and the phone service's also give the wait in details.retry_after_seconds. |
provider_error | 502 | provider_error | true | The phone service returned an error. | Send a read again after a few seconds. After a write, observe the phone before you send it again. |
action_outcome_unknown | 502 | provider_error | false | The phone didn't confirm this action. | Observe before anything else, and never repeat the action blindly. See Unknown outcomes. |
upload_failed | 502 | provider_error | true | The upload couldn't be stored. | An upload's error has this code when Phonebox couldn't store its file. Create a new upload and send the file again. |
capacity_unavailable | 503 | provider_error | true | No phones are available right now. | Try again after Retry-After. With details.phone, that phone couldn't get a device. A new phone has then failed, and the error is retryable: false with no Retry-After: create a new phone, with a new idempotency key if you use one. A started phone parked again instead, and the error stays retryable: start it again later. |
phone_unavailable | 503 | provider_error | true | The phone is temporarily unavailable. | Nothing was done on the phone. Try again after Retry-After, or after a few seconds when there is none. With details.phone, the phone itself became unavailable: start it again later. |
service_paused | 503 | api_error | true | Phonebox is paused for maintenance. | Try again later. Your phones and everything on them are kept. |
storage_full | 503 | api_error | true | Phonebox can't store more apps right now. | Phonebox's app storage is full. Creating an upload answers it, and completing one does too, keeping the file for the rest of the upload's hour so that completing it again later works. An upload's error has this code when the storage filled while it was processing: try again later with a new upload. Contact support if it lasts. |
billing_not_configured | 503 | api_error | true | Payments aren't connected yet. | Credits can't be bought on this deployment yet, so no checkout link was created. Try again later, and contact support if it lasts. |
platform_not_configured | 503 | api_error | true | The platform isn't configured yet. | Phonebox is missing a setting. Try again later, and contact support if it lasts. |
provider_timeout | 504 | provider_error | true | The phone didn't respond in time. | With details.phone, the phone didn't become ready in time: a new phone failed, so the error isn't retryable and you create another, with a new idempotency key if you use one, and a started phone parked again, so you start it again. In an action result, the batch ran out of time: observe, then send the actions that didn't run, which details.not_run counts, in a new batch. |
internal_error | 500 | api_error | true | Something went wrong on our side. | Keep the request ID. A GET's error is retryable, so send the read again. On any other request, the write may have taken effect, so the error has retryable set to false and next says to read the phone before you retry. Contact support if it keeps happening. |
Errors inside a batch
An action that fails inside a batch reports its error in its own result, with code, message, retryable, next and details, while the batch itself answers 200. Codes such as target_not_found and wait_timeout appear only there. Action errors lists what each one means for the batch.
Waiting with Retry-After
A 429 rate_limited and a 503 capacity_unavailable carry a Retry-After header in seconds: the limit's own wait when it has one, and 30 seconds otherwise. A failed phone's own failure carries none, because it isn't retryable. A 503 phone_unavailable carries one only when the wait is known, which is 2 seconds when the phone's control service was briefly out of reach. Other errors carry none.
Failures without the envelope
Only an answer in this envelope, with its error.code, comes from Phonebox, so only such an answer says what happened to your request. Anything else leaves the outcome unknown:
- a
502or504whose body isn't this JSON, which a proxy, a load balancer or the hosting platform sends, for example when a request runs past its time limit; - a timeout on your side;
- a dropped connection, or no answer at all.
The request may have finished, partly finished or never started. Send a read again. Send a create again only with the same Idempotency-Key, and a park or heartbeat as it was, since they are safe to repeat. Read the phone before you send a start again: if it is starting or ready, it is running, and another start would renew its session, reserving credit again. Before you send any other write again, such as an action batch, an install or a file upload, observe the phone to find out whether it happened, as Unknown outcomes describes.
The SDK and the CLI report an answer without the envelope as internal_error, with its HTTP status in status. They read a body as Phonebox's envelope only when the answer carries an X-Request-Id header, which every answer from Phonebox has, so a proxy's JSON error isn't taken for one. The CLI reports a failed connection or a timeout as internal_error too, while the SDK throws the error that fetch gave it, such as a TypeError or a TimeoutError.
Errors over MCP
The MCP server answers a refused tool call with isError set to true and this same envelope as its content. MCP has no headers, so a Retry-After wait appears as details.retry_after_seconds. Arguments that don't match a tool's schema are refused in the MCP library's own shape instead. MCP shows both.
Terminal setup
The two requests a terminal or an agent makes to get an API key without the console, after a person confirms a short code in the browser.
Test your Android build with a coding agent
Install the APK your coding agent just built on a Phonebox phone in one command, check its version, then have the agent walk the new screens and report what it saw.