# Phonebox complete documentation

---

OpenAPI: https://phonebox.dev/openapi.json

---

## Rules for agents

- Always park a phone when you're done, even when the task failed. A phone bills from the moment it exists until it parks.
- Send an `Idempotency-Key` with every create, one key for each phone you want, and repeat a create only with the same key and body.
- Send an action batch again only when Phonebox's own error says nothing ran: any error in its envelope except `500 internal_error`. After any other failure, observe the screen first.
- Treat a live view link as a credential. Whoever opens it controls the phone, and every account signed in on it, until it expires, so give it only to your user.

---

# Introduction

Source: https://phonebox.dev/docs

> Phonebox gives AI agents cloud Android phones they can see and control.



Phonebox gives your agent an Android phone in the cloud: its screen, its installed apps, touch input and the keyboard. Your agent reads the screen as a short numbered list of elements, taps them by their label, types, swipes, installs and opens apps, and moves files.

Phonebox is not a phone number or an SMS inbox, and it doesn't make or receive calls. Your agent controls the device itself.

## Who it's for [#who-its-for]

* Coding agents such as Claude Code, Codex and Cursor use it to test the Android builds they produce: `phonebox apps "$PHONEBOX_PHONE" install app-debug.apk` installs the APK an agent just built, and the agent then walks the screens it changed. [Test your Android build with a coding agent](/docs/guides/test-android-builds) shows how.
* Agents use it to operate apps that have no API, where the screen is the only way in.
* Teams use it to build agent products on phones for their own customers, with one phone for each customer.

## How it works [#how-it-works]

You create a phone with one call. Your agent then works in a loop: it reads the screen, acts on it, and reads it again to see what changed. When your agent is done, it parks the phone, and a phone that no request addresses parks itself after about 5 minutes. A parked phone keeps its apps, files and signed-in accounts, and it costs nothing until it starts again.

## How the pieces fit [#how-the-pieces-fit]

Every interface calls the same service, so keys, limits and billing work the same way whichever you use.

| Interface                              | Use it for                                                                                       |
| -------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [REST API](/docs/interfaces/rest)      | Any language. The API lives at `https://phonebox.dev/v1` and takes an API key as a bearer token. |
| [CLI](/docs/interfaces/cli)            | Agents that work in a terminal, and quick checks by hand.                                        |
| [TypeScript SDK](/docs/interfaces/sdk) | Node.js code. It ships in the same npm package as the CLI.                                       |
| [MCP](/docs/interfaces/mcp)            | MCP clients, through the hosted server at `https://phonebox.dev/mcp`.                            |
| [Agent skill](/docs/interfaces/skill)  | Teaching a coding agent the whole workflow from one file.                                        |

Agents can read these docs as Markdown too: every page has a Markdown version, [/llms.txt](/llms.txt) lists them all, and the API schema is at [/openapi.json](/openapi.json).

## Pricing [#pricing]

A phone costs $0.06 a minute while it runs, billed per second with a 60-second minimum per session, and a parked phone costs nothing. Every new account gets $2 starter credit without a card. Paid top-ups start at $10.

## Start here [#start-here]

* The [Quickstart](/docs/getting-started/quickstart) takes you from a new key to your first phone in the terminal.
* [Set up with your coding agent](/docs/getting-started/coding-agent) shows the one prompt that lets your agent do the setup for you.


---

# Quickstart

Source: https://phonebox.dev/docs/getting-started/quickstart

> Set up from your terminal with one command, then create, read, control and park your first phone.



This walkthrough takes you from nothing to a phone that you control from your terminal. You need Node.js 20 or later.

## 1. Set up from your terminal [#1-set-up-from-your-terminal]

```bash
curl -fsSL https://phonebox.dev/install | sh
```

The script installs the `phonebox` CLI with npm and runs `phonebox setup`, which asks you to sign in in your browser:

1. **Sign in.** The terminal prints a link and a short code. Open the link, sign up or sign in, check that the page shows the same code, and click Connect. A new account gets its first project and $2 starter credit here, with no card required.
2. **Ready to try.** Setup uses your available starter credit. Only when it cannot cover a one-minute session does the terminal print a checkout link, starting at $10. `phonebox setup --amount 25` asks for another top-up amount.

Back in the terminal, setup confirms your project and balance. It saved an agent key for this machine in `~/.config/phonebox/credentials.json`, which every `phonebox` command now uses. An agent key can create, use and park phones, but it can't delete them. The key is listed under [API keys](/app/keys), where you can revoke it.

A phone costs $0.06 a minute while it runs, billed per second, and a parked phone costs nothing. Every new account gets $2 starter credit, enough for about 33 phone-minutes.

`phonebox setup` is safe to run again: it skips what is already done, and it is also how you add credit later. To do the same steps in the console instead, see [Set up in the console](#set-up-in-the-console).

## 2. Create a phone [#2-create-a-phone]

```bash
phonebox create --name quickstart
```

The phone exists, and bills, from the moment the command starts. Before it waits, the command prints the new phone's ID on stderr:

```text
{"created":"ph_4vn6q2x7k5ma","status":"creating"}
```

It then waits until the phone is ready, which often takes about a minute, and prints the phone as one line of JSON. Here it is formatted:

```json title="Response: phone"
{
  "id": "ph_4vn6q2x7k5ma",
  "object": "phone",
  "name": "quickstart",
  "status": "ready",
  "country": null,
  "metadata": {},
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:00:41.230Z",
  "session": {
    "id": "ses_m4q7t2w5z3c6",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": "2026-09-29T10:00:41.230Z",
    "idle_timeout": 300,
    "max_duration": 900,
    "parks_at": "2026-09-29T10:05:41.230Z",
    "park_reason": "idle",
    "reserved_usd": "0.900000"
  },
  "failure": null
}
```

Billing started when you ran the command. The session reserved $0.90, enough for its default maximum length of 15 minutes, and you pay only for the seconds you use. If the wait is interrupted, the phone still exists: `phonebox status` with its ID and `--wait` waits again, and `phonebox ls` lists your phones, so never create a second one to replace it. `parks_at` says when the phone will park itself unless a request addresses it before then.

Save the phone's ID, so that the next commands can leave it out:

```bash
export PHONEBOX_PHONE=ph_4vn6q2x7k5ma
```

## 3. Read the screen [#3-read-the-screen]

```bash
phonebox look
```

```text
snapshot snp_4f2k7m3q6z3a
app com.android.launcher3
keyboard hidden
screen 1080x2400
[1] EditText "Search apps" #search clickable editable (540,250)
[2] TextView "Chrome" #icon clickable (200,1900)
[3] TextView "Settings" #icon clickable (500,1900)
[4] TextView "Play Store" #icon clickable (800,1900)
```

The first line names the snapshot that the refs belong to, which a tap by ref needs. The next lines name the app in front, the keyboard's state and the screen size. Each line after that is one element: its ref number, type, text, resource ID, flags, and its center in screen pixels. [Observe the screen](/docs/using-phones/observe) describes the format and the JSON form.

## 4. Tap and type [#4-tap-and-type]

Tap the search field by its text, then type into it:

```bash
phonebox tap --text "Search apps"
```

```text
{"index":0,"type":"tap","ok":true,"ms":184,"resolved":{"ref":1,"center":[540,250]}}
```

```bash
phonebox type "chrome"
```

```text
{"index":0,"type":"type","ok":true,"ms":912}
```

Look again to see what changed. The keyboard is up, the field holds your text, and the matching app is listed:

```bash
phonebox look
```

```text
snapshot snp_6w3n7k2q5r4e
app com.android.launcher3
keyboard visible, text field focused
screen 1080x2400
[1] EditText "chrome" #search clickable editable focused (540,250)
[2] TextView "Chrome" #icon clickable (200,520)
```

If a command fails, it prints the error as JSON on stderr and exits with a non-zero code. The error's `next` field names what to do. [Actions and targets](/docs/using-phones/actions) lists every action and error.

## 5. Park the phone [#5-park-the-phone]

```bash
phonebox park
```

```json title="Response: phone"
{
  "id": "ph_4vn6q2x7k5ma",
  "object": "phone",
  "name": "quickstart",
  "status": "parked",
  "country": null,
  "metadata": {},
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:01:40.377Z",
  "session": null,
  "failure": null
}
```

Billing stopped when you ran `park`. This session lasted 131 seconds and cost $0.131, and the rest of the reservation went back to your balance. The phone keeps its apps, files and signed-in accounts, and `phonebox start` resumes it.

## The same flow over HTTP [#the-same-flow-over-http]

The HTTP examples read the key from `PHONEBOX_API_KEY`. `phonebox token` prints the key that setup saved:

```bash
export PHONEBOX_API_KEY=$(phonebox token)
```

Create the phone:

```json title="POST /v1/phones"
{
  "name": "quickstart"
}
```

```bash
key="quickstart-$(date +%s)"   # a new key for each phone; send the same one to repeat this create
curl https://phonebox.dev/v1/phones \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $key" \
  -d '{"name": "quickstart"}'
```

The request waits up to about 50 seconds. It answers `201 Created` with the ready phone, which looks like the one in step 2. If the phone isn't ready by then, it answers `202 Accepted` with the phone still `creating`, and you wait for it with a long poll:

```bash
curl "https://phonebox.dev/v1/phones/ph_4vn6q2x7k5ma?wait=ready&timeout=50" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Read the screen. `format=text` returns the text form shown above, and without it you get [JSON](/docs/using-phones/observe):

```bash
curl "https://phonebox.dev/v1/phones/ph_4vn6q2x7k5ma/observe?format=text" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Tap and type in one batch of actions:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "text": "Search apps" } },
    { "type": "type", "text": "chrome" }
  ],
  "observe": "ui"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_4vn6q2x7k5ma/actions \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"actions": [{"type": "tap", "target": {"text": "Search apps"}}, {"type": "type", "text": "chrome"}], "observe": "ui"}'
```

The response has each action's result and, because of `"observe": "ui"`, the screen after the batch:

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "tap", "ok": true, "ms": 184, "resolved": { "ref": 1, "center": [540, 250] } },
    { "index": 1, "type": "type", "ok": true, "ms": 912 }
  ],
  "completed": 2,
  "observation": {
    "snapshot_id": "snp_4f2k7m3q6z3a",
    "taken_at": "2026-09-29T10:01:32.118Z",
    "app": { "package": "com.android.launcher3", "activity": null },
    "keyboard": { "visible": true, "focused_editable": true },
    "screen": { "width": 1080, "height": 2400 },
    "elements": [
      {
        "ref": 1, "type": "EditText", "text": "chrome", "desc": null, "id": "com.android.launcher3:id/search",
        "bounds": [60, 200, 1020, 300], "center": [540, 250], "state": ["clickable", "editable", "focused"]
      },
      {
        "ref": 2, "type": "TextView", "text": "Chrome", "desc": null, "id": "com.android.launcher3:id/icon",
        "bounds": [100, 420, 300, 620], "center": [200, 520], "state": ["clickable"]
      }
    ],
    "truncated": false,
    "text": "app com.android.launcher3\nkeyboard visible, text field focused\nscreen 1080x2400\n[1] EditText \"chrome\" #search clickable editable focused (540,250)\n[2] TextView \"Chrome\" #icon clickable (200,520)"
  }
}
```

Park the phone. Every field of the body is optional, so this request sends none:

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_4vn6q2x7k5ma/park \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

## Set up in the console [#set-up-in-the-console]

Everything `phonebox setup` does is also in the console:

1. [Sign up](/sign-up). Your first project is created automatically with $2 starter credit. No card is required.
2. Open [API keys](/app/keys) and create a key. Keep the Agent preset. The console shows the full key only once, so copy it into your environment right away: `export PHONEBOX_API_KEY=pbx_…`. Keep the key out of your code and your prompts. [Authentication and keys](/docs/getting-started/authentication) explains the presets and scopes.
3. Use your starter credit to try a phone. Add more in [Billing](/app/billing) when needed, starting at $10.
4. Install the CLI: `npm install -g https://phonebox.dev/downloads/phonebox-0.1.0.tgz`. The package holds both the `phonebox` CLI and the [TypeScript SDK](/docs/interfaces/sdk).

`PHONEBOX_API_KEY` always wins over a key that setup saved.

## Next steps [#next-steps]

* [Set up with your coding agent](/docs/getting-started/coding-agent) so that your agent drives phones by itself.
* Read [Concepts](/docs/getting-started/concepts) for how sessions, parking and billing work.
* Browse the [CLI](/docs/interfaces/cli) and the [REST API](/docs/interfaces/rest) for everything else.


---

# Set up with your coding agent

Source: https://phonebox.dev/docs/getting-started/coding-agent

> Paste one prompt into Claude Code, Codex or Cursor. Your agent signs you up from the terminal, asks what you want phones for, then helps with it.



Your coding agent can set you up with Phonebox, account included. It reads a short [setup guide](https://phonebox.dev/setup.md) written for agents, installs the CLI, and gives you a sign-in link. New accounts receive $2 starter credit automatically, with no card required. It then asks what you want to use phones for, and reads the Phonebox [agent skill](/docs/interfaces/skill) before it drives one. It adds the SDK to your project only when you ask for an integration.

## Copy the setup prompt [#copy-the-setup-prompt]

The home page and the sign-up page have this prompt with a Copy button. You don't need an account first:

```text
Read https://phonebox.dev/setup.md and set me up with Phonebox from here: get me signed in and ready, then find out what I want to use phones for and help me with that.
```

If you are already signed in, the console's Home page has a prompt that skips the sign-up and connects your agent's machine to your account:

```text
Read https://phonebox.dev/setup.md and connect this machine to my existing Phonebox account. Use my available credit without requiring a purchase. Then read https://phonebox.dev/skills/phonebox/SKILL.md, find out what I want to use phones for, and help me with that. Park the phone when done.
```

Paste either into your coding agent.

## What you do [#what-you-do]

Your agent runs `phonebox setup`:

1. **Sign in.** Open the link and sign in with Google, or ask your agent for an email link instead. Check that the page shows the code your agent gave you, and click Connect. A new account gets its first project here. This saves an agent key on the machine your agent runs on. The agent never sees the key, and you never paste one into the chat.
2. **Try your first task.** A new account has $2 starter credit, enough for about 33 minutes of running time. Setup uses your available credit and only asks for a top-up when it cannot cover a one-minute session. Paid top-ups start at $10.

Your agent may ask you to approve the commands it runs along the way. If you'd rather use the console, [Quickstart](/docs/getting-started/quickstart#set-up-in-the-console) lists the same steps there.

## What the skill teaches your agent [#what-the-skill-teaches-your-agent]

The setup guide covers only the setup. The skill is a single Markdown file on using phones. It covers:

* to find out what you want before it installs or writes anything, and to ask when it can't tell;
* onboarding: checking what already exists, then running `phonebox setup` for only the missing steps (account, key, credit);
* when Phonebox is the right tool, and when an API or a website is better;
* how to drive a phone with what it has: MCP tools, the CLI or HTTP, and the SDK only for integration code you asked for;
* the loop: create or start a phone, look at the screen, act, look again, and park;
* testing an Android build, when that is what you want: install the APK with `phonebox apps "$PHONEBOX_PHONE" install ./app.apk`, then look at and walk the screens that changed;
* how to read the text form of the screen, and how to tap by coordinates, ref, text, resource ID or description;
* parking and cost, so that it always parks the phone when it's done;
* rules such as using one agent per phone and never repeating an action whose outcome is unknown;
* what each error code means and what to do next;
* phones for several customers, with metadata and keys limited to certain phones;
* the HTTP request behind every CLI command, for code that calls the API directly.

## Claude Code, Codex and Cursor [#claude-code-codex-and-cursor]

All three work from the terminal, so they use Phonebox the same way: they run `phonebox` commands, which use the key that setup saved. The prompt above works in each of them.

To make the skill available in every Claude Code session, install it as a personal skill:

```bash
mkdir -p ~/.claude/skills/phonebox
curl -fsSL https://phonebox.dev/skills/phonebox/SKILL.md -o ~/.claude/skills/phonebox/SKILL.md
```

Claude Code can then use it whenever a task calls for an Android phone. [Agent skill](/docs/interfaces/skill) covers project-level installs and other agents.

If your agent prefers tools to terminal commands, connect it to the [MCP server](/docs/interfaces/mcp) instead. It offers the same phones, limits and billing.

## Check the result [#check-the-result]

When the setup is done, your agent confirms your project, balance and limits with `phonebox account`, then helps with what you asked for. Any phone it used should be parked when it finishes: [Phones](/app/phones) shows each phone's status, sessions and what they cost.


---

# Authentication and keys

Source: https://phonebox.dev/docs/getting-started/authentication

> Project API keys, the Agent, Admin and Read-only presets, scopes, keys limited to certain phones, and expiry.



Every request to the API, the CLI, the SDK and the MCP server authenticates with a project API key, sent as a bearer token:

```http
Authorization: Bearer pbx_…
```

A key belongs to exactly one project, and it selects that project: you never send a project ID. Your console session is separate. Signing in to the console doesn't let a browser call the API.

## Create a key [#create-a-key]

Open [API keys](/app/keys) in the console and create a key. Give it a name that says where it runs, choose its preset, and choose when it expires.

`phonebox setup` creates a key too: an Agent key without an expiry, named after the terminal that asked for it, and saved on that machine. It is created only after you sign in and confirm the terminal's code in your browser. No API call creates a key without that confirmation, and Admin, Read-only and phone-limited keys are created only in the console.

The console shows the full key once, right after you create it. Copy it into your server's environment or your secret manager before you close the dialog. Phonebox stores only a SHA-256 hash of the key, so it can't show the key again. Later, the console identifies the key by its name and a short prefix such as `pbx_AbCd…wXyZ`. If you lose a key, create a new one.

A key is `pbx_` followed by 43 characters. A project can have up to 50 active keys.

## Presets and scopes [#presets-and-scopes]

A key's preset decides its scopes:

| Preset             | Scopes                                           | What it can do                                                                                                                                     |
| ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Agent, the default | `phones:read`, `phones:control`, `phones:create` | Create, start, use and park phones. It can't delete them.                                                                                          |
| Admin              | The Agent scopes plus `phones:delete`            | Everything an agent key can do, and permanently deleting phones with their data.                                                                   |
| Read-only          | `phones:read`                                    | See phones, their screens, apps and files, and the account. It can't create, start, use or park phones, and its requests don't keep a phone awake. |

The console shows all three presets side by side, with Agent preselected. Admin is never preselected: choosing it asks you to confirm that you understand the key can permanently delete phones, including their apps and signed-in accounts. Give agents Agent keys, dashboards and monitors Read-only keys, and keep Admin keys for the scripts that clean up.

Each scope allows these requests:

| Scope            | Requests                                                                                                                                                                                                       |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `phones:read`    | Listing and reading phones and their sessions, observing the screen, screenshots, listing apps and installs, listing and downloading files, and the account.                                                   |
| `phones:control` | Starting, parking, renaming and heartbeats, actions, installing and uninstalling apps, uploading and deleting files, the clipboard, location, locale, timezone, reboots, live links and credit checkout links. |
| `phones:create`  | Creating phones.                                                                                                                                                                                               |
| `phones:delete`  | Deleting phones.                                                                                                                                                                                               |

A request without the scope it needs fails with `403 insufficient_scope`, and `details.required` names the missing scope.

## Keys limited to certain phones [#keys-limited-to-certain-phones]

When you create a key, you can limit it to between 1 and 20 phones. Such a key sees only those phones, in lists and in the account's counts, and it can use only them. A request for any other phone fails with `403 phone_not_allowed`. Of the project's [uploads](/docs/api-reference/uploads), it sees and uses only the ones it created; any other answers `404 upload_not_found`.

A limited key can never create phones, whatever its preset. Creating one fails like this:

```json title="Response: error"
{
  "error": {
    "type": "permission_error",
    "code": "insufficient_scope",
    "message": "This key doesn't have permission for this action.",
    "retryable": false,
    "next": "Use a key that isn't limited to specific phones.",
    "request_id": "req_k4m2n7p3q5r6",
    "details": { "required": "phones:create" }
  }
}
```

Limited keys suit products that serve several customers: a worker that acts for one customer holds a key that reaches only that customer's phones. See [Concepts](/docs/getting-started/concepts) for how to organize phones.

## Expiry and revocation [#expiry-and-revocation]

A key expires after 30 days, 90 days (the default) or a year, or never. An expired key fails with `401 key_expired`. A key you revoke in the console fails with `401 key_revoked` from then on. A missing or malformed key fails with `401 invalid_api_key`.

To rotate a key, create the new key, deploy it where the old one ran, check a request such as `GET /v1/account`, and then revoke the old key.

## Call the API from a server [#call-the-api-from-a-server]

Phonebox refuses any request that carries an `Origin` header, which every browser adds, so a key can't be used from a web page:

```json title="Response: error"
{
  "error": {
    "type": "permission_error",
    "code": "browser_requests_not_allowed",
    "message": "Call the API from a server, not a browser.",
    "retryable": false,
    "next": null,
    "request_id": "req_5f7a2c4d6e3b",
    "details": {}
  }
}
```

The same rule applies to the MCP server. If your product has a web front end, call Phonebox from your backend and pass on only what your user needs. For a person who has to see or control a phone, create a [live view link](/docs/using-phones/live-view) instead of exposing a key.

## Keep keys secret [#keep-keys-secret]

* Keep keys in environment variables or a secret manager, never in code, commits or logs.
* Don't paste a key into an agent's prompt. Agents read it from `PHONEBOX_API_KEY`.
* Revoke a key as soon as you think it leaked, and check the console's activity for calls you don't recognize.


---

# Concepts

Source: https://phonebox.dev/docs/getting-started/concepts

> Phones, sessions, statuses, parking, timers, billing and limits.



## Phones [#phones]

A phone is a persistent Android device in the cloud. Its ID starts with `ph_`, as in `ph_7kx2m6q4v3ta`. A phone keeps its installed apps, files and signed-in accounts for as long as it exists, whether it is running or parked. It is gone only when you delete it, or when it fails, because a failed phone can't be started again.

You can give a phone a `name` and up to 20 `metadata` pairs of your own, such as a customer ID, and find phones by them later. A phone can also ask for a `country` when you create it: where it appears to be, from the list that `GET /v1/account` returns.

## Sessions [#sessions]

A session is one billed period on one phone, from the moment it starts until it parks. Its ID starts with `ses_`. Creating a phone opens its first session, and each start after a park opens a new one. While a session is open, the phone's `session` field shows when it started, its timers and when it will park. `GET /v1/phones/{id}/sessions` lists a phone's sessions with what each one cost.

## Statuses [#statuses]

| Status        | Meaning                                                                                                                                                                                                  | Billed                                           |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `creating`    | The phone is being set up for the first time. This often takes about a minute.                                                                                                                           | Yes                                              |
| `starting`    | The phone is resuming after a park, or restarting after a reboot.                                                                                                                                        | Yes                                              |
| `ready`       | The phone's screen and a screenshot have both answered, so you can use it.                                                                                                                               | Yes                                              |
| `parking`     | A park was requested and the device is stopping.                                                                                                                                                         | No: billing stopped when the park was requested. |
| `parked`      | The phone is stopped. Its apps, files and sign-ins are kept.                                                                                                                                             | No                                               |
| `unavailable` | The phone is under maintenance, or its state is unknown. Its session was closed, and you can start it again later.                                                                                       | No                                               |
| `failed`      | Setting up the phone failed, or its device was lost. The phone's `failure` field says why. It can't be used again, so create a new phone, with a new idempotency key if you created this one with a key. | No                                               |
| `deleted`     | You deleted the phone. This can't be undone.                                                                                                                                                             | No                                               |

Only a `ready` phone accepts screen, action, app, file and device requests. Any other status answers with an error whose `next` field names the way forward, such as starting the phone.

## Parking and timers [#parking-and-timers]

Parking stops a phone and its billing while keeping everything on it. Park a phone yourself whenever you're done with it. Phonebox also parks it for you:

* A phone parks when its `idle_timeout` has passed since its `last_active_at`: the moment it became ready, or the last time a request addressed it since. The default is 300 seconds, and you can choose from 60 to 3600.
* A phone parks when its session reaches its `max_duration`, however busy it is. The default is up to 900 seconds, shortened automatically to available credit and the monthly spending limit, and you can choose from 60 to 10800, which is three hours.

Any API or MCP request that addresses a ready phone counts as activity, including reads, and so does a live view that is open on it. Three kinds of request don't count: a long poll that waits for the phone to park, a request refused with `401` or `403`, and any request made with a key that is revoked or expired. `last_active_at` moves to the time of a request at most once every 15 seconds, so a phone can park up to about 15 seconds sooner than `idle_timeout` after your last request. The phone's `session.parks_at` field says when it will park, which is `last_active_at` plus `idle_timeout` or the session's deadline, whichever comes first, and `park_reason` says which rule will park it: `idle` or `max_duration`.

You set both timers when you create or start a phone. Starting a phone that is already running renews it: its deadline moves to `max_duration` from now. Phonebox also parks running phones when a project is frozen or during maintenance.

## Billing [#billing]

A phone costs $0.06 a minute, which is $0.001 a second, while it is `creating`, `starting` or `ready`. Billing is per second, with a 60-second minimum for each session. A parked phone costs nothing.

The clock starts when Phonebox accepts your create or start request. It stops when you ask to park, when Phonebox decides to park the phone, or when the phone becomes unavailable or fails. Any time the device takes to stop after that is not billed.

A session that ends before the phone was ever ready costs nothing, unless you parked or deleted the phone yourself. In that case it is billed like any other session.

### Reservations [#reservations]

When a session starts, Phonebox reserves credit for its whole `max_duration`: $3.60 for the default hour. The session's `reserved_usd` field shows the amount. When the session ends, you're charged for the seconds it ran, never more than the reservation, and the rest is released. Renewing a running phone reserves only the extra time.

If your balance, minus what is already reserved, can't cover a new reservation, the request fails with `402 insufficient_credits`. Its `details.max_affordable_seconds` says the longest `max_duration` you can afford right now. Because of the reservation, your balance can never go below zero.

### Spending limit [#spending-limit]

Each project has a monthly spending limit, $100 by default, which you change in the console's Settings. A session whose reservation would take the month's spending past the limit fails with `402 spend_limit_reached`. A limit of $0 stops all spending.

You buy credits in [Billing](/app/billing), from $10 to $1,000 at a time. Credits don't expire, and there are no subscriptions or automatic top-ups. `GET /v1/account` shows your balance, what is reserved and what you spent this month:

```json title="Response: account"
{
  "project": "Checkout QA",
  "balance_usd": "21.400000",
  "reserved_usd": "3.600000",
  "spend_limit_usd": "100.000000",
  "spent_this_month_usd": "4.120000",
  "limits": { "phones": 10, "running": 2 },
  "running": 1,
  "phones": 3,
  "price": { "per_minute_usd": "0.060000", "minimum_seconds": 60 },
  "countries": [{ "code": "AR", "name": "Argentina" }, { "code": "US", "name": "United States" }]
}
```

The API writes money as a decimal string of US dollars with six places, so that no amount is rounded.

## Limits [#limits]

| Limit                                                    | Default                                   | When you exceed it                    |
| -------------------------------------------------------- | ----------------------------------------- | ------------------------------------- |
| Phones in a project, not counting deleted or failed ones | 10                                        | `409 phone_limit_reached`             |
| Phones running at once                                   | 2, which you can lower in Settings        | `409 running_limit_reached`           |
| Requests                                                 | 600 a minute for each API key             | `429 rate_limited` with `Retry-After` |
| Creates, starts, parks and deletes                       | 60 a minute for each project              | `429 rate_limited` with `Retry-After` |
| Actions in one batch                                     | 20, with at most 60 seconds of waiting    | `400 validation_failed`               |
| File uploads and downloads                               | 4 MB                                      | `413 payload_too_large`               |
| Live link lifetime                                       | 60 seconds to 24 hours, 1 hour by default | `400 validation_failed`               |
| Monthly spending                                         | $100                                      | `402 spend_limit_reached`             |
| Active API keys                                          | 50                                        | The console refuses a new key.        |

The running limit counts all of your projects together. To run more phones at once, email [team@phonebox.dev](mailto:team@phonebox.dev). A failed phone holds no device, so it doesn't count toward the phone limit. When Phonebox as a whole has no phones free, a create or a start fails with `503 capacity_unavailable`. Its `retryable` field says what that means. When it is `true`, nothing is lost: wait for `Retry-After`, about 30 seconds, and send the same request again, a few times at most. That covers a create that was refused, which made no phone, and a start whose phone parked again, which you start again with its apps and sign-ins. When it is `false`, a new phone could get no device and has failed, so its `status` is `failed`: create another phone with a new key. A phone is gone only when its `status` is `failed` or `deleted`, never because an error names it in `details.phone`.

## One agent per phone [#one-agent-per-phone]

Phonebox doesn't lock a phone to one caller. If two agents act on the same phone at once, each acts on screens the other changed. Give each agent its own phone, and each end user their own phone too, so that their apps and sign-ins stay apart.


---

# Invite your team

Source: https://phonebox.dev/docs/getting-started/team

> Share a project with your team, what owners, admins and members can do, and what happens when someone leaves.



A project's team shares its phones, keys and credit. The person who created the project is its owner. Everyone else is an admin or a member. A project has up to 20 people, pending invites included.

## Roles [#roles]

| Can…                                                                                                         | Owner | Admin       | Member                 |
| ------------------------------------------------------------------------------------------------------------ | ----- | ----------- | ---------------------- |
| Use phones in the console (create, start, park, delete, drive, live links, apps, files, recordings, uploads) | ✓     | ✓           | ✓                      |
| See activity, usage, billing (balance, holds, credit activity), keys, the team                               | ✓     | ✓           | ✓                      |
| Add credits                                                                                                  | ✓     | ✓           | ✓                      |
| Create Agent and Read-only keys; connect a terminal (`phonebox setup`)                                       | ✓     | ✓           | ✓                      |
| Revoke a key                                                                                                 | Any   | Any         | Only keys they created |
| Create Admin keys                                                                                            | ✓     | ✓           |                        |
| Invite, change roles, remove people, revoke invites                                                          | ✓     | ✓           |                        |
| Project settings (name, spending limit, running limit)                                                       | ✓     | ✓           |                        |
| Billing portal (receipts, payment details)                                                                   | ✓     | ✓           |                        |
| Claim a free-credit link into the project                                                                    | ✓     |             |                        |
| Be removed or demoted                                                                                        | Never | By an admin | By an admin            |
| Leave the project                                                                                            | Never | ✓           | ✓                      |

Someone who isn't on the team can't see the project at all.

## Invite someone [#invite-someone]

1. Open [Settings](/app/settings) and go to Team.
2. Choose **Invite**, enter their email address, and choose Admin or Member.
3. Copy the link and send it to them. It is shown once.

The link works once, for 7 days, and only for the invited address: they sign in or sign up with that email to join. Inviting the same address again replaces the earlier link, and an admin can revoke a pending invite from Team.

## What a member can't do [#what-a-member-cant-do]

A member can't create Admin keys, revoke a key someone else created, invite or remove people, change roles, change the project's settings, or open the billing portal. The console shows these controls disabled, and says only admins can use them.

Phones started in a shared project count toward its owner's running limit, never toward yours.

## Remove someone or leave [#remove-someone-or-leave]

An admin can change anyone's role and remove anyone, except the owner. A person who is removed loses access on their next request. Anyone but the owner can leave the project from Team.

API keys stay when their creator leaves or is removed, and keep working until an admin revokes them on [API keys](/app/keys).


---

# Lifecycle and timers

Source: https://phonebox.dev/docs/using-phones/lifecycle

> Create, wait for, start, renew, park, update and delete phones, and handle failures.



A phone moves through a few statuses: `creating` or `starting` while it boots, `ready` while you use it, and `parking` and then `parked` when it stops. [Concepts](/docs/getting-started/concepts) describes every status. This page covers the requests that move a phone between them.

## Create a phone [#create-a-phone]

```json title="POST /v1/phones"
{
  "name": "checkout-test",
  "metadata": { "customer": "acme" },
  "idle_timeout": 300,
  "max_duration": 1800
}
```

```bash
key="checkout-test-$(date +%s)"   # a new key for each phone; send the same one to repeat this create
curl https://phonebox.dev/v1/phones \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $key" \
  -d '{"name": "checkout-test", "metadata": {"customer": "acme"}, "idle_timeout": 300, "max_duration": 1800}'
```

Every field is optional, so a request with no body creates a phone with the defaults.

| Field          | Meaning                                                                                                                                                                                        |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`         | Up to 80 characters. The default is the phone's ID.                                                                                                                                            |
| `country`      | Where the phone appears to be: one of the two-letter codes that `GET /v1/account` lists in `countries`, such as `US`. Leave it out to take a phone from any country, which is usually fastest. |
| `metadata`     | Up to 20 string pairs of your own. Keys have up to 40 characters: letters, digits, `_`, `:` and `-`, starting with a letter or digit. Values have up to 256 characters.                        |
| `idle_timeout` | Seconds after the phone's last activity, its `last_active_at`, at which it parks itself, from 60 to 3600. The default is 300.                                                                  |
| `max_duration` | The most seconds a session may run before the phone parks, from 60 to 10800. The default is 900, shortened automatically to fit available credit when omitted.                                 |
| `wait`         | Whether to wait until the phone is ready. The default is `true`.                                                                                                                               |

Creating a phone opens its first session, so billing starts at once. The request needs the `phones:create` scope, which keys limited to certain phones never have.

With `wait` on, the request waits up to about 50 seconds. If the phone is ready by then, it answers `201 Created` with the ready phone. Otherwise it answers `202 Accepted` with the phone still `creating`, and you wait with a long poll, as described below. With `"wait": false`, it answers `202` at once. The phone in a `202` looks like this:

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "creating",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": null,
  "session": {
    "id": "ses_q2w6e4r5t3y7",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": null,
    "idle_timeout": 300,
    "max_duration": 1800,
    "parks_at": "2026-09-29T10:30:00.412Z",
    "park_reason": "max_duration",
    "reserved_usd": "1.800000"
  },
  "failure": null
}
```

Until the phone is ready, the idle timer hasn't started, so `parks_at` is the session's deadline.

### Repeat a create safely [#repeat-a-create-safely]

Send an `Idempotency-Key` header with every create. It holds 8 to 128 characters, which are letters, digits, `_`, `.`, `:` and `-`, and it starts with a letter or digit. If a create times out or its connection drops, send it again with the same key and the same body: you get the same phone, as it is now, instead of a second one. If that phone has failed, or you deleted it, a repeat only returns it again, so create the next phone with a new key. A repeat with the same key and a different body fails with `409 idempotency_conflict`, whose `details.phone` names the phone the key created: send the original body to get it, never a new key, which would create a second phone. `wait` isn't compared, so a create that waits and one that doesn't can share a key. Keys are remembered for 24 hours.

Every POST validates an `Idempotency-Key` it is given, but only create acts on one. Park and heartbeat are safe to repeat without a key. A start on a running phone renews its session and reserves credit again, so read the phone before you send a start again. [Action batches](/docs/using-phones/actions) are never replayed.

## Wait for a status [#wait-for-a-status]

To wait until a phone is ready, or until it has parked, make a long poll:

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta?wait=ready&timeout=50" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

`wait` is `ready` or `parked`, and `timeout` is in seconds, from 1 to 55, with a default of 50. The request returns the phone as soon as it reaches that status. It also returns early when the phone reaches a status from which it can't get there without another request. For example, a phone that parked can't become ready until you start it. When the time runs out, it returns the phone as it is. Poll again only while the phone is still on its way: `creating` or `starting` for `ready`, and `parking` for `parked`. Any other status ends the loop, so read the phone's `status` and `failure` and act on them. A poll only reads the phone, so wait this way for a phone that is still creating or starting, and not with a start, which renews a running phone's session. The CLI's `phonebox status --wait`, the SDK's `waitUntil()` and the MCP tool `get_phone` wait the same way.

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "ready",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:01:04.771Z",
  "session": {
    "id": "ses_q2w6e4r5t3y7",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": "2026-09-29T10:01:04.771Z",
    "idle_timeout": 300,
    "max_duration": 1800,
    "parks_at": "2026-09-29T10:06:04.771Z",
    "park_reason": "idle",
    "reserved_usd": "1.800000"
  },
  "failure": null
}
```

Every wait in Phonebox lasts about 50 seconds at most, the waits in create, start and park included. A waiting request sends nothing until it answers, and many proxies and load balancers close a connection that stays silent for 60 seconds. A shorter wait and another poll is more reliable than one long request. The [SDK](/docs/interfaces/sdk) and the [CLI](/docs/interfaces/cli) poll for you.

A `wait=parked` poll doesn't count as activity, so waiting for a park never delays it.

### What ready means [#what-ready-means]

A phone becomes `ready` only after its screen has answered a read and a screenshot has succeeded. For a few seconds after that, the phone can still be briefly out of reach. Phonebox retries reads such as observe and screenshots for up to about 15 seconds, so they ride this out. It never retries a write for you, because a write might have happened. When an action didn't reach the phone at all, the request fails with Phonebox's retryable `503 phone_unavailable` error, which means nothing was done, and you can send it again. A failure without Phonebox's error envelope, such as a gateway's `504`, means no such thing: see [Unknown outcomes](/docs/troubleshooting/unknown-outcomes#when-no-answer-comes-from-phonebox). [Actions and targets](/docs/using-phones/actions) covers this in detail.

## Start or renew a phone [#start-or-renew-a-phone]

```json title="POST /v1/phones/{id}/start"
{
  "idle_timeout": 900,
  "max_duration": 7200
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/start \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"idle_timeout": 900, "max_duration": 7200}'
```

Starting a parked or unavailable phone opens a new session and resumes the device, with its apps, files and sign-ins as you left them. The body takes `idle_timeout`, `max_duration` and `wait`, and every field is optional. A timer you leave out keeps the phone's current setting. Like create, the request waits up to about 50 seconds. It answers `200 OK` with the ready phone, or `202 Accepted` with the phone still `starting`, or as it was last seen when the wait failed for any reason but the phone's own failure.

Starting a phone that is already running renews its session instead: the deadline moves to `max_duration` from now, only the extra time is reserved, and a ready phone's idle timer restarts. A renewal answers `200` at once when the phone is ready.

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "ready",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T14:20:41.309Z",
  "session": {
    "id": "ses_x7c3v5b2n6m4",
    "started_at": "2026-09-29T14:20:02.118Z",
    "ready_at": "2026-09-29T14:20:41.309Z",
    "idle_timeout": 900,
    "max_duration": 7200,
    "parks_at": "2026-09-29T14:35:41.309Z",
    "park_reason": "idle",
    "reserved_usd": "7.200000"
  },
  "failure": null
}
```

A start can fail before it opens a session:

| Error                                     | Why                                                                                                                                 |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `409 phone_parking`                       | The phone is still parking. Wait with `wait=parked`, then start it. This error is retryable.                                        |
| `409 running_limit_reached`               | The project already runs its maximum number of phones.                                                                              |
| `402 insufficient_credits`                | Your balance can't cover the reservation. `details.max_affordable_seconds` says how long a session you can afford.                  |
| `402 spend_limit_reached`                 | The reservation would pass the monthly spending limit.                                                                              |
| `503 capacity_unavailable`                | No phones are free right now. Try again after `Retry-After`.                                                                        |
| `409 phone_failed` or `409 phone_deleted` | The phone can't be used again. Create a new one, with a new idempotency key if you use one, because the old key returns this phone. |

## Park a phone [#park-a-phone]

```json title="POST /v1/phones/{id}/park"
{
  "wait": true
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/park \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"wait": true}'
```

Parking closes the session, which stops billing at once, and then stops the device. With `wait` on, the default, the request waits up to about 50 seconds for the device to stop. It answers `200 OK` with the parked phone, or `202 Accepted` with the phone still `parking`, or as it was last seen when the wait failed for any reason but the phone's own failure. Parking a phone that is already parked returns it unchanged.

```json title="Response: phone"
{
  "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
}
```

A phone also parks itself at `session.parks_at`, which is `last_active_at` plus `idle_timeout`, or the session's deadline if that comes first. `session.park_reason` tells you which of the two it is. A request that addresses a ready phone moves `last_active_at` to its own time, at most once every 15 seconds, so a phone can park up to about 15 seconds sooner than `idle_timeout` after your last request.

## Keep a phone awake [#keep-a-phone-awake]

While a person watches the phone, for example in your own interface, send heartbeats so that it doesn't park under them, and stop when they leave:

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/heartbeat \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

A heartbeat sets `last_active_at` to now, which restarts the idle timer, and returns the phone. It doesn't move the session's deadline: start the phone again to renew it. A heartbeat to a phone that is still creating or starting returns the phone unchanged, and one to a phone that isn't running fails with the usual error.

Don't send heartbeats while nobody watches, because they keep the phone billing. For a long wait, such as a build, park the phone and start it again afterwards, or give it a longer `idle_timeout`. [Keep costs down](/docs/guides/keep-costs-down) explains why.

## Rename a phone or change its metadata [#rename-a-phone-or-change-its-metadata]

```json title="PATCH /v1/phones/{id}"
{
  "name": "checkout-test-2",
  "metadata": { "customer": "acme", "suite": "nightly" }
}
```

```bash
curl -X PATCH https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "checkout-test-2", "metadata": {"customer": "acme", "suite": "nightly"}}'
```

`metadata` replaces the phone's whole metadata, so send every pair you want to keep. You can update a phone in any status except `deleted`, and the response is the updated phone:

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test-2",
  "status": "parked",
  "country": null,
  "metadata": { "customer": "acme", "suite": "nightly" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T14:41:18.520Z",
  "session": null,
  "failure": null
}
```

To find phones by metadata, filter the list by one pair: `GET /v1/phones?metadata[customer]=acme`. You can also filter by `status`. Without it, the list leaves out failed and deleted phones.

## List a phone's sessions [#list-a-phones-sessions]

`GET /v1/phones/{id}/sessions` lists a phone's sessions, newest first, with why each one ended and what it cost:

```json title="Response: sessions"
{
  "data": [
    {
      "id": "ses_x7c3v5b2n6m4",
      "started_at": "2026-09-29T14:20:02.118Z",
      "ready_at": "2026-09-29T14:20:41.309Z",
      "ended_at": "2026-09-29T14:41:33.004Z",
      "end_reason": "parked",
      "billed_seconds": 1291,
      "cost_usd": "1.291000",
      "reserved_usd": "7.200000"
    },
    {
      "id": "ses_q2w6e4r5t3y7",
      "started_at": "2026-09-29T10:00:00.412Z",
      "ready_at": "2026-09-29T10:01:04.771Z",
      "ended_at": "2026-09-29T10:12:27.506Z",
      "end_reason": "idle",
      "billed_seconds": 748,
      "cost_usd": "0.748000",
      "reserved_usd": "1.800000"
    }
  ],
  "next_cursor": null
}
```

`end_reason` is one of `parked`, `idle`, `max_duration`, `deleted`, `failed`, `unavailable`, `project_frozen` and `service_paused`. An open session has `null` in `ended_at`, `end_reason`, `billed_seconds` and `cost_usd`.

## Delete a phone [#delete-a-phone]

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Deleting a phone destroys its device with every app, file and signed-in account on it, and it can't be undone. It needs a key with the `phones:delete` scope, which only Admin keys have. Any open session ends and is billed up to that moment. The response is the phone with `status` set to `deleted`, and deleting it again returns the same. Park phones you may need again instead of deleting them.

## Failures [#failures]

A phone that can't be set up within 10 minutes, or whose device is lost, becomes `failed`. Its `failure` field says why:

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "failed",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": null,
  "session": null,
  "failure": { "code": "provider_timeout", "message": "The phone didn't respond in time.", "at": "2026-09-29T10:10:02.965Z" }
}
```

A session that fails before the phone was ever ready costs nothing. A failed phone never recovers: it can't be started again, and a repeat of its create with the same `Idempotency-Key` only returns it again. Create a new phone, with a new key. Failed phones don't count toward your phone limit.

If you're waiting on a new phone when it fails, in a create with `wait` or in a `wait=ready` poll, the request answers with the phone's failure as an error, and `details.phone` names the phone. Because the phone won't recover, that error has `retryable` set to `false` and no `Retry-After`, whatever its code. Its `next` gives the code's own advice and then tells you to create a new phone, with a new idempotency key if you use one.

When a start on an existing phone can't finish within 5 minutes, the phone parks again, keeping its data, and `failure` records what went wrong. A start with `wait`, or a `wait=ready` poll, answers with that failure as an error too, but it keeps its code's usual `retryable`, because you can start the same phone again later:

```json title="Response: error"
{
  "error": {
    "type": "provider_error",
    "code": "provider_timeout",
    "message": "The phone didn't respond in time.",
    "retryable": true,
    "next": null,
    "request_id": "req_m3n5p2q7r4s6",
    "details": { "phone": "ph_7kx2m6q4v3ta" }
  }
}
```

A code that isn't in the list is refused at once with `400 validation_failed`, whose `details.issues` names the `country` field. No phone is made and nothing is held.

A listed country can still run out of phones for a while. Then the create fails with `400 validation_failed` too, but this time the phone was made, so the error names it in `details.phone`. That phone has failed, so create the next one with a new key, and leave `country` out or choose another country.


---

# Observe the screen

Source: https://phonebox.dev/docs/using-phones/observe

> Read the screen as JSON or as compact text, with optional screenshots, and use snapshots to act on elements by ref.



Observing reads the phone's screen: which app is in front, whether the keyboard is showing, and every element you can act on or read. Each element gets a ref number that actions can target. The phone must be `ready`.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/observe \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: observation"
{
  "snapshot_id": "snp_4f2k7m3q6z3a",
  "taken_at": "2026-09-29T10:01:05.662Z",
  "app": { "package": "com.android.launcher3", "activity": null },
  "keyboard": { "visible": false, "focused_editable": false },
  "screen": { "width": 1080, "height": 2400 },
  "elements": [
    {
      "ref": 1, "type": "EditText", "text": "Search apps", "desc": null, "id": "com.android.launcher3:id/search",
      "bounds": [60, 200, 1020, 300], "center": [540, 250], "state": ["clickable", "editable"]
    },
    {
      "ref": 2, "type": "TextView", "text": "Chrome", "desc": null, "id": "com.android.launcher3:id/icon",
      "bounds": [100, 1800, 300, 2000], "center": [200, 1900], "state": ["clickable"]
    },
    {
      "ref": 3, "type": "TextView", "text": "Settings", "desc": null, "id": "com.android.launcher3:id/icon",
      "bounds": [400, 1800, 600, 2000], "center": [500, 1900], "state": ["clickable"]
    },
    {
      "ref": 4, "type": "TextView", "text": "Play Store", "desc": null, "id": "com.android.launcher3:id/icon",
      "bounds": [700, 1800, 900, 2000], "center": [800, 1900], "state": ["clickable"]
    }
  ],
  "truncated": false,
  "text": "app com.android.launcher3\nkeyboard hidden\nscreen 1080x2400\n[1] EditText \"Search apps\" #search clickable editable (540,250)\n[2] TextView \"Chrome\" #icon clickable (200,1900)\n[3] TextView \"Settings\" #icon clickable (500,1900)\n[4] TextView \"Play Store\" #icon clickable (800,1900)"
}
```

| Field         | Meaning                                                                       |
| ------------- | ----------------------------------------------------------------------------- |
| `snapshot_id` | The ID of this reading of the screen. Actions use it with a ref.              |
| `taken_at`    | When the screen was read.                                                     |
| `app`         | The package in front. `activity` is often `null`.                             |
| `keyboard`    | Whether the keyboard is showing, and whether a text field has focus.          |
| `screen`      | The screen's size in pixels.                                                  |
| `elements`    | The elements, in reading order.                                               |
| `truncated`   | `true` when the screen had more than 250 elements and the rest were left out. |
| `text`        | The same screen in the compact text form.                                     |
| `screenshot`  | The screenshot, when you ask for one.                                         |

Each element has these fields:

| Field    | Meaning                                                                                                                                       |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref`    | Its number in this snapshot, from 1.                                                                                                          |
| `type`   | Its Android class name without the package, such as `Button`, `EditText` or `TextView`.                                                       |
| `text`   | Its text, or `null`.                                                                                                                          |
| `desc`   | Its content description, or `null`. Icons often have one instead of text.                                                                     |
| `id`     | Its full resource ID, such as `com.android.launcher3:id/search`, or `null`.                                                                   |
| `bounds` | Its rectangle as `[left, top, right, bottom]`.                                                                                                |
| `center` | The point a tap on it uses, as `[x, y]`.                                                                                                      |
| `state`  | Its flags: `clickable`, `long_clickable`, `editable`, `focused`, `checkable`, `checked`, `selected`, `scrollable`, `disabled` and `password`. |

All coordinates are device pixels, the same pixels that `screen` measures and that `{x, y}` targets use.

## Which elements are listed [#which-elements-are-listed]

The list keeps what an agent can use and drops layout containers. An element is listed when it is visible, has a size, and either can be acted on (it is clickable, long-clickable, editable, checkable or scrollable) or has text or a description. Elements of the system status bar are left out unless you can act on them.

Refs run from 1 in reading order: top to bottom, then left to right. At most 250 elements are returned. When a screen has more, `truncated` is `true` and the text form ends with a line that says so. Scroll to reach the others.

## The text form [#the-text-form]

Add `format=text` for the screen as plain text, made for models to read. It is the `text` field on its own, and the response's `X-Snapshot-Id` header carries the snapshot ID:

```bash
curl -i "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/observe?format=text" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```text
app com.android.launcher3
keyboard hidden
screen 1080x2400
[1] EditText "Search apps" #search clickable editable (540,250)
[2] TextView "Chrome" #icon clickable (200,1900)
[3] TextView "Settings" #icon clickable (500,1900)
[4] TextView "Play Store" #icon clickable (800,1900)
```

The CLI's `phonebox look` and the SDK's `look()` print the snapshot ID on a first line of its own, `snapshot snp_…`, as MCP's `observe` does, and then this text.

The first three lines of the text are always the same header:

| Line                                  | Meaning                                                                                                                                |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `app com.example.shop (MainActivity)` | The package in front, followed by its activity in parentheses when it is known. It reads `app unknown` when the package can't be read. |
| `keyboard hidden`                     | Or `keyboard visible`, with `, text field focused` added when a text field has focus. The same suffix can follow `keyboard hidden`.    |
| `screen 1080x2400`                    | The screen's width and height in pixels.                                                                                               |

Each element line after the header reads `[ref] Type "text" desc="description" #id flags (x,y)`. A part the element doesn't have is left out, and `desc` is shown only when it differs from the text. `#id` is the part of the resource ID after `:id/`, and `(x,y)` is the element's center. When the list was truncated, the last line is `… more elements are on screen but not listed`.

The text form never includes a screenshot, even when you ask for one.

## Screenshots [#screenshots]

Add `screenshot=jpeg` or `screenshot=png` to include a screenshot in the JSON, and `max_width` to scale it down to at most that many pixels wide, from 160 to 2000:

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/observe?screenshot=jpeg&max_width=720" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The response then carries a `screenshot` object with `format`, `width`, `height`, `scale` and `data`, the image in base64. Element coordinates stay in device pixels. `scale` is the image's width divided by the screen's, so an element's center falls on the image at its coordinates times `scale`, and a point on the image maps back to the phone at its coordinates divided by `scale`. A 1080-pixel-wide screen at `max_width=720` has a `scale` of 0.666667. Phonebox keeps the whole response under 4 MB, and it shrinks a screenshot further when it has to.

To get only the image, use the screenshot endpoint. It returns the image itself, not JSON:

```bash
curl -o screen.jpg "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/screenshot?format=jpeg&max_width=720&quality=80" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

| Parameter   | Meaning                                                                                                    |
| ----------- | ---------------------------------------------------------------------------------------------------------- |
| `format`    | `png`, the default, or `jpeg`.                                                                             |
| `max_width` | The most pixels wide the image may be, from 160 to 2000. By default, the image has the screen's full size. |
| `quality`   | JPEG quality, from 30 to 95. The default is 70.                                                            |

The response carries the screen's size in device pixels in the `X-Screen-Width` and `X-Screen-Height` headers, and the image's `scale` in `X-Screen-Scale`, so you can map a point on a scaled image back onto the phone.

Use screenshots when the element list doesn't show what you need, as in games, maps, drawings, images and web pages that expose no elements. Then act by coordinates.

## Snapshots and refs [#snapshots-and-refs]

Each observation stores its element list as a snapshot for 15 minutes, under its `snapshot_id`. An action can then target an element as `{"ref": 3, "snapshot_id": "snp_4f2k7m3q6z3a"}`. Before acting, Phonebox finds that element on the screen as it is now: it must have the same type, resource ID, text and description, and overlap its old position by at least half. If it doesn't, the action fails with `stale_snapshot`, and you observe again.

A ref means something only together with its snapshot. Refs from different snapshots can point at different elements, so always pass the `snapshot_id` from the same observation.

Observing a phone counts as activity, so it keeps an idle phone from parking. A Read-only key's requests are the exception: they never keep a phone awake. The CLI's `phonebox look` prints the text form, and `phonebox look --json` prints the JSON.


---

# Actions and targets

Source: https://phonebox.dev/docs/using-phones/actions

> Every action type, how targets find elements, the rules of a batch, and what to do when an action fails or its outcome is unknown.



You act on a phone by sending a batch of actions to `POST /v1/phones/{id}/actions`. A batch holds up to 20 actions, which run in order, and its response tells you how each one went. The phone must be `ready`, and the key needs the `phones:control` scope.

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "open_app", "package": "com.android.settings" },
    { "type": "wait_for", "target": { "text": "Network & internet" }, "timeout_ms": 10000 },
    { "type": "tap", "target": { "text": "Network & internet" } },
    { "type": "scroll", "direction": "down", "amount": 0.5 },
    { "type": "back" }
  ],
  "observe": "none"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/actions \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"actions": [{"type": "open_app", "package": "com.android.settings"}, {"type": "wait_for", "target": {"text": "Network & internet"}, "timeout_ms": 10000}, {"type": "tap", "target": {"text": "Network & internet"}}, {"type": "scroll", "direction": "down", "amount": 0.5}, {"type": "back"}], "observe": "none"}'
```

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "open_app", "ok": true, "ms": 1311 },
    { "index": 1, "type": "wait_for", "ok": true, "ms": 1024 },
    { "index": 2, "type": "tap", "ok": true, "ms": 206, "resolved": { "ref": 4, "center": [540, 620] } },
    { "index": 3, "type": "scroll", "ok": true, "ms": 688 },
    { "index": 4, "type": "back", "ok": true, "ms": 143 }
  ],
  "completed": 5
}
```

Each result has the action's `index` and `type`, whether it succeeded (`ok`), and how long it took in milliseconds (`ms`). A tap, double tap or long press also has `resolved`: the `center` it acted on and, when the target was an element, that element's `ref`. A failed action has an `error` instead. `completed` counts the actions that succeeded.

## Action types [#action-types]

| `type`                                     | Fields                                                                                                                  | What it does                                                                                                                                                       |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `tap`                                      | `target`                                                                                                                | Taps the target's center.                                                                                                                                          |
| `double_tap`                               | `target`                                                                                                                | Taps it twice, 80 ms apart.                                                                                                                                        |
| `long_press`                               | `target`, and `duration_ms` from 300 to 5000, 800 by default                                                            | Presses and holds the target.                                                                                                                                      |
| `swipe`                                    | `from` and `to` as `{"x": …, "y": …}`, and `duration_ms` from 50 to 5000, 300 by default                                | Swipes from one point to the other.                                                                                                                                |
| `scroll`                                   | `direction` (`up`, `down`, `left` or `right`), an optional element `target`, and `amount` from 0.1 to 1, 0.6 by default | Scrolls the screen, or inside the target element. The swipe stays within the middle 60% of the element or screen, and `amount` is how much of that span it covers. |
| `type`                                     | `text` of up to 5000 characters, `clear` (default `false`) and `submit` (default `false`)                               | Types into the focused text field. `clear` empties the field first, and `submit` presses Enter afterwards.                                                         |
| `key`                                      | `key`: a key name, or an Android key code from 0 to 400                                                                 | Presses one key.                                                                                                                                                   |
| `back`, `home`, `recents`, `notifications` | None                                                                                                                    | Presses Back, goes to the home screen, opens the recent apps, or opens the notifications.                                                                          |
| `open_app`                                 | `package`, and an optional `activity`                                                                                   | Opens an installed app, at that activity if you name one.                                                                                                          |
| `close_app`                                | `package`, and `clear_data` (default `false`)                                                                           | Stops an app. `clear_data` also erases the app's data, its signed-in accounts included.                                                                            |
| `open_url`                                 | `url`, and an optional `package`                                                                                        | Opens a web address or a deep link, in that app if you name one.                                                                                                   |
| `wait`                                     | `ms`, from 1 to 10000                                                                                                   | Pauses for that many milliseconds.                                                                                                                                 |
| `wait_for`                                 | A text, ID or description `target`, `gone` (default `false`), and `timeout_ms` from 100 to 30000, 10000 by default      | Checks the screen every half second until the target appears, or with `gone` until it disappears.                                                                  |

The key names are `enter`, `delete`, `tab`, `escape`, `space`, `up`, `down`, `left`, `right`, `page_up`, `page_down`, `move_home`, `move_end`, `menu`, `search`, `volume_up` and `volume_down`.

## Targets [#targets]

Each target takes exactly one of these forms:

| Form        | Example                                                  | How it finds the element                                                                                                   |
| ----------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Coordinates | `{"x": 540, "y": 950}`                                   | A point in device pixels.                                                                                                  |
| Ref         | `{"ref": 3, "snapshot_id": "snp_4f2k7m3q6z3a"}`          | The element with that ref in an earlier [observation](/docs/using-phones/observe), found again on the screen as it is now. |
| Text        | `{"text": "Sign in"}` or `{"text": "Sign in", "nth": 2}` | An element whose text or description matches, as described below.                                                          |
| ID          | `{"id": "sign_in"}`                                      | An element with that resource ID, either in full or the part after `:id/`.                                                 |
| Description | `{"desc": "Back"}`                                       | An element whose content description matches, in the same way as text.                                                     |

A text target tries three levels, in order:

1. text or a description that is exactly the target;
2. the same match, ignoring case;
3. text or a description that contains the target, ignoring case.

The first level that finds anything decides, and within it clickable elements win over the rest. If one element remains, that is the target. `nth` picks among several, counting from 1 in reading order. If several remain and there is no `nth`, the action fails with `ambiguous_target`. Only text targets take `nth`: an ID or description target that matches several elements fails with `ambiguous_target` too, and you narrow it, for example with the full resource ID, or target one of the candidates by ref.

A ref target is checked before it is used. The element must still be on the screen with the same type, resource ID, text and description, and overlap its old position by at least half. Otherwise the action fails with `stale_snapshot`, and you observe again.

When a target finds nothing, the action fails with `target_not_found`. Its `details.target` repeats your target, and `details.candidates` lists up to 10 elements that share a word with it, or else clickable elements. When `nth` is larger than the number of matches, `details.matches` says how many there were.

## Batch rules [#batch-rules]

* A batch has from 1 to 20 actions, and the `ms` of its `wait` actions and the `timeout_ms` of its `wait_for` actions may add up to 60 seconds at most. A `wait_for` without a `timeout_ms` counts as 10 seconds. A batch that breaks either rule fails with `400 validation_failed`.
* Actions run in order, and the batch stops at the first action that fails. `results` has an entry for each action that ran, ending with the one that failed, and the actions after it never run. The actions before the failed one did run, so when you try again, send only the failed action, changed as needed, and the ones after it.
* A batch that has run for 90 seconds starts no more actions. The next one fails with the retryable `provider_timeout`, and its `details.not_run` counts the actions that didn't run.
* `observe` chooses what the response shows of the screen afterwards. `ui`, the default, adds the element list as an `observation`. `screenshot` adds only a 720-pixel-wide JPEG screenshot, `both` adds both, and `none` adds nothing.
* A batch that stops early shows the element list, whatever `observe` asked for, so you can see where it stopped, unless the screen can't be read, as the next rule describes. `none` and `ui` add it without a screenshot, and `screenshot` and `both` add it with one.
* Reading the screen after a batch gets only the time the request has left. When the read doesn't finish in time, the response has `results` and `completed` but no `observation`, so observe the screen before you continue.
* A request that runs a batch may take up to 300 seconds, so give your HTTP client a timeout of about 300 seconds for batches.
* Phonebox never retries an action for you, and it never replays a batch. A retry is always your decision, made after you look at the screen.

Problems with the request itself fail the whole request with an [error envelope](/docs/interfaces/rest#errors) and an HTTP error status. That covers the key, the body, a phone that isn't `ready`, rate limits, and a phone that couldn't be reached before the first action. Once the batch runs, a failed action is part of a `200` response, with `ok: false` and an `error` in its result.

## When an action fails [#when-an-action-fails]

This batch stops at its second action, because three buttons on the screen read "Add to cart":

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "scroll", "direction": "down" },
    { "type": "tap", "target": { "text": "Add to cart" } },
    { "type": "wait_for", "target": { "text": "Added to cart" }, "timeout_ms": 5000 }
  ]
}
```

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "scroll", "ok": true, "ms": 702 },
    {
      "index": 1, "type": "tap", "ok": false, "ms": 391,
      "error": {
        "code": "ambiguous_target",
        "message": "Several elements on the screen match the target.",
        "retryable": false,
        "next": "Add nth to the target, or tap by ref from the returned observation.",
        "details": {
          "candidates": [
            { "ref": 3, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "center": [860, 620] },
            { "ref": 5, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "center": [860, 900] },
            { "ref": 7, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "center": [860, 1180] }
          ],
          "snapshot_id": "snp_4f2k7m3q6z3a"
        }
      }
    }
  ],
  "completed": 1,
  "observation": {
    "snapshot_id": "snp_4f2k7m3q6z3a",
    "taken_at": "2026-09-29T11:15:09.240Z",
    "app": { "package": "com.example.shop", "activity": null },
    "keyboard": { "visible": false, "focused_editable": false },
    "screen": { "width": 1080, "height": 2400 },
    "elements": [
      { "ref": 1, "type": "EditText", "text": "Search products", "desc": null, "id": "com.example.shop:id/search", "bounds": [60, 140, 1020, 240], "center": [540, 190], "state": ["clickable", "editable"] },
      { "ref": 2, "type": "TextView", "text": "Wireless earbuds", "desc": null, "id": null, "bounds": [60, 560, 640, 680], "center": [350, 620], "state": [] },
      { "ref": 3, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "bounds": [700, 560, 1020, 680], "center": [860, 620], "state": ["clickable"] },
      { "ref": 4, "type": "TextView", "text": "Phone stand", "desc": null, "id": null, "bounds": [60, 840, 640, 960], "center": [350, 900], "state": [] },
      { "ref": 5, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "bounds": [700, 840, 1020, 960], "center": [860, 900], "state": ["clickable"] },
      { "ref": 6, "type": "TextView", "text": "USB-C cable", "desc": null, "id": null, "bounds": [60, 1120, 640, 1240], "center": [350, 1180], "state": [] },
      { "ref": 7, "type": "Button", "text": "Add to cart", "desc": null, "id": "com.example.shop:id/add_to_cart", "bounds": [700, 1120, 1020, 1240], "center": [860, 1180], "state": ["clickable"] }
    ],
    "truncated": false,
    "text": "app com.example.shop\nkeyboard hidden\nscreen 1080x2400\n[1] EditText \"Search products\" #search clickable editable (540,190)\n[2] TextView \"Wireless earbuds\" (350,620)\n[3] Button \"Add to cart\" #add_to_cart clickable (860,620)\n[4] TextView \"Phone stand\" (350,900)\n[5] Button \"Add to cart\" #add_to_cart clickable (860,900)\n[6] TextView \"USB-C cable\" (350,1180)\n[7] Button \"Add to cart\" #add_to_cart clickable (860,1180)"
  }
}
```

The screen may move between the moment an action fails and the moment the batch reads the screen. Phonebox therefore matches each candidate to the element in the returned observation, gives it that element's ref, and names the observation's `snapshot_id` in `details.snapshot_id`. If any candidate is no longer on the screen, `details` keeps the refs as the action saw them and names no snapshot, and you observe again before acting by ref.

To tap the phone stand's button, target it by ref with that snapshot, or by its text with `nth`:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "ref": 5, "snapshot_id": "snp_4f2k7m3q6z3a" } },
    { "type": "wait_for", "target": { "text": "Added to cart" }, "timeout_ms": 5000 }
  ]
}
```

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "text": "Add to cart", "nth": 2 } }
  ]
}
```

These are the errors an action's result can carry, and what to do about each:

| Code                     | Meaning                                                                                             | What to do                                                                                                                                                                                                            |
| ------------------------ | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target_not_found`       | No element matches the target.                                                                      | Observe, then use one of `details.candidates`, or scroll to bring the element into view.                                                                                                                              |
| `ambiguous_target`       | Several elements match.                                                                             | Target a ref from `details.candidates` with `details.snapshot_id`, or add `nth` to a text target. Only text targets take `nth`, so narrow an ID or description target instead, for example with the full resource ID. |
| `stale_snapshot`         | The element behind a ref changed or went away.                                                      | Observe again and use the new refs.                                                                                                                                                                                   |
| `no_focused_field`       | A `type` action found no focused text field.                                                        | Tap the field first, then type.                                                                                                                                                                                       |
| `wait_timeout`           | A `wait_for` ran out of time. It is retryable, and `details` has the `timeout_ms` and `target`.     | Observe. If the screen is still loading, wait again with a longer `timeout_ms`.                                                                                                                                       |
| `app_not_found`          | `open_app` or `close_app` named an app that isn't installed.                                        | [Install it](/docs/using-phones/apps), or check the package name.                                                                                                                                                     |
| `action_outcome_unknown` | The phone didn't confirm the action. It may or may not have happened.                               | Observe before anything else. See below.                                                                                                                                                                              |
| `phone_unavailable`      | The phone was briefly out of reach.                                                                 | When `retryable` is true, the action didn't run, and you can send it and the rest of the batch again.                                                                                                                 |
| `rate_limited`           | The phone service was busy.                                                                         | Wait `details.retry_after_seconds`, then send the rest of the batch again.                                                                                                                                            |
| `provider_error`         | The phone service refused the action.                                                               | Observe, then decide whether to try again.                                                                                                                                                                            |
| `provider_timeout`       | The batch ran out of time before this action. `details.not_run` counts the actions that didn't run. | Observe, then send them again in a new batch.                                                                                                                                                                         |
| `capacity_unavailable`   | The phone service had no capacity for this action, so it didn't run.                                | Send it and the actions after it again later.                                                                                                                                                                         |
| `internal_error`         | Something failed on our side, and the action may have run.                                          | Observe before you decide what to send again.                                                                                                                                                                         |

## Nothing ran [#nothing-ran]

When the first action of a batch couldn't reach the phone at all, the whole request fails with `503 phone_unavailable`, and both `Retry-After` and `details.retry_after_seconds` say to wait 2 seconds. Nothing was done on the phone, so sending the same batch again is safe:

```json title="Response: error"
{
  "error": {
    "type": "provider_error",
    "code": "phone_unavailable",
    "message": "The phone is temporarily unavailable.",
    "retryable": true,
    "next": "Nothing was done on the phone; retry this action in a few seconds.",
    "request_id": "req_w5x3y6z2a4b7",
    "details": { "retry_after_seconds": 2 }
  }
}
```

This happens most often in the first seconds after a phone becomes ready. Other failures before the first action mean that nothing ran, too: `503 capacity_unavailable` and `429 rate_limited` come with a `Retry-After`, while `502 provider_error`, and a `503 phone_unavailable` during maintenance, come without one. When a later action couldn't reach the phone, the earlier ones already ran, so the batch returns `200` as usual. The failed action's result then has `phone_unavailable` with `retryable` set to `true`, which means that action didn't run, and you can send it and the ones after it again.

These rules hold only for Phonebox's own errors, which carry the [error envelope](/docs/api-reference/errors) with an `error.code`. A `502` or `504` without it, which a proxy or the hosting platform sends, a timeout on your side, or a dropped connection says nothing about the batch, which may have run: observe before you send it again.

## Unknown outcomes [#unknown-outcomes]

`action_outcome_unknown` means the phone didn't confirm the action: it may have happened or not. Its `retryable` is always `false`. Never repeat such an action blindly, since a second tap on "Pay" or a second copy of a message can do real harm. Observe the screen, work out whether the action took effect, and continue from there.

## Partial actions [#partial-actions]

Some actions take two steps. `type` with `submit` types the text and then presses Enter, and `double_tap` taps twice. When the second step fails after the first one reached the phone, the result has `details.partial` set to `true` and `retryable` set to `false`, whatever the error, and `next` says what was done. In this batch, the text was typed but Enter never reached the phone:

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "tap", "ok": true, "ms": 197, "resolved": { "ref": 2, "center": [540, 700] } },
    {
      "index": 1, "type": "type", "ok": false, "ms": 1214,
      "error": {
        "code": "phone_unavailable",
        "message": "The phone is temporarily unavailable.",
        "retryable": false,
        "next": "The text was typed but Enter wasn't pressed. Observe the screen, then send a key action for enter.",
        "details": { "retry_after_seconds": 2, "partial": true }
      }
    }
  ],
  "completed": 1,
  "observation": {
    "snapshot_id": "snp_7d3h6j2n5p4r",
    "taken_at": "2026-09-29T11:32:47.615Z",
    "app": { "package": "com.example.app", "activity": null },
    "keyboard": { "visible": true, "focused_editable": true },
    "screen": { "width": 1080, "height": 2400 },
    "elements": [
      { "ref": 1, "type": "TextView", "text": "Welcome back", "desc": null, "id": null, "bounds": [60, 400, 1020, 520], "center": [540, 460], "state": [] },
      { "ref": 2, "type": "EditText", "text": "alex@example.com", "desc": null, "id": "com.example.app:id/email", "bounds": [60, 640, 1020, 760], "center": [540, 700], "state": ["clickable", "editable", "focused"] },
      { "ref": 3, "type": "Button", "text": "Sign in", "desc": null, "id": "com.example.app:id/sign_in", "bounds": [60, 890, 1020, 1010], "center": [540, 950], "state": ["clickable"] }
    ],
    "truncated": false,
    "text": "app com.example.app\nkeyboard visible, text field focused\nscreen 1080x2400\n[1] TextView \"Welcome back\" (540,460)\n[2] EditText \"alex@example.com\" #email clickable editable focused (540,700)\n[3] Button \"Sign in\" #sign_in clickable (540,950)"
  }
}
```

Observe before you continue, and never press Enter blindly. Here `phone_unavailable` means Enter never reached the phone, and the observation still shows the filled field, so a `key` action for `enter` finishes the step. Don't send a second `type`, which would type the text again. When the error is `action_outcome_unknown` instead, Enter may have been pressed after all: check that the screen hasn't moved on before you press it.

## Wait for the screen [#wait-for-the-screen]

Screens take time to change after a tap. Instead of fixed pauses, use `wait_for` to wait for what you expect to see, or for a spinner to go away:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "text": "Sign in" } },
    { "type": "wait_for", "target": { "text": "Loading" }, "gone": true, "timeout_ms": 20000 },
    { "type": "wait_for", "target": { "text": "Welcome" }, "timeout_ms": 5000 }
  ],
  "observe": "ui"
}
```

`wait_for` finds its target the same way as a text, ID or description target, and it checks the screen every 500 ms. When the time runs out, it fails with `wait_timeout`, which is retryable. The CLI's `phonebox wait` exits with code 3 in that case.

## In the CLI and the SDK [#in-the-cli-and-the-sdk]

The CLI's `tap`, `type`, `key`, `swipe`, `scroll`, `open`, `wait`, `back`, `home` and `recents` commands, and the matching SDK methods such as `tap()`, `type()` and `waitFor()`, each send a batch of one action with `"observe": "none"`. When that action fails, the CLI prints its error and exits with code 1, or 3 when `wait` runs out of time, and the SDK throws a `PhoneboxError`. `phonebox act` and the SDK's `act()` send a whole batch and return every result, so check `completed` and each `ok` yourself.


---

# Apps

Source: https://phonebox.dev/docs/using-phones/apps

> List, install, open and uninstall apps on a phone, your own APKs included.



Every app request needs the phone to be `ready`. Installed apps and their data, signed-in accounts included, stay on the phone when it parks, so an app you set up once is there the next time you start the phone.

## List installed apps [#list-installed-apps]

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The response's `data` lists every installed app, the system apps that came with the phone included, and each entry has these fields:

| Field          | Meaning                                                 |
| -------------- | ------------------------------------------------------- |
| `package`      | The Android package name, such as `com.android.chrome`. |
| `label`        | The name the launcher shows.                            |
| `version_name` | The version as the app displays it.                     |
| `version_code` | The version as a number.                                |
| `system`       | `true` for an app that came with the phone.             |

## Install an app [#install-an-app]

An install by package name comes from Phonebox's app library, never from Google Play:

```json title="POST /v1/phones/{id}/apps"
{
  "package": "com.example.app"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"package": "com.example.app"}'
```

Phonebox starts the install and answers `202 Accepted` right away, with the package and `"status": "running"`, while the install continues in the background. An app that isn't in the library fails with `422 app_not_available`, whose `next` says to install it from the Play Store app on the phone instead.

A phone runs at most two installs at once, and one per app. One more, while two run or while the same app is still installing, fails with `409 install_in_progress`. That error is retryable. An install usually takes a few seconds, so wait a few seconds, check the installs list below, or `phonebox apps installs` in the CLI, and send the install again once the running install has finished. After a few refused tries, stop and tell your user.

To follow an install, list the recent installs:

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps/installs \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Each entry in `data` has the `package`, the `upload` and `install` IDs for an install by upload (otherwise `null`), a `status` of `running`, `succeeded` or `failed`, an `error` code when it failed (otherwise `null`) with a `next` step when Phonebox can tell, and `started_at` and `updated_at`. You can also just try to open the app: `open_app` fails with `app_not_found` until the install has finished.

### Apps from the Play Store [#apps-from-the-play-store]

The app library is a small set of ready-to-install apps, not a complete app store. In the console, **Install → Open Play Store** opens Google Play on the phone; **Upload an APK** installs your own APK.

To install an app from Google Play, use the Play Store app on the phone, as a person would. The phone needs a Google account for that. Google's sign-in asks for a password and often a second step, so hand it to a person once with a [live view link](/docs/using-phones/live-view). The phone stays signed in while it is parked. Then open the app's page with an `open_url` action for `market://details?id=` and the package, observe, and tap Install.

### Your own builds [#your-own-builds]

Your own APK, such as the debug build your coding agent just made, installs in one command:

```bash
phonebox apps "$PHONEBOX_PHONE" install app/build/outputs/apk/debug/app-debug.apk
```

The CLI uploads the file, waits while Phonebox reads its manifest, installs it and waits until the phone lists the app at the APK's `versionCode`. The SDK does the same with `phone.installApk(path)`. Through the REST API it is two steps: an [upload](/docs/api-reference/uploads), which takes files of up to 500 MiB straight to Phonebox's storage, then an install that names it:

```json title="POST /v1/phones/{id}/apps"
{
  "upload": "upl_q3m7x2k6v4ta",
  "replace": false
}
```

It answers `202 Accepted` with the APK's package, the upload, the install's own ID (`ins_…`) and `"status": "running"`. The install's entry in the installs list has the same ID, and it succeeds only once the phone lists the package at the upload's `version_code`. Android keeps one signing key per app and doesn't downgrade one, so:

* An older `version_code` than the phone has fails at once with `409 version_downgrade`.
* A build signed with another key fails on the phone, and its entry's `next` says so.
* `"replace": true` fixes both: it uninstalls the app first, which deletes its data and sign-ins, then installs the upload.

[Test your Android build with a coding agent](/docs/guides/test-android-builds) puts it together with walking the new screens.

## Open, close and link into apps [#open-close-and-link-into-apps]

Apps open through [actions](/docs/using-phones/actions):

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "open_app", "package": "org.wikipedia" },
    { "type": "wait_for", "target": { "text": "Search Wikipedia" }, "timeout_ms": 15000 }
  ],
  "observe": "ui"
}
```

* `open_app` opens an installed app, at a given `activity` if you name one.
* `close_app` stops an app. With `"clear_data": true` it also erases the app's data, which signs it out of every account.
* `open_url` opens a web address or a deep link, in the app you name with `package`, or in whichever app handles it.

`open_app` and `close_app` fail with `app_not_found` when the app isn't installed. In the first seconds after a phone becomes ready, it can still be finishing its own start-up, and it may send an app you just opened to the background. If the app isn't in front when you observe, open it again.

## Uninstall an app [#uninstall-an-app]

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps/org.wikipedia \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The response is `{"package": "org.wikipedia", "uninstalled": true}`. Removing an app that isn't installed fails with `404 app_not_found`. Uninstalling deletes the app's data with it.

## From the CLI and the SDK [#from-the-cli-and-the-sdk]

| CLI                                                 | SDK                                                |
| --------------------------------------------------- | -------------------------------------------------- |
| `phonebox apps`                                     | `phone.apps.list()`                                |
| `phonebox apps install com.example.app`             | `phone.apps.install("com.example.app")`            |
| `phonebox apps install ./app-debug.apk [--replace]` | `phone.installApk("./app-debug.apk", { replace })` |
| `phonebox uploads`                                  | `pb.uploads.list()`                                |
| `phonebox apps rm org.wikipedia`                    | `phone.apps.uninstall("org.wikipedia")`            |
| `phonebox apps installs`                            | `phone.apps.installs()`                            |
| `phonebox open org.wikipedia`                       | `phone.open("org.wikipedia")`                      |

`phonebox open` and `phone.open()` open a URL or deep link instead when you give them one.


---

# Files

Source: https://phonebox.dev/docs/using-phones/files

> Upload, download, list and delete files in a phone's shared storage.



Files live in the phone's shared storage under `/sdcard`, the storage that apps such as the file manager, the camera and the browser's downloads use. `/storage/emulated/0` is another name for the same place. Files stay on the phone when it parks, and every file request needs the phone to be `ready`.

Each request names its file or directory in the `path` query parameter:

* A path is `/sdcard` or a path inside it, or the same under `/storage/emulated/0`, with at most 1024 characters.
* A path can't contain `.` or `..` segments.
* URL-encode the path when it has spaces or other special characters.

A file can be at most 4 MB (4,000,000 bytes), whether you upload it or download it.

## Upload a file [#upload-a-file]

Send the file's bytes as the request body, not as JSON or a form:

```bash
curl -X PUT "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @report.pdf
```

The path must end with the file's name. Directories on the way that don't exist yet are created. The response is `201 Created` with the `path` and the `size` in bytes. A body over 4 MB fails with `413 payload_too_large`.

## Download a file [#download-a-file]

```bash
curl -o report.pdf "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files/content?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The response is the file itself, as `application/octet-stream`, with its name in the `Content-Disposition` header. A path with no file fails with `404 file_not_found`, and a path to a directory fails with `400 validation_failed`, whose `next` tells you how to list it instead. A file over 4 MB fails with `413 payload_too_large`. When the file's size isn't known in advance, a download that passes 4 MB is cut off instead.

## List a directory [#list-a-directory]

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Without `path`, the request lists `/sdcard`. The response has the `path` and a `data` list with one entry for each file or directory:

| Field         | Meaning                                                |
| ------------- | ------------------------------------------------------ |
| `name`        | The entry's name.                                      |
| `type`        | `file`, `directory` or `other`.                        |
| `size`        | Its size in bytes.                                     |
| `modified_at` | When it last changed, or `null` when that isn't known. |

A directory that doesn't exist fails with `404 file_not_found`. A path to a file fails with `400 validation_failed`, whose `next` points to the download request for it.

## Delete a file [#delete-a-file]

```bash
curl -X DELETE "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The response has the `path` and `"deleted": true`. A path with nothing at it fails with `404 file_not_found`, and the storage root itself can't be deleted.

Directories are not supported by this endpoint: they return `400 validation_failed` without deleting any contents. To delete a folder, open the Files app on the phone. In the console, choose **Open Files** in the Files tab, then delete the folder on the phone screen.

## From the CLI and the SDK [#from-the-cli-and-the-sdk]

| CLI                                                          | SDK                                                        |
| ------------------------------------------------------------ | ---------------------------------------------------------- |
| `phonebox files ls /sdcard/Download`                         | `phone.files.list("/sdcard/Download")`                     |
| `phonebox files push report.pdf /sdcard/Download/`           | `phone.files.upload("/sdcard/Download/report.pdf", bytes)` |
| `phonebox files pull /sdcard/Download/report.pdf report.pdf` | `phone.files.download("/sdcard/Download/report.pdf")`      |
| `phonebox files rm /sdcard/Download/report.pdf`              | `phone.files.remove("/sdcard/Download/report.pdf")`        |

When the CLI's `push` target ends with `/`, the local file keeps its name in that directory.


---

# Device settings

Source: https://phonebox.dev/docs/using-phones/device

> Read a phone's device info, set its clipboard, location, locale and timezone, record its screen, and reboot or reset it.



These requests change the phone itself. Each one needs the phone to be `ready` and a key with the `phones:control` scope, reading the clipboard included.

## Clipboard [#clipboard]

Set the clipboard, for example to paste a long value into a field:

```json title="PUT /v1/phones/{id}/clipboard"
{
  "text": "https://example.com/reset?code=7F3K9Q"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/clipboard \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "https://example.com/reset?code=7F3K9Q"}'
```

The text can be up to 10,000 characters, and the response repeats it as `{"text": …}`. Read the clipboard with `GET /v1/phones/{id}/clipboard`, which returns the same shape. Phonebox records clipboard text in the console's activity only as `[redacted]`.

## Location [#location]

Set the location that the phone reports to apps, in decimal degrees:

```json title="PUT /v1/phones/{id}/location"
{
  "lat": 52.520008,
  "lng": 13.404954
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/location \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"lat": 52.520008, "lng": 13.404954}'
```

`lat` runs from -90 to 90 and `lng` from -180 to 180. By default the phone's timezone is set to the one at that location too, so its clock matches the place, and the response repeats the coordinates with the `timezone` it set; send `"timezone_from_location": false` to change only the location. `GET /v1/phones/{id}/location` reads it back, and `DELETE /v1/phones/{id}/location` goes back to the default location, New York, and answers `{"reset": true}`.

## Locale [#locale]

Change the phone's language and region:

```json title="PUT /v1/phones/{id}/locale"
{
  "locale": "de-DE"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/locale \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"locale": "de-DE"}'
```

A locale is a language code, then an optional script and an optional region: `en-US`, `de-DE`, `zh-Hant-TW` or `es-419`. Changing it restarts the phone's interface, so observe the screen again before you act, and expect the labels on it to be in the new language.

## Timezone [#timezone]

```json title="PUT /v1/phones/{id}/timezone"
{
  "timezone": "America/New_York"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/timezone \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"timezone": "America/New_York"}'
```

Use an IANA time zone name, spelled exactly, such as `Europe/Berlin` or `Asia/Kolkata`. Offsets such as `+05:30` aren't accepted.

## Reboot [#reboot]

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/reboot \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

A reboot restarts the phone and keeps everything on it. The request answers `202 Accepted` with the phone in `starting`, and the phone has to prove it is ready again, as after a start. Wait for it with `GET /v1/phones/{id}?wait=ready` before you use it. The session stays open, so the reboot's time is billed like any other.

A phone can reboot at most once every 10 minutes. A second reboot within that time fails with `429 rate_limited`, and both `details.retry_after_seconds` and the `Retry-After` header say how long to wait:

```json title="Response: error"
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Too many requests. Wait a moment and try again.",
    "retryable": true,
    "next": "A phone can reboot once every 10 minutes.",
    "request_id": "req_c6d3e7f2g5h4",
    "details": { "retry_after_seconds": 412 }
  }
}
```

If the phone service doesn't confirm the reboot, the request fails with `502 action_outcome_unknown`, and its `next` tells you to wait for the phone to be ready. The phone may be rebooting, so Phonebox checks its readiness again either way. Never send a second reboot to find out.

## Reset [#reset]

`POST /v1/phones/{id}/reset` wipes the phone back to a clean state between runs, without creating a new one: its apps and their data, its files, its signed-in accounts and its clipboard are deleted. The phone keeps its ID and session, goes to `starting`, and is back in about a minute. It needs an Admin key, and it can't be undone. [Reset](/docs/api-reference/device#reset) has the details.

## Device info and recordings [#device-info-and-recordings]

`GET /v1/phones/{id}/device` shows the phone's brand, model, Android version, screen size and carrier; `GET` on `/location`, `/locale` and `/timezone` reads those settings back. [Recordings](/docs/api-reference/recordings) capture the screen as a video you can download, to watch what an agent did.

## From the SDK and the CLI [#from-the-sdk-and-the-cli]

In the SDK, these are methods of a phone: `phone.clipboard.get()`, `phone.clipboard.set(text)`, `phone.info()`, `phone.getLocation()`, `phone.setLocation(lat, lng)`, `phone.resetLocation()`, `phone.getLocale()`, `phone.setLocale(locale)`, `phone.getTimezone()`, `phone.setTimezone(timezone)`, `phone.recordings`, `phone.reboot()` and `phone.reset()`. `reboot()` and `reset()` return without waiting, so follow them with `phone.waitUntil("ready")`. The CLI has `device`, `location`, `record`, `reboot` and `reset`.


---

# Live view and human handoff

Source: https://phonebox.dev/docs/using-phones/live-view

> Create a link that lets a person watch and control a phone in the browser, for sign-ins and other steps your agent shouldn't take alone.



A live view link opens the phone's screen in a browser, where a person can watch it and control it without an account or a key. Use it to hand the phone to a person for a step your agent can't or shouldn't take alone, such as a sign-in with a code sent to their own phone, a payment, or a consent screen. Then take the phone back when they're done.

## Create a link [#create-a-link]

```json title="POST /v1/phones/{id}/live"
{
  "expires_in": 900
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/live \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expires_in": 900}'
```

```json title="Response: live"
{
  "url": "https://phonebox.dev/live/u9rVPHtjr7pzfvGcJMUZKdT_8ARJufF_tVOk5dngZ0w",
  "expires_at": "2026-09-29T11:15:00.084Z"
}
```

`expires_in` is in seconds, from 60 to 86400, which is 24 hours. The default is 3600, one hour. The phone must be `ready`, and the key needs the `phones:control` scope. The CLI's `phonebox live --expires 15m` and the MCP tool `live_view_url` create the same links.

## What the person can do [#what-the-person-can-do]

The page shows the phone's name, how long until it parks, and a view of its screen that keeps updating. The person can:

* tap, and swipe by dragging across the screen;
* type text into the focused field;
* press Back, Home and Recents.

While the page is open, it keeps the phone from parking for being idle. The session's maximum length still applies, so start the phone again to renew it if a handoff runs long. When the phone parks, the page can't control it until the phone starts again.

## Anyone with the link can control the phone [#anyone-with-the-link-can-control-the-phone]

A live view link grants full control of the phone, and of every account signed in on it, until the link expires. Treat it like a password:

* Send it only to the person who needs it, over a private channel.
* Don't post it in issues, logs, shared chats or anywhere else others can read it.
* Choose the shortest expiry that works for the task. A link can't be revoked early, so it stays valid until `expires_at`, or until you delete the phone.

Phonebox stores only a hash of each link and never writes the link itself to activity.

## A handoff, step by step [#a-handoff-step-by-step]

1. Your agent reaches a screen it shouldn't complete itself, such as a request for a code that was sent to your user.
2. It creates a link with a short expiry and sends the URL to your user.
3. It waits for the screen that comes after the step, with a `wait_for` of up to 30 seconds at a time:

   ```json title="POST /v1/phones/{id}/actions"
   {
     "actions": [
       { "type": "wait_for", "target": { "text": "Inbox" }, "timeout_ms": 30000 }
     ],
     "observe": "ui"
   }
   ```

   A `wait_timeout` means the person isn't done yet, so the agent sends the same request again, for as long as the link lasts. The stopped batch shows the screen, so when a different screen appears, such as an error or another check, the agent decides again instead of waiting. When the link expires and the person still hasn't finished, the agent stops waiting, parks the phone and tells your user. Each request counts as activity, so the phone doesn't park while your agent waits.
4. When the expected screen appears, your agent observes and carries on.


---

# REST API

Source: https://phonebox.dev/docs/interfaces/rest

> 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](/openapi.json) describes every endpoint.

## Requests [#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](/docs/api-reference/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](/docs/getting-started/authentication) covers keys and scopes.

```http
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_failed` instead 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 [#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]

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 [#errors]

Every error has the same envelope:

```json title="Response: error"
{
  "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](/docs/using-phones/actions) 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]

`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 [#pagination]

`GET /v1/phones` and `GET /v1/phones/{id}/sessions` return a page:

```json title="Response: phones"
{
  "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 [#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](/docs/using-phones/lifecycle) describes every wait in detail.

## Retries [#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 `retryable` is `true`, the same request may succeed later. Wait for `Retry-After` when the response has one, or a few seconds when it doesn't. For a write, `retryable` doesn'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_error` ran nothing, so you may send it again once you've dealt with the error, as [When nothing ran](/docs/api-reference/actions#when-nothing-ran) explains. In a batch that ran, an action result whose error says the action didn't run, such as `phone_unavailable` with `retryable` set to `true`, 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 `502` or `504` without 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 same `Idempotency-Key`, and observe before you repeat anything else. [Failures without the envelope](/docs/api-reference/errors#failures-without-the-envelope) has the details.

## Rate limits [#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.


---

# CLI

Source: https://phonebox.dev/docs/interfaces/cli

> Install the phonebox CLI, set its environment, and use every command, its output and its exit codes.



The `phonebox` CLI drives phones from a terminal. It suits coding agents, which already work in one, and quick checks by hand. Every command calls the [REST API](/docs/interfaces/rest), so keys, limits and billing work as they do there.

## Install [#install]

The CLI needs Node.js 20 or later. One command installs it and sets you up:

```bash
curl -fsSL https://phonebox.dev/install | sh
```

The script installs the package with npm and runs [`phonebox setup`](#setup). To install without setting up:

```bash
npm install -g https://phonebox.dev/downloads/phonebox-0.1.0.tgz
phonebox --help
```

Run the CLI as the installed `phonebox` command. The package ships only from phonebox.dev, so an `npx` call that doesn't name the file would fetch whatever the npm registry holds under that name, and run it with your key in its environment. For a one-off run without installing anything, name the file:

```bash
npx -y --package=https://phonebox.dev/downloads/phonebox-0.1.0.tgz phonebox --help
```

Inside a project, `npm install https://phonebox.dev/downloads/phonebox-0.1.0.tgz` adds the package, which is also the [TypeScript SDK](/docs/interfaces/sdk). The package's SHA-256 checksum is published next to it, at `https://phonebox.dev/downloads/phonebox-0.1.0.tgz.sha256`.

## Environment [#environment]

| Variable           | Meaning                                                                                                           |
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `PHONEBOX_API_KEY` | Your API key. It wins over the key that `phonebox setup` saved; without either, commands fail with a usage error. |
| `PHONEBOX_PHONE`   | A phone ID. Commands that act on a phone use it when you leave the ID out.                                        |
| `PHONEBOX_URL`     | Another API origin. Leave it unset to use `https://phonebox.dev`.                                                 |

```bash
export PHONEBOX_PHONE=ph_7kx2m6q4v3ta
phonebox look
```

## Commands [#commands]

`<id>` is a phone ID, which you can leave out when `PHONEBOX_PHONE` is set. A first argument that starts with `ph_` but isn't a valid phone ID, or an argument that the command doesn't take, is a usage error, so a mistyped ID never falls back to the phone in `PHONEBOX_PHONE`. Durations such as `--idle`, `--max`, `--expires` and `--timeout` take seconds or forms like `90s`, `10m` and `1h30m`. Every command prints its own help with `--help`, as in `phonebox tap --help`.

### Setup [#setup]

```text
phonebox setup [--no-wait] [--no-open] [--email] [--amount 10] [--client NAME]
phonebox login [--no-wait] [--no-open] [--email] [--client NAME]
phonebox logout
phonebox token
```

| Command  | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setup`  | Signs you in, adds credit and confirms the account, skipping what is already done. It prints a link and a code: open the link, sign in with Google, or with an emailed code when you pass `--email`, check that the page shows the same code, and click Connect. `--client` names this terminal on that page and on its key. It then prints a checkout link when the project has no credit, for $10 or `--amount`. On a terminal it opens each link in your browser unless you pass `--no-open`, and waits up to 15 minutes for you. `--no-wait` prints the current step and exits at once, which is how agents use it. |
| `login`  | The sign-in part of `setup` alone. Its last line has the step `signed_in`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `logout` | Forgets the saved key. The key stays valid until you revoke it under [API keys](/app/keys).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `token`  | Prints the key in use, as in `export PHONEBOX_API_KEY=$(phonebox token)`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

Setup saves an Agent key in `~/.config/phonebox/credentials.json`, or under `$XDG_CONFIG_HOME`, readable only by you. Its progress goes to stderr, and its last line on stdout is one JSON object whose `step` is `sign_in`, `add_credits` or `ready`:

```text
{"step":"sign_in","url":"https://phonebox.dev/connect?code=K7QM-2XHD","user_code":"K7QM-2XHD","expires_in":900,"next":"Ask your user to open the URL, sign up or in, and confirm the code. Then run phonebox setup again."}
```

### Phones [#phones]

```text
phonebox ls [--status STATUS]
phonebox create [--name NAME] [--country CC] [--idle 10m] [--max 1h] [--idempotency-key KEY] [--no-wait]
phonebox start <id> [--idle 10m] [--max 1h] [--no-wait]
phonebox park <id> [--no-wait]
phonebox rm <id> --yes
phonebox status <id> [--wait [--timeout 10m]]
phonebox live <id> [--expires 1h]
phonebox account
```

`--country` takes one of the two-letter codes that `phonebox account` lists under `countries`, such as `US` or `DE`. Leave it out for any country.

| Command   | What it does                                                                                                                                                                                                                                                                                                                                                              |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ls`      | Lists the project's 50 newest phones, leaving out failed and deleted ones, or only those in one status with `--status`, such as `--status failed`. To see more, page through [List phones](/docs/api-reference/phones#list-phones) with its `cursor`.                                                                                                                     |
| `create`  | Creates a phone and waits until it is ready, for up to 10 minutes. The moment the phone exists, before the wait, it prints the phone's ID on stderr. `--no-wait` prints the new phone at once instead of waiting. `--idempotency-key` makes a repeat of the same create return the same phone, and a repeat that finds that phone failed or deleted exits with its error. |
| `start`   | Starts a parked phone, with its apps, files and sign-ins kept, and waits until it is ready, for up to 5 minutes. On a running phone, it renews the session.                                                                                                                                                                                                               |
| `park`    | Parks a phone and waits until it has stopped. Parked phones cost nothing.                                                                                                                                                                                                                                                                                                 |
| `rm`      | Deletes a phone permanently, with its apps, files and sign-ins. It needs `--yes` and an Admin key.                                                                                                                                                                                                                                                                        |
| `status`  | Shows a phone's status and session. `--wait` waits until the phone is ready, for up to 10 minutes or `--timeout`, only reading it, and exits with code 3 when time runs out. `--timeout` without `--wait` is a usage error. Wait this way for a phone that is still creating or starting, because `start` renews a running phone's session.                               |
| `live`    | Creates a [live view link](/docs/using-phones/live-view) that lets a person watch and control the phone in a browser. Anyone with the link controls the phone until it expires, so share it only with your user, and never paste it into logs or shared chats.                                                                                                            |
| `account` | Shows your balance, limits and price.                                                                                                                                                                                                                                                                                                                                     |

### The screen [#the-screen]

```text
phonebox look <id> [--screenshot FILE.jpg] [--json]
phonebox screenshot <id> <FILE> [--png]
```

| Command      | What it does                                                                                                                                                                                                                                                                                                              |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `look`       | Prints the screen in the [text form](/docs/using-phones/observe#the-text-form), after a first line that names its snapshot, such as `snapshot snp_4f2k7m3q6z3a`, which a tap by ref needs. `--json` prints the full observation instead, with its `snapshot_id`. `--screenshot` also saves a full-size JPEG to that file. |
| `screenshot` | Saves a screenshot to the file, as JPEG, or as PNG with `--png`.                                                                                                                                                                                                                                                          |

### Actions [#actions]

```text
phonebox tap <id> (X Y | --text T [--nth N] | --id R | --desc D | --ref N --snapshot S) [--long] [--double]
phonebox type <id> <TEXT> [--clear] [--submit]
phonebox key <id> <NAME|CODE>
phonebox back <id>
phonebox home <id>
phonebox recents <id>
phonebox swipe <id> X1 Y1 X2 Y2 [--ms 300]
phonebox scroll <id> <up|down|left|right> [--text T]
phonebox open <id> <PACKAGE|URL>
phonebox wait <id> --text T [--gone] [--timeout 20s]
phonebox act <id> <JSON|->
```

| Command                   | What it does                                                                                                                                                                                                                                                          |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tap`                     | Taps a point or an element. `--long` long-presses and `--double` double-taps. `--nth` goes only with `--text`, and the CLI refuses it with any other target as a usage error. [Targets](/docs/using-phones/actions#targets) explains how each form finds its element. |
| `type`                    | Types into the focused field. `--clear` empties it first, and `--submit` presses Enter afterwards.                                                                                                                                                                    |
| `key`                     | Presses a key by name, such as `enter`, `delete`, `tab` or `escape`, or by its Android key code.                                                                                                                                                                      |
| `back`, `home`, `recents` | Press Back, go to the home screen, or open the recent apps.                                                                                                                                                                                                           |
| `swipe`                   | Swipes between two points, in `--ms` milliseconds.                                                                                                                                                                                                                    |
| `scroll`                  | Scrolls the screen, or the element with the text `--text`.                                                                                                                                                                                                            |
| `open`                    | Opens an app by its package name, or a URL or deep link when the argument has a scheme such as `https:`.                                                                                                                                                              |
| `wait`                    | Waits until the text appears, or with `--gone` until it disappears. `--timeout` can be up to 30 seconds, and the default is 10. It exits with code 3 when time runs out.                                                                                              |
| `act`                     | Runs a batch of [actions](/docs/using-phones/actions), given as JSON or read from stdin with `-`. The JSON is either `{"actions": […], "observe": …}` or just the array of actions.                                                                                   |

Each of these commands but `act` sends one action with `"observe": "none"` and prints its result. `act` prints the whole batch result and exits with code 0 even when an action failed, so check `completed` and each result's `ok`.

```bash
echo '[{"type": "tap", "target": {"text": "Sign in"}}, {"type": "wait_for", "target": {"text": "Welcome"}}]' | phonebox act -
```

### Apps and files [#apps-and-files]

```text
phonebox apps <id> [install PACKAGE | install ./app.apk [--replace] | rm PACKAGE | installs]
phonebox uploads [ls | rm UPLOAD]
phonebox files <id> ls PATH | push LOCAL REMOTE | pull REMOTE LOCAL | rm PATH
```

| Command   | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps`    | Lists the installed apps. `apps install ./app.apk` installs your own APK (an argument ending in `.apk` is always a file, and one that isn't there is a usage error): it uploads the file, waits while Phonebox reads it, installs it and waits until the phone lists the app, printing each step on stderr. `--replace` uninstalls the app first, which deletes its data: use it for a build signed with another key, or an older `versionCode`. `apps install PACKAGE` starts an install from Phonebox's app library, which never reaches Google Play, `apps rm PACKAGE` uninstalls an app, and `apps installs` lists the recent installs, each `running`, `succeeded` or `failed`. |
| `uploads` | Lists the project's [uploads](/docs/api-reference/uploads) of your own APKs, or deletes one with `rm`. An upload is deleted 7 days after its last install.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `files`   | Lists a directory, uploads a local file (`push`), downloads a file (`pull`) or deletes one, under `/sdcard`. When the `push` target ends with `/`, the file keeps its name.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |

### The device and recordings [#the-device-and-recordings]

```text
phonebox device <id>
phonebox location <id> [LAT LNG [--keep-timezone] | reset]
phonebox reboot <id> [--no-wait]
phonebox reset <id> --yes [--no-wait]
phonebox record <id> start [--name NAME] | stop REC | ls | pull REC FILE.mp4 | rm REC
phonebox library [QUERY] [--cursor N]
```

| Command    | What it does                                                                                                                                                                                                                                                              |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `device`   | Shows the phone's brand, model, Android version, screen size and carrier, with its location, locale and timezone.                                                                                                                                                         |
| `location` | Shows the phone's location. With `LAT LNG`, sets it, and the timezone found there unless `--keep-timezone` is given. `location <id> reset` goes back to the default location and its timezone.                                                                            |
| `reboot`   | Restarts a stuck phone, keeping everything on it, and waits until it is ready, for up to 5 minutes. A phone can reboot once every 10 minutes.                                                                                                                             |
| `reset`    | Wipes the phone's apps, files, signed-in accounts and clipboard, keeping its ID, and waits until it is ready. It can't be undone: it needs `--yes` and an Admin key.                                                                                                      |
| `record`   | Records the phone's screen: `start` begins a recording and prints it with its `rec_` ID, `stop` ends one, `ls` lists the phone's recordings, `pull` saves a ready recording's video to an MP4 file, and `rm` deletes one. Recordings are deleted 7 days after they start. |
| `library`  | Searches Phonebox's app library, the store and system apps that `apps <id> install PACKAGE` installs. `--cursor` reads the next page, from `next_cursor`.                                                                                                                 |

## Output [#output]

* A command that succeeds prints one line of JSON on stdout: the phone, the action's result, the list, and so on.
* Each line on stderr is one JSON value. A `create` that is about to wait prints `{"created":"ph_…","status":"creating"}` there first, so stderr can hold that line and then an error. `apps install ./app.apk` prints `{"uploading":…}`, `{"processing":"upl_…"}`, `{"ready":"upl_…"}` and `{"installing":…}` there as it goes.
* `look` prints its snapshot line and the text form instead, unless you add `--json`.
* `setup` and `login` print their progress as plain sentences on stderr, and `token` prints the key alone.
* A command that fails prints `{"error": {…}}` on stderr, with the fields `status`, `code`, `message`, `retryable`, `next`, `request_id` and `details`. `status` is the HTTP status, and a usage error has the code `usage`.
* When the answer isn't Phonebox's own error, such as a gateway's `504`, or the API can't be reached or doesn't answer in time, the code is `internal_error`. After a command that changes the phone, such as `tap` or `act`, look before you run it again, because it may have run.

```text
{"error":{"status":422,"code":"ambiguous_target","message":"Several elements on the screen match the target.","retryable":false,"next":"Add nth to the target, or tap by ref from the returned observation.","request_id":null,"details":{"candidates":[{"ref":3,"type":"Button","text":"Add to cart","desc":null,"id":"com.example.shop:id/add_to_cart","center":[860,620]},{"ref":5,"type":"Button","text":"Add to cart","desc":null,"id":"com.example.shop:id/add_to_cart","center":[860,900]},{"ref":7,"type":"Button","text":"Add to cart","desc":null,"id":"com.example.shop:id/add_to_cart","center":[860,1180]}],"snapshot_id":"snp_4f2k7m3q6z3a"}}}
```

The CLI waits for phones the way the API asks: `create`, `start`, `park` and `status --wait` poll in requests of about 50 seconds each until the phone gets there. A wait ends early with the phone's own failure when the phone can't get there, for example when a start fails and the phone parks again.

## Creating phones safely [#creating-phones-safely]

A new phone starts billing as soon as it exists, which is before it is ready. When `create` is going to wait, it first prints the new phone's ID on stderr:

```text
{"created":"ph_7kx2m6q4v3ta","status":"creating"}
```

The line appears only for a phone on its way to ready, whose status is `creating` or `starting`. If the wait is cut short, for example because your shell stops the command, the phone still exists: keep that ID, and `phonebox status` with it and `--wait` waits until the phone is ready.

An error from a create tells you whether a phone was made:

* Phonebox's own error that names no phone in `details.phone`, such as `insufficient_credits`, `running_limit_reached` or a `capacity_unavailable` that names no phone, is a refusal when no `created` line came before it: it created nothing, and you can send the create again.
* `internal_error`, which the CLI also prints when no answer came from Phonebox, such as after a timeout, a dropped connection or a gateway's `504`, says nothing about the create, and neither does a stopped shell. The phone may exist, so run `phonebox ls` and use the newest phone with the name you gave it before you create another.
* A phone is gone only when its `status` is `failed` or `deleted`, so read the error's `retryable` and the phone's status, never `details.phone` alone. A create whose new phone failed answers `retryable: false`, and a repeat that finds the phone failed or deleted exits with that phone's error. Create the next phone with a new idempotency key only then.

`--idempotency-key` makes a create safe to repeat. Choose one key for each phone you want, for example the task's name and the time, and keep it for as long as you may need to repeat that create:

```bash
key="signup-test-$(date +%s)"
phonebox create --name signup-test --idempotency-key "$key" --no-wait
```

The key must be 8 to 128 characters, letters, digits, `_`, `.`, `:` and `-`, starting with a letter or digit. The CLI refuses any other key, and an empty one, such as an unset variable, with a usage error, before it sends anything. If the command fails without a phone, run it again with the same key and the same flags within 24 hours: you get the phone the first call created, as it is now, instead of a second phone. If that phone has failed, or you deleted it, a repeat only returns it again, and `create` exits with its error: create the next phone with a new key.

## Exit codes [#exit-codes]

| Code | Meaning                                                                                                                                                                                                                                            |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0    | The command succeeded.                                                                                                                                                                                                                             |
| 1    | The API returned an error, or an action failed.                                                                                                                                                                                                    |
| 2    | The command was used wrongly, for example with a malformed phone ID, an argument the command doesn't take or a malformed duration such as `--max 2hours`, or there is no key: run `phonebox setup`. Nothing was done on the phone.                 |
| 3    | A wait timed out: `wait` ran out of time, `setup` waited 15 minutes for you, or a phone didn't reach its status in time. After a `create` or `start` that timed out, the phone exists, so read it with `phonebox status` and never create another. |
| 130  | You interrupted the command with Ctrl-C.                                                                                                                                                                                                           |


---

# TypeScript SDK

Source: https://phonebox.dev/docs/interfaces/sdk

> Drive phones from Node.js with the phonebox package, its Phone methods, its errors, and how it retries and waits.



The `phonebox` npm package is a typed client for the [REST API](/docs/interfaces/rest). It runs on Node.js 20 or later, is published as an ES module, and depends only on `zod`. The same package contains the [CLI](/docs/interfaces/cli).

## Install [#install]

```bash
npm install https://phonebox.dev/downloads/phonebox-0.1.0.tgz
```

The package includes its type definitions. Import it with `import`, because it has no CommonJS build.

## Quick example [#quick-example]

```ts
import { Phonebox, PhoneboxError } from "phonebox";

const pb = new Phonebox(); // reads PHONEBOX_API_KEY
const key = `signup-test-${Date.now()}`; // a new key for each phone; reuse it only to repeat this create
const phone = await pb.phones.create({ name: "signup-test", idempotencyKey: key });
try {
  if (phone.status !== "ready") await phone.waitUntil("ready"); // create() returns the phone even when its wait was cut short
  console.log(await phone.look());
  await phone.tap({ text: "Sign in" });
  await phone.tap({ id: "email" });
  await phone.type("alex@example.com", { submit: true });
  await phone.waitFor({ text: "Welcome" }, { timeoutMs: 20_000 });
} catch (error) {
  if (error instanceof PhoneboxError) console.error(error.code, error.next);
  throw error;
} finally {
  await phone.park();
}
```

## The client [#the-client]

`new Phonebox(options)` takes these options, all of them optional:

| Option    | Meaning                                                                                |
| --------- | -------------------------------------------------------------------------------------- |
| `apiKey`  | The API key. The default is the `PHONEBOX_API_KEY` environment variable.               |
| `baseUrl` | The API origin. The default is `PHONEBOX_URL`, or `https://phonebox.dev`.              |
| `fetch`   | A `fetch` function to use instead of the global one.                                   |
| `sleep`   | How the SDK waits between polls that don't long-poll, such as an install's. For tests. |

Without a key, the constructor throws a `PhoneboxError` with the code `missing_api_key`.

| Method                                                                       | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pb.phones.create(input)`                                                    | Creates a phone and, unless `wait` is `false`, waits until it is ready. `input` takes `name`, `country`, `metadata`, `idleTimeout`, `maxDuration`, `wait`, `idempotencyKey` and `timeoutMs`, which defaults to 10 minutes. Returns a `Phone`. Once the phone exists, only an error that names the phone in `details.phone` throws: the phone's own failure, or someone else parking or deleting it while `create()` waits. A wait that ends any other way, such as a timeout or a dropped connection, still returns the phone as it was last seen, so check its `status`. |
| `pb.phones.list(query)`                                                      | Lists phones, newest first. `query` takes `status`, `limit`, `cursor` and `metadata`. Without `status`, it leaves out failed and deleted phones. Returns `{ data, nextCursor }`, where `data` holds `Phone` objects.                                                                                                                                                                                                                                                                                                                                                      |
| `pb.phones.get(id)`                                                          | Gets one phone as a `Phone`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `pb.account()`                                                               | Returns the account: balance, reserved credit, spending, limits and price.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `pb.uploads.create(file, { onProgress, timeoutMs })`                         | Uploads your own APK and waits until it is `ready`. `file` is a path (Node), a `Uint8Array` or a `Blob`; a path streams from disk. The file goes straight to the upload's one-off URL, without your API key. An upload that ends otherwise throws its `error`, such as `invalid_apk`. Returns the [upload](/docs/api-reference/uploads#the-upload-object).                                                                                                                                                                                                                |
| `pb.uploads.get(id, { wait })`, `pb.uploads.list()`, `pb.uploads.delete(id)` | Read, list or delete [uploads](/docs/api-reference/uploads).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `pb.library.search(query, { cursor })`                                       | Searches [Phonebox's app library](/docs/api-reference/apps#search-the-app-library): the store and system apps `apps.install(pkg)` installs.                                                                                                                                                                                                                                                                                                                                                                                                                               |

The SDK names options in camelCase, such as `idleTimeout` and `maxDuration`, and it sends them as the API's snake\_case fields. Timers are in seconds and `timeoutMs` values in milliseconds. Actions and targets keep the API's own field names, such as `timeout_ms` and `snapshot_id`. So do the objects the SDK returns, except that `pb.phones.list()` names its cursor `nextCursor`.

## A phone [#a-phone]

A `Phone` holds the phone's latest state in `phone.data`, with `phone.id` and `phone.status` as shortcuts. Lifecycle methods refresh `data`, and screen and action methods leave it as it is. Call `refresh()` to read the phone again.

### Lifecycle [#lifecycle]

| Method                         | What it does                                                                                                                                                                                                                                                                                                                   |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `refresh()`                    | Reads the phone again.                                                                                                                                                                                                                                                                                                         |
| `waitUntil(target, timeoutMs)` | Waits until the phone is `"ready"` or `"parked"`. The default timeout is 10 minutes. It throws at once when the phone can't get there without a new request, as described below. It only reads the phone, so wait this way for a phone that is still creating or starting, because `start()` renews a running phone's session. |
| `start(options)`               | Starts a parked phone, or renews a running one, and waits until it is ready. Options are `idleTimeout`, `maxDuration`, `wait` and `timeoutMs`, which defaults to 5 minutes.                                                                                                                                                    |
| `park(options)`                | Parks the phone and waits until it has stopped: up to about 50 seconds inside the park request, then up to 2 more minutes of polling. It waits only while the phone is `parking`, so a phone that the park leaves `parked` or `unavailable` returns at once. Pass `{ wait: false }` to return at once in any case.             |
| `delete()`                     | Deletes the phone permanently. It needs an Admin key.                                                                                                                                                                                                                                                                          |
| `update({ name, metadata })`   | Renames the phone or replaces its metadata.                                                                                                                                                                                                                                                                                    |
| `heartbeat()`                  | Resets the idle timer.                                                                                                                                                                                                                                                                                                         |
| `reboot()`                     | Reboots the phone, and returns without waiting. Follow it with `waitUntil("ready")`.                                                                                                                                                                                                                                           |
| `reset()`                      | Wipes the phone's apps, files, signed-in accounts and clipboard, keeping its ID, and returns without waiting: `{ phone, wiped }`. Follow it with `waitUntil("ready")`. It needs an Admin key.                                                                                                                                  |
| `sessions({ limit, cursor })`  | Lists the phone's sessions, with their cost.                                                                                                                                                                                                                                                                                   |

### The screen [#the-screen]

| Method                                      | What it does                                                                                                                             |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `observe({ screenshot, maxWidth })`         | Returns the [observation](/docs/using-phones/observe). `screenshot` is `"none"`, the default, `"jpeg"` or `"png"`.                       |
| `look()`                                    | Returns the screen in the text form, as a string that starts with a line naming its snapshot, `snapshot snp_…`, as MCP's `observe` does. |
| `screenshot({ format, maxWidth, quality })` | Returns the image's bytes as a `Uint8Array`. `format` is `"png"`, the default, or `"jpeg"`.                                              |

### Actions [#actions]

`act(actions, { observe })` runs a batch of [actions](/docs/using-phones/actions) and returns the batch result. It doesn't throw when an action fails, so check `completed` and each result's `ok`. It throws only when the request as a whole fails.

```ts
const result = await phone.act(
  [
    { type: "tap", target: { text: "Search apps" } },
    { type: "type", text: "chrome" },
  ],
  { observe: "ui" },
);
if (result.completed < 2) console.log(result.results.at(-1)?.error);
```

These methods each send one action, and they throw a `PhoneboxError` when it fails:

| Method                                             | Action                                                                           |
| -------------------------------------------------- | -------------------------------------------------------------------------------- |
| `tap(target, { long, double })`                    | `tap`, or `long_press` with `long`, or `double_tap` with `double`                |
| `type(text, { clear, submit })`                    | `type`                                                                           |
| `key(nameOrCode)`                                  | `key`                                                                            |
| `back()`, `home()`, `recents()`, `notifications()` | `back`, `home`, `recents`, `notifications`                                       |
| `swipe(from, to, durationMs)`                      | `swipe`                                                                          |
| `scroll(direction, { target, amount })`            | `scroll`                                                                         |
| `open(packageOrUrl)`                               | `open_url` when the argument has a scheme such as `https:`, otherwise `open_app` |
| `waitFor(target, { gone, timeoutMs })`             | `wait_for`                                                                       |

A target is an object in one of the [target forms](/docs/using-phones/actions#targets), such as `{ text: "Sign in" }` or `{ ref: 3, snapshot_id: "snp_4f2k7m3q6z3a" }`.

### Apps, files and the device [#apps-files-and-the-device]

| Method                                                                                                                    | What it does                                                                                                                                                                                          |
| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps.list()`, `apps.install(pkg)`, `apps.installs()`, `apps.uninstall(pkg)`                                              | Manage [apps](/docs/using-phones/apps). `install(pkg)` starts an install from Phonebox's app library and answers at once.                                                                             |
| `apps.install({ upload, replace })`                                                                                       | Installs an upload and waits until the phone lists the app, up to 10 minutes. A failure throws `install_failed` with the next step. `replace: true` uninstalls the app first, which deletes its data. |
| `installApk(file, { replace, onProgress })`                                                                               | Installs your own APK in one call: `pb.uploads.create(file)`, then `apps.install({ upload })`.                                                                                                        |
| `files.list(dir)`, `files.upload(path, bytes)`, `files.download(path)`, `files.remove(path)`                              | Manage [files](/docs/using-phones/files). `upload` takes a `Uint8Array`, and `download` returns one.                                                                                                  |
| `clipboard.get()`, `clipboard.set(text)`                                                                                  | Read or set the clipboard.                                                                                                                                                                            |
| `setLocation(lat, lng, { timezoneFromLocation })`, `resetLocation()`, `setLocale(locale)`, `setTimezone(timezone)`        | Change the [device settings](/docs/using-phones/device). `setLocation` also sets the timezone found there, unless `timezoneFromLocation` is `false`.                                                  |
| `getLocation()`, `getLocale()`, `getTimezone()`, `info()`                                                                 | Read the location, locale and timezone, and the phone's [device info](/docs/api-reference/device#get-device-info): brand, model, Android version, screen and carrier.                                 |
| `recordings.start({ name })`, `recordings.list()`, `recordings.stop(id)`, `recordings.video(id)`, `recordings.delete(id)` | [Record the screen](/docs/api-reference/recordings). `video(id)` returns the MP4's bytes once the recording is ready.                                                                                 |
| `live({ expiresIn })`                                                                                                     | Creates a [live view link](/docs/using-phones/live-view) and returns `{ url, expires_at }`.                                                                                                           |

## Errors [#errors]

An error that the API reports is a `PhoneboxError`, carrying the API's error envelope:

| Property    | Meaning                                                                                                                                   |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `status`    | The HTTP status, or 0 when no request was made.                                                                                           |
| `code`      | The error code, such as `phone_not_running`.                                                                                              |
| `message`   | A sentence for people.                                                                                                                    |
| `retryable` | Whether the same call may succeed later.                                                                                                  |
| `next`      | The next step, when there is one.                                                                                                         |
| `requestId` | The request's ID, from its `X-Request-Id` header. A single-action method's error carries the ID of the batch request that ran the action. |
| `details`   | Facts about the error, such as `candidates` for a target error.                                                                           |

When a single-action method such as `tap()` fails, the error carries the action's code with that code's usual HTTP status, so a `phone_unavailable` has the status 503 and a `target_not_found` 422.

An answer without the envelope, such as a gateway's `504`, becomes a `PhoneboxError` with the code `internal_error`, that `status` and `retryable` set to `false`. Only an answer that carries an `X-Request-Id` header is read as Phonebox's envelope, so a proxy's JSON error, whatever it says, is reported this way too. A network error or a timeout is thrown as the error that `fetch` gave, such as a `TypeError` or a `TimeoutError`. After either on a write, such as `act()` or `tap()`, the write may have happened: observe before you send it again.

## Retries and waiting [#retries-and-waiting]

* A GET that fails with a network error, a timeout, or the status 429, 502, 503 or 504 is sent up to twice more, after a quarter and then half a second.
* A POST, PUT, PATCH or DELETE is sent only once, so the SDK never repeats an action or a create. Pass `idempotencyKey` to `create()` so that you can repeat a create yourself safely.
* `create()`, `start()` and `park()` send their request, which itself waits up to about 50 seconds, and then poll `GET /v1/phones/{id}?wait=…` in requests of about 50 seconds each, until the phone gets there or `timeoutMs` passes after that first answer. When the time runs out, `start()`, `park()` and `waitUntil()` throw a `PhoneboxError` with the code `wait_timeout` and the status 422, which is retryable and names the phone in `details.phone`. `create()` returns the phone instead, as it was last seen.
* A wait stops at once when the phone lands where it can't get there without a new request. A phone that failed, or was deleted, makes it throw with the failure's code, `retryable` set to `false`, and a `next` that says to create a new phone with a new idempotency key. A start that fails and leaves the phone parked again throws with the failure's code too, but keeps the code's usual `retryable`, because the same phone can start again. A phone that someone else parked makes a wait for `ready` throw `phone_not_running`, whose `next` is the start request.
* A poll that answers at once without reaching the status waits before the next one: 1 second, doubling to 10.
* Each HTTP request gives up after 130 seconds, except a batch of actions, from `act()` or a single-action method, which waits up to 310 seconds: a little longer than the 300 seconds a request may run at the platform, so the batch's own answer arrives first.


---

# MCP

Source: https://phonebox.dev/docs/interfaces/mcp

> Connect Claude Code, Cursor or any MCP client to the hosted Phonebox server, and use its tools.



Phonebox runs a hosted MCP server at `https://phonebox.dev/mcp`. It speaks Streamable HTTP and authenticates with the same API keys as the REST API. Every tool call is a request to the [REST API](/docs/interfaces/rest) made with your key, so scopes, limits and billing are exactly the same, and the console's activity shows each call as coming from MCP.

## Connect a client [#connect-a-client]

Every request carries your key as a bearer token:

```http
Authorization: Bearer pbx_…
```

The server doesn't use OAuth, so your client has to be able to send this header.

### Claude Code [#claude-code]

```bash
claude mcp add --transport http phonebox https://phonebox.dev/mcp --header "Authorization: Bearer pbx_…"
```

To share the server with a project without sharing the key, commit a `.mcp.json` at the project's root that reads the key from each person's environment:

```json
{
  "mcpServers": {
    "phonebox": {
      "type": "http",
      "url": "https://phonebox.dev/mcp",
      "headers": { "Authorization": "Bearer ${PHONEBOX_API_KEY}" }
    }
  }
}
```

### Cursor [#cursor]

Add the server to `~/.cursor/mcp.json` to use it in every project, or to `.cursor/mcp.json` in one project:

```json
{
  "mcpServers": {
    "phonebox": {
      "url": "https://phonebox.dev/mcp",
      "headers": { "Authorization": "Bearer pbx_…" }
    }
  }
}
```

Keep a file that holds a key out of version control.

### Other clients [#other-clients]

Point any client that supports Streamable HTTP at `https://phonebox.dev/mcp`, and have it send the `Authorization` header. The server is stateless: it accepts only POST requests, holds no session between them, and takes one JSON-RPC message per request, so it refuses JSON-RPC batches.

## Tools [#tools]

| Tool                  | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Arguments                                                                                          |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `list_phones`         | Lists the project's phones, newest first, with their status and when each parks: up to 50 a page, and `next_cursor` for the next page. Without `status`, it leaves out failed and deleted phones.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `status` and `cursor`, both optional.                                                              |
| `get_phone`           | Reads one phone: its status, session and when it parks. With `wait`, waits until it is ready or parked, only reading it, so it is the way to wait for a phone that is still creating or starting.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `phone_id`, and optionally `wait`, `"ready"` or `"parked"`.                                        |
| `create_phone`        | Creates a phone and waits until it is ready.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | All optional: `name`, `country`, `metadata`, `idle_timeout`, `max_duration` and `idempotency_key`. |
| `start_phone`         | Starts a parked phone, or renews a running one, and waits until it is ready.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `phone_id`, and optionally `idle_timeout` and `max_duration`.                                      |
| `park_phone`          | Parks a phone to stop billing, and waits until it has stopped.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `phone_id`.                                                                                        |
| `observe`             | Reads the screen, and adds a screenshot 720 pixels wide as an image.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | `phone_id`, and `screenshot`, which is `true` by default.                                          |
| `act`                 | Runs up to 20 [actions](/docs/using-phones/actions) in order, stopping at the first failure.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `phone_id`, `actions`, and `observe`, which is `ui` by default.                                    |
| `list_apps`           | Lists the apps installed on the phone: package, label, version, and whether it came with the phone.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `phone_id`.                                                                                        |
| `reboot_phone`        | Restarts a stuck phone, keeping everything on it, and waits until it is ready again. A phone can reboot once every 10 minutes. A reboot the phone service didn't confirm is never sent twice: wait with `get_phone` instead.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `phone_id`.                                                                                        |
| `install_app`         | With `package`, installs an app from Phonebox's app library in the background. It doesn't reach Google Play: a Play Store app installs through the Play Store app on the phone. With `upload`, installs your own APK and waits until the phone lists it, or fails with `install_failed` and what to do. While that upload is still installing on the phone, and for 10 minutes after that install succeeds, calling it again with the same arguments starts nothing: it waits for that install, or reports it. When the phone doesn't confirm an install's start, it follows Phonebox's record of the install to its end. While two installs run, or while the same app is still installing, another answers `install_in_progress`. | `phone_id`, and `package` or `upload`; with `upload`, optionally `replace`.                        |
| `create_app_upload`   | Starts an upload of your own APK. MCP can't carry a file, so it returns a one-off `upload_url` and the exact `curl` command that sends the APK there from your shell.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | None.                                                                                              |
| `complete_app_upload` | Finishes an upload once its file has arrived, and waits while Phonebox reads and stores the APK. Calling it again with the same arguments only waits again.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `upload_id`, and `storage_id`: the `storageId` that `upload_url` answered.                         |
| `live_view_url`       | Creates a [live view link](/docs/using-phones/live-view) for a person to watch and control the phone. Anyone with the link controls the phone until it expires, so share it only with your user, and never paste it into logs or shared chats.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `phone_id`, and `expires_in` in seconds, which is 3600 by default.                                 |

`get_phone` reads one phone. With `wait` set to `"ready"` or `"parked"`, it waits for the phone the way `GET /v1/phones/{id}?wait=…` does, without starting or renewing it, so it is the way to wait for a phone that is still creating or starting.

There is no tool that deletes or resets phones, because both wipe the phone. Delete or reset phones through the API or the CLI with an Admin key.

### Install your own build [#install-your-own-build]

A coding agent with a shell installs the APK it just built in four steps:

1. Call `create_app_upload`. Its result has the upload's `id`, its `upload_url`, and the `curl` command to run.
2. Run that command with your APK's path, such as `app/build/outputs/apk/debug/app-debug.apk`. It answers `{"storageId": "…"}`. Over the REST API, that answer goes to `POST /v1/apps/uploads/{id}/complete` as it is.
3. Call `complete_app_upload` with the upload's ID and the `storageId`. It answers once the upload is `ready`, or with the reason it can't be installed, such as a file that isn't a signed APK.
4. Call `install_app` with the phone's ID and `upload`. It answers once the phone lists the app. If the result is still `running`, with a `note` saying so, call `install_app` again with the same arguments: while that install runs, and for 10 minutes after it succeeds, a repeat only waits for it or reports it, and never starts a second one (or uninstalls the app again for `replace`). After an install fails, calling it again installs again, as its `next` step says. If the phone already has a build of the app signed with another key, or a newer one, install with `replace: true`, which uninstalls the app first and deletes its data.

[Uploads](/docs/api-reference/uploads) describes the upload's limits and statuses.

The tools carry the usual MCP hints for clients that decide what to confirm with you. `list_phones`, `list_apps` and `observe` only read. `act` is marked destructive, because an action can do anything a person can do on a phone, such as clearing an app's data. `park_phone` is safe to repeat.

Unknown arguments are refused, so a misspelled argument fails instead of being ignored.

## Results [#results]

A successful call returns `{"data": …, "request_id": "req_…"}`, both as JSON text and as structured content. `data` is what the REST API returned.

`observe` returns the screen in the form a model reads best. The text starts with the snapshot ID, continues with the [text form](/docs/using-phones/observe#the-text-form), and ends with the screenshot's size and scale. The screenshot itself follows as an image:

```text
snapshot snp_4f2k7m3q6z3a
app com.android.launcher3
keyboard hidden
screen 1080x2400
[1] EditText "Search apps" #search clickable editable (540,250)
[2] TextView "Chrome" #icon clickable (200,1900)
[3] TextView "Settings" #icon clickable (500,1900)
[4] TextView "Play Store" #icon clickable (800,1900)
screenshot 720x1600, scale 0.666667 (device pixels = image pixels / scale)
```

Coordinates in the text are device pixels. To tap a point you found on the image, divide its coordinates by `scale`. `act` returns the batch result as JSON, followed by the screen after the batch in the same form, unless the screen couldn't be read, or not in time. Without the screen, call `observe` before you act again. When the call has a `progressToken`, `act` sends a progress notification for each action as the batch runs, which also keeps the connection active during a long batch.

A tool call makes one REST request, or several when a waiting tool keeps waiting. Its `request_id` names the request whose data it returns, which is the last one, while the HTTP response's `X-Request-Id` names the first. The console's activity has a row for each of them.

## Waiting tools [#waiting-tools]

`create_phone`, `start_phone`, `park_phone`, and `get_phone` with `wait`, wait for the phone; `complete_app_upload` waits for its upload, and `install_app` with `upload` for its install. How long they wait depends on your client:

* A call without a `progressToken` returns within about 50 seconds, the length of one REST wait; `complete_app_upload` and `install_app` within about 45. An upload still processing is waited on again by repeating `complete_app_upload`, and an install still running by repeating `install_app`: both repeats only wait. If the phone is still creating or starting then, the result is the phone as it is, and calling `get_phone` with its ID and `wait` set to `"ready"` waits again, only reading the phone. Calling `start_phone` again would renew a running phone's session, which reserves credit again. A park that hasn't finished can be waited on the same way with `park_phone`.
* A call with a `progressToken` keeps waiting for up to about 100 seconds. The tool sends a progress notification before each wait, which also keeps the connection active.

The limit exists because a response that sends nothing for 60 seconds is closed by many proxies on the way.

Once `create_phone` has created a phone, it always returns that phone. If a later wait fails, for example on a rate limit, the result is the phone as last seen, and only the phone's own failure is reported as an error. If the call itself fails or times out before you see a result, repeat it with the same `idempotency_key` and arguments to get the same phone instead of a second one. A repeat returns the phone as it is now, even one that has failed since, with its `failure`: then create the next phone with a new `idempotency_key`. Without a key, call `list_phones` and use the newest phone with the name you gave it before you create another.

## Errors [#errors]

A refused call is a tool result with `isError` set to `true`. Its text, and its structured content, is the REST API's [error envelope](/docs/interfaces/rest#errors), with the code, the message, whether it is retryable, the next step and the request ID. An `internal_error` from a tool that changes the phone isn't retryable, because the change may have happened: read the phone before you call it again. MCP has no headers, so a `Retry-After` wait appears as `details.retry_after_seconds`. Every message you send counts toward your key's limit of 600 requests a minute, `initialize` and `tools/list` included, and one over the limit is refused with `429 rate_limited` and a `Retry-After` wait. A failed phone's own failure carries no wait, because it isn't retryable: create a new phone, with a new `idempotency_key` if you use one.

A call that ends without a tool result, for example because the connection dropped or timed out, may still have run. After `act` or another tool that changes the phone, call `observe` before you call it again.

Arguments that don't match a tool's schema are refused before any request is made, in the MCP library's own error shape. The text names the tool and each problem:

```json
{
  "content": [
    {
      "type": "text",
      "text": "Input validation error: Invalid arguments for tool observe: phone_id: Invalid string: must match pattern /^ph_[a-z2-7]{12}$/"
    }
  ],
  "isError": true
}
```

The server checks the form of your key on every request, and it answers a missing or malformed key with `401` and a `WWW-Authenticate` header. It checks whether the key exists, has expired or was revoked when a tool runs, so a key that no longer works shows up as a tool error on the first tool call, while the handshake and the tool list still succeed. A request that carries an `Origin` header is refused, as on the REST API.


---

# Agent skill

Source: https://phonebox.dev/docs/interfaces/skill

> The Phonebox skill file teaches a coding agent the whole workflow. Install it in Claude Code, or point any agent at it.



The Phonebox agent skill is one Markdown file, published at [https://phonebox.dev/skills/phonebox/SKILL.md](/skills/phonebox/SKILL.md). It is written for agents. It teaches an agent to find out what its user wants, onboard them, and use Phonebox through MCP tools, the CLI or HTTP, with the HTTP request behind every command.

## What it contains [#what-it-contains]

* What Phonebox is, and what it fits: apps without an API, services that work only in a mobile app, testing an Android build, and a phone per customer. None is a default, and it also says when not to use Phonebox, for example when an API or a website would do.
* Start with what your user wants: act on a phone now, build an integration the user asked for, or onboard and ask one question.
* Onboarding your user: check what exists (a key, MCP tools, `phonebox account`), then walk the user through only the missing steps: sign-up, a key and credit.
* How to drive a phone: MCP tools when connected, the CLI in a shell (installed when needed), the SDK only for integration code, and HTTP otherwise; `PHONEBOX_API_KEY` and `PHONEBOX_PHONE`.
* The loop: create a phone, look at the screen, tap and type, look again, and park, as one block of commands.
* Reading the screen: the text form, refs and snapshots, and when to take a screenshot instead.
* Targets: coordinates, refs, text, resource IDs and descriptions, the order in which text matches, and what an ambiguous target means.
* Testing an Android build, one use case among several: build the APK, install it with `phonebox apps "$PHONEBOX_PHONE" install ./app.apk` (with `--replace` for another signing key or an older version), then look and walk the screens, with a link to [the guide](/docs/guides/test-android-builds).
* Parking and cost: the price, the timers, reservations, and the rule to always park when done.
* Rules: one agent per phone, never repeating an action whose outcome is unknown, never deleting phones, and keeping keys out of prompts.
* When something fails: each common error code, what it means, and what to do next.
* Several customers or tasks: names, metadata, one phone per end user, and keys limited to certain phones.
* HTTP equivalents: the REST request behind every CLI command.

## Install it in Claude Code [#install-it-in-claude-code]

To make the skill available in every Claude Code session, install it as a personal skill:

```bash
mkdir -p ~/.claude/skills/phonebox
curl -fsSL https://phonebox.dev/skills/phonebox/SKILL.md -o ~/.claude/skills/phonebox/SKILL.md
```

To give it to everyone who works on one project, save it in the project and commit it:

```bash
mkdir -p .claude/skills/phonebox
curl -fsSL https://phonebox.dev/skills/phonebox/SKILL.md -o .claude/skills/phonebox/SKILL.md
```

Claude Code can then use the skill whenever a task calls for an Android phone. Download it again when you update the CLI, so that the two match.

## Other agents [#other-agents]

Any agent that can read a URL can use the skill. The simplest way is the setup prompt below, which tells the agent to read the skill first. For an agent you use often, add a line to the instructions file it reads, such as `AGENTS.md`, telling it to read `https://phonebox.dev/skills/phonebox/SKILL.md` before it uses Phonebox.

Agents that prefer tools to terminal commands can use the [MCP server](/docs/interfaces/mcp) instead.

## The setup prompt [#the-setup-prompt]

The console's Home page has this prompt with a Copy button, for an account that already exists:

```text
Read https://phonebox.dev/setup.md and connect this machine to my existing Phonebox account. Use my available credit without requiring a purchase. Then read https://phonebox.dev/skills/phonebox/SKILL.md, find out what I want to use phones for, and help me with that. Park the phone when done.
```

Paste it into your coding agent. [Set up with your coding agent](/docs/getting-started/coding-agent) walks through what happens next.


---

# Conventions

Source: https://phonebox.dev/docs/api-reference

> The conventions every API request follows, from authentication and idempotency to pagination, rate limits and money, and the list of endpoints.



The API lives at `https://phonebox.dev/v1`. This section documents every endpoint exactly: its method and path, the scope it needs, its parameters with their types, defaults and ranges, its responses and the errors it can return. The OpenAPI 3.1 document at [/openapi.json](/openapi.json) describes the same endpoints for tools and code generators. For a guided tour, read [REST API](/docs/interfaces/rest) and [Using phones](/docs/using-phones/lifecycle) first.

## Endpoints [#endpoints]

| Operation                                                                      | Method and path                              | Scope            |
| ------------------------------------------------------------------------------ | -------------------------------------------- | ---------------- |
| [Create a phone](/docs/api-reference/phones#create-a-phone)                    | `POST /v1/phones`                            | `phones:create`  |
| [List phones](/docs/api-reference/phones#list-phones)                          | `GET /v1/phones`                             | `phones:read`    |
| [Get a phone](/docs/api-reference/phones#get-a-phone)                          | `GET /v1/phones/{id}`                        | `phones:read`    |
| [Update a phone](/docs/api-reference/phones#update-a-phone)                    | `PATCH /v1/phones/{id}`                      | `phones:control` |
| [Delete a phone](/docs/api-reference/phones#delete-a-phone)                    | `DELETE /v1/phones/{id}`                     | `phones:delete`  |
| [Start a phone](/docs/api-reference/phones#start-a-phone)                      | `POST /v1/phones/{id}/start`                 | `phones:control` |
| [Park a phone](/docs/api-reference/phones#park-a-phone)                        | `POST /v1/phones/{id}/park`                  | `phones:control` |
| [Keep a phone awake](/docs/api-reference/phones#keep-a-phone-awake)            | `POST /v1/phones/{id}/heartbeat`             | `phones:control` |
| [List a phone's sessions](/docs/api-reference/sessions#list-a-phones-sessions) | `GET /v1/phones/{id}/sessions`               | `phones:read`    |
| [Observe the screen](/docs/api-reference/observe#observe-the-screen)           | `GET /v1/phones/{id}/observe`                | `phones:read`    |
| [Take a screenshot](/docs/api-reference/observe#take-a-screenshot)             | `GET /v1/phones/{id}/screenshot`             | `phones:read`    |
| [Run actions](/docs/api-reference/actions#run-actions)                         | `POST /v1/phones/{id}/actions`               | `phones:control` |
| [Search the app library](/docs/api-reference/apps#search-the-app-library)      | `GET /v1/apps/library`                       | `phones:read`    |
| [List apps](/docs/api-reference/apps#list-apps)                                | `GET /v1/phones/{id}/apps`                   | `phones:read`    |
| [Install an app](/docs/api-reference/apps#install-an-app)                      | `POST /v1/phones/{id}/apps`                  | `phones:control` |
| [List app installs](/docs/api-reference/apps#list-app-installs)                | `GET /v1/phones/{id}/apps/installs`          | `phones:read`    |
| [Uninstall an app](/docs/api-reference/apps#uninstall-an-app)                  | `DELETE /v1/phones/{id}/apps/{package}`      | `phones:control` |
| [Create an upload](/docs/api-reference/uploads#create-an-upload)               | `POST /v1/apps/uploads`                      | `phones:control` |
| [List uploads](/docs/api-reference/uploads#list-uploads)                       | `GET /v1/apps/uploads`                       | `phones:read`    |
| [Get an upload](/docs/api-reference/uploads#get-an-upload)                     | `GET /v1/apps/uploads/{id}`                  | `phones:read`    |
| [Complete an upload](/docs/api-reference/uploads#complete-an-upload)           | `POST /v1/apps/uploads/{id}/complete`        | `phones:control` |
| [Delete an upload](/docs/api-reference/uploads#delete-an-upload)               | `DELETE /v1/apps/uploads/{id}`               | `phones:control` |
| [List files](/docs/api-reference/files#list-files)                             | `GET /v1/phones/{id}/files`                  | `phones:read`    |
| [Upload a file](/docs/api-reference/files#upload-a-file)                       | `PUT /v1/phones/{id}/files`                  | `phones:control` |
| [Download a file](/docs/api-reference/files#download-a-file)                   | `GET /v1/phones/{id}/files/content`          | `phones:read`    |
| [Delete a file](/docs/api-reference/files#delete-a-file)                       | `DELETE /v1/phones/{id}/files`               | `phones:control` |
| [Get device info](/docs/api-reference/device#get-device-info)                  | `GET /v1/phones/{id}/device`                 | `phones:read`    |
| [Read the clipboard](/docs/api-reference/device#read-the-clipboard)            | `GET /v1/phones/{id}/clipboard`              | `phones:control` |
| [Set the clipboard](/docs/api-reference/device#set-the-clipboard)              | `PUT /v1/phones/{id}/clipboard`              | `phones:control` |
| [Read the location](/docs/api-reference/device#read-the-location)              | `GET /v1/phones/{id}/location`               | `phones:read`    |
| [Set the location](/docs/api-reference/device#set-the-location)                | `PUT /v1/phones/{id}/location`               | `phones:control` |
| [Reset the location](/docs/api-reference/device#reset-the-location)            | `DELETE /v1/phones/{id}/location`            | `phones:control` |
| [Read the locale](/docs/api-reference/device#read-the-locale)                  | `GET /v1/phones/{id}/locale`                 | `phones:read`    |
| [Set the locale](/docs/api-reference/device#set-the-locale)                    | `PUT /v1/phones/{id}/locale`                 | `phones:control` |
| [Read the timezone](/docs/api-reference/device#read-the-timezone)              | `GET /v1/phones/{id}/timezone`               | `phones:read`    |
| [Set the timezone](/docs/api-reference/device#set-the-timezone)                | `PUT /v1/phones/{id}/timezone`               | `phones:control` |
| [Reboot](/docs/api-reference/device#reboot)                                    | `POST /v1/phones/{id}/reboot`                | `phones:control` |
| [Reset](/docs/api-reference/device#reset)                                      | `POST /v1/phones/{id}/reset`                 | `phones:delete`  |
| [Start a recording](/docs/api-reference/recordings#start-a-recording)          | `POST /v1/phones/{id}/recordings`            | `phones:control` |
| [List recordings](/docs/api-reference/recordings#list-recordings)              | `GET /v1/phones/{id}/recordings`             | `phones:read`    |
| [Stop a recording](/docs/api-reference/recordings#stop-a-recording)            | `POST /v1/phones/{id}/recordings/{rid}/stop` | `phones:control` |
| [Download a recording](/docs/api-reference/recordings#download-a-recording)    | `GET /v1/phones/{id}/recordings/{rid}/video` | `phones:read`    |
| [Delete a recording](/docs/api-reference/recordings#delete-a-recording)        | `DELETE /v1/phones/{id}/recordings/{rid}`    | `phones:control` |
| [Create a live view link](/docs/api-reference/live#create-a-live-view-link)    | `POST /v1/phones/{id}/live`                  | `phones:control` |
| [Get the account](/docs/api-reference/account#get-the-account)                 | `GET /v1/account`                            | `phones:read`    |
| [Create a checkout link](/docs/api-reference/account#create-a-checkout-link)   | `POST /v1/credits/checkout`                  | `phones:control` |
| [Start terminal setup](/docs/api-reference/setup#start-terminal-setup)         | `POST /v1/setup`                             | No key           |
| [Poll terminal setup](/docs/api-reference/setup#poll-terminal-setup)           | `POST /v1/setup/token`                       | No key           |

`{id}` is a phone ID such as `ph_7kx2m6q4v3ta`, and `{package}` is an Android package name such as `org.wikipedia`.

## Authentication [#authentication]

Send a project API key as a bearer token with every request, except the two [terminal setup](/docs/api-reference/setup) requests, which are how a terminal gets its key:

```http
Authorization: Bearer pbx_…
```

A key is `pbx_` followed by 43 characters, and it selects its project, so you never send a project ID. Each endpoint needs one scope. Agent keys have `phones:read`, `phones:control` and `phones:create`, Admin keys add `phones:delete`, and Read-only keys have only `phones:read`. A key limited to certain phones sees and uses only those phones, and it never has `phones:create`. [Authentication and keys](/docs/getting-started/authentication) covers presets, limits and expiry.

Call the API from a server or an agent, never from a web page. Phonebox refuses any request that carries an `Origin` header with `403 browser_requests_not_allowed`.

## Content types [#content-types]

* Send a JSON body with `Content-Type: application/json`. A body without that header, or one that isn't valid JSON, fails with `400 validation_failed`.
* A JSON body may be up to 100 KB, and an action batch up to 200 KB. A larger body fails with `413 payload_too_large`.
* A file upload is the exception: its body is the file's raw bytes, up to 4 MB.
* Bodies are checked strictly. A field the endpoint doesn't define fails with `400 validation_failed` instead of being ignored.
* Create, update, start, park and live links take an optional body. Leave it out to take every default.
* Give each query parameter at most once. A repeated one fails with `400 validation_failed`.

Responses are JSON, with three exceptions: the text form of observe is `text/plain`, a screenshot is `image/png` or `image/jpeg`, and a file download is `application/octet-stream`.

## Request IDs [#request-ids]

Every response, errors included, carries an `X-Request-Id` header, such as `req_k4m2n7p3q5r6`, and `Cache-Control: no-store`. An error repeats the ID in its `request_id` field. The console's Activity page lists your calls by the same IDs, so quote the ID when you contact support.

An MCP tool call's response carries the ID of the first REST request the tool made, so that ID finds the call in Activity too.

## Idempotency [#idempotency]

Send an `Idempotency-Key` header with every create. Without one, a create whose reply was lost can't be told apart from a create that failed, and sending it again can create, and bill, a second phone.

* A key holds 8 to 128 characters: letters, digits, `_`, `.`, `:` and `-`, starting with a letter or digit. Generate a new one for each phone you mean to create, and store it before you send the request.
* A repeat with the same key and the same body within 24 hours returns the phone that the first request created, as it is now. It creates nothing, reserves nothing and doesn't count toward the lifecycle rate limit. The order of keys in the body doesn't matter.
* If that phone has failed, or was deleted, a repeat only returns it again. Create the next phone with a new key.
* A repeat with the same key and a different body fails with `409 idempotency_conflict`. `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. The conflict names the phone the key created in `details.phone`: send the original body with that key to get it, never a new key, which would create a second phone.
* 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 without a key. Start isn't: on a running phone it renews the session and reserves credit again, so read the phone before you send a start again, as the `next` of a write's `internal_error` says.
* An action batch is never replayed, so a repeated batch runs again. [Run actions](/docs/api-reference/actions#run-actions) says when a repeat is safe.

In the SDK, the option is `idempotencyKey`, and the MCP tool `create_phone` takes `idempotency_key`. Once `create_phone` has created a phone, it returns that phone even when a later wait fails.

## Pagination [#pagination]

`GET /v1/phones` and `GET /v1/phones/{id}/sessions` return pages of the shape `{"data": […], "next_cursor": …}`, newest first.

| Parameter | Type    | Default | Rules                                                        |
| --------- | ------- | ------- | ------------------------------------------------------------ |
| `limit`   | integer | 50      | From 1 to 100 items.                                         |
| `cursor`  | string  | None    | The `next_cursor` of the previous page, sent back unchanged. |

`next_cursor` is `null` on the last page. Treat cursors as opaque. An invalid or expired cursor fails with `400 validation_failed`.

A key limited to certain phones gets all of its matching phones from `GET /v1/phones` on one page: `limit` and `cursor` have no effect there, and `next_cursor` is always `null`.

## Waiting [#waiting]

Create, start and park wait for the phone by default, for up to about 50 seconds. `GET /v1/phones/{id}?wait=ready` or `?wait=parked` waits on request, for its `timeout`: from 1 to 55 seconds, 50 by default.

A waiting request sends nothing until it answers, and many proxies and load balancers close a connection that stays silent for 60 seconds. That is why no wait is longer. After a `202 Accepted` from create or start, poll `GET /v1/phones/{id}?wait=ready` while the phone is `creating` or `starting`. After one from park, poll `?wait=parked` while it is `parking`. Any other status ends the loop: go on when the phone has the status you waited for, and otherwise read its `status` and `failure` and act on them, as [Get a phone](/docs/api-reference/phones#get-a-phone) describes. A `wait=ready` poll answers at once for a phone in any other status, so polling again would only spin.

The MCP server's waiting tools are never silent for longer either. Without a `progressToken` they answer within about 50 seconds, and with one they keep waiting for up to about 100 seconds, sending progress notifications as they go. [MCP](/docs/interfaces/mcp#waiting-tools) explains both.

Give your HTTP client a timeout above 60 seconds, so that it outlasts every wait. Give action batches about 300 seconds; the SDK uses 310. A batch's waits alone may add up to 60 seconds, its actions take time of their own, and it stops starting new actions only after 90 seconds, but an action that has started runs to its end, so a request that runs a batch may take up to 300 seconds at the platform.

## Rate limits [#rate-limits]

| Limit                          | Applies to                                                                                                                             |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| 600 requests a minute          | Each API key, across all its requests. Phonebox counts it on each of its servers, so treat it as approximate.                          |
| 60 lifecycle requests a minute | Each project: creates, starts and renewals, parks and deletes, repeats included. A replayed create doesn't count. This limit is exact. |
| 1 reboot every 10 minutes      | Each phone.                                                                                                                            |
| 10 checkout links an hour      | Each project. This limit is exact.                                                                                                     |
| 10 setup requests a minute     | Each IP address. Approximate, like the key limit.                                                                                      |
| 60 setup polls a minute        | Each setup request. Approximate, like the key limit.                                                                                   |
| 1 reset a minute               | Each phone. A reset also has to wait until the phone is ready again.                                                                   |
| 20 recordings                  | Each project. Each is deleted 7 days after it started.                                                                                 |

A request over a limit fails with `429 rate_limited` and a `Retry-After` header in seconds. The header holds the limit's own wait when it has one, such as the time left before a phone can reboot again, which `details.retry_after_seconds` repeats. Otherwise it is 30 seconds.

## Timestamps, durations and money [#timestamps-durations-and-money]

* Timestamps are ISO 8601 strings in UTC with milliseconds, such as `2026-09-29T10:00:00.412Z`.
* Durations are whole seconds, such as `idle_timeout` and `max_duration`. Inside actions, `ms`, `duration_ms` and `timeout_ms` are milliseconds.
* Money is a string of US dollars with exactly six decimal places, one for each millionth of a dollar, such as `"0.060000"` for the price of a minute. Parse it as a decimal, never as a floating-point number.

## Retries [#retries]

* Phonebox retries its own reads of a phone for up to about 15 seconds, which rides out the short restarts a phone can have right after it becomes ready. You may repeat any read.
* Phonebox never retries a write, because a write might have taken effect. When a write couldn't reach the phone at all, it fails with Phonebox's retryable `503 phone_unavailable` error, which means nothing was done.
* When an error's `retryable` is `true`, the same request may succeed later. Wait for `Retry-After` when the response has one, or a few seconds when it doesn't. For a write, `retryable` doesn't mean that the first attempt did nothing. Send a write again only when its error says that nothing was done, as `phone_unavailable` does, and observe the phone first otherwise. An action batch refused with any error but `500 internal_error` ran nothing, as [When nothing ran](/docs/api-reference/actions#when-nothing-ran) explains.
* These rules hold only for an answer in Phonebox's [error envelope](/docs/api-reference/errors). A `502` or `504` without it, a timeout or a dropped connection leaves a write's outcome unknown, as [Failures without the envelope](/docs/api-reference/errors#failures-without-the-envelope) explains.
* `action_outcome_unknown` is never retryable: [observe first](/docs/troubleshooting/unknown-outcomes), then decide.

## Common errors [#common-errors]

Every error has the same envelope, described with every code in [Errors](/docs/api-reference/errors). Each endpoint on the following pages lists its own errors. Beyond those, any request can fail with these:

| Error                                                       | When                                                                                                                                                                                                             |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 invalid_api_key`, `401 key_expired`, `401 key_revoked` | The key is missing, malformed or unknown, has expired, or was revoked.                                                                                                                                           |
| `403 browser_requests_not_allowed`                          | The request carries an `Origin` header.                                                                                                                                                                          |
| `403 insufficient_scope`                                    | The key lacks the endpoint's scope. `details.required` names it.                                                                                                                                                 |
| `400 validation_failed`                                     | A parameter, the body or the `Idempotency-Key` breaks a rule. `details.issues` lists each problem as `{path, message}`.                                                                                          |
| `404 not_found`                                             | No endpoint exists at this path.                                                                                                                                                                                 |
| `405 method_not_allowed`                                    | The path exists, but not with this method. The `Allow` header lists its methods.                                                                                                                                 |
| `429 rate_limited`                                          | The key made 600 requests this minute.                                                                                                                                                                           |
| `500 internal_error`                                        | Something went wrong on our side. Keep the request ID. On a GET the error is retryable. On any other request it isn't, because the write may have taken effect: read the phone before you retry, as `next` says. |
| `503 platform_not_configured`                               | Phonebox itself is missing a setting. Try again later, and contact support if it lasts.                                                                                                                          |

A request for one phone, under `/v1/phones/{id}`, can also fail with `403 phone_not_allowed` when the key is limited to other phones, and `404 phone_not_found` when no phone with that ID exists in the project. A phone in another project answers `404 phone_not_found` too.

Requests that reach into the phone need it to be `ready`: observing, screenshots, actions, apps, files, device settings, reboots and live links. They can fail with these as well:

| Error                                   | When                                                                                                                                                                                                                     |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `409 phone_starting`                    | The phone is creating or starting. This error is retryable: wait with `GET /v1/phones/{id}?wait=ready`, as `next` says.                                                                                                  |
| `409 phone_parking`                     | The phone is parking. This error is retryable: wait with `?wait=parked`, then start it.                                                                                                                                  |
| `409 phone_not_running`                 | The phone is parked or unavailable, as `details.status` says. `next` names the start request.                                                                                                                            |
| `409 phone_failed`, `409 phone_deleted` | The phone can't be used again. Create a new one, with a new idempotency key if you use one.                                                                                                                              |
| `502 provider_error`                    | The phone service returned an error. A read was already retried for about 15 seconds. This error is retryable, but after a write, observe the phone before you send it again.                                            |
| `503 phone_unavailable`                 | The phone was briefly out of reach, and nothing was done on it. This error is retryable, and `Retry-After` says when to try again, when it is known.                                                                     |
| `429 rate_limited`                      | The phone service asked Phonebox to slow down. `Retry-After` and `details.retry_after_seconds` say how long to wait.                                                                                                     |
| `503 capacity_unavailable`              | The phone service has no capacity right now. Try again after `Retry-After`. When it names a phone in `details.phone` that has failed, it isn't retryable: create a new phone, with a new idempotency key if you use one. |
| `502 action_outcome_unknown`            | A write wasn't confirmed, so it may or may not have happened. This error is never retryable: observe before you do anything else.                                                                                        |


---

# Phones

Source: https://phonebox.dev/docs/api-reference/phones

> Create, list, get, update, delete, start, park and keep phones awake, and the phone object these endpoints return.



These endpoints manage phones and their sessions. [Lifecycle and timers](/docs/using-phones/lifecycle) explains the model behind them, and the [conventions](/docs/api-reference) and [common errors](/docs/api-reference#common-errors) apply to every one of them.

## The phone object [#the-phone-object]

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "ready",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:03:18.905Z",
  "session": {
    "id": "ses_q2w6e4r5t3y7",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": "2026-09-29T10:01:04.771Z",
    "idle_timeout": 300,
    "max_duration": 1800,
    "parks_at": "2026-09-29T10:08:18.905Z",
    "park_reason": "idle",
    "reserved_usd": "1.800000"
  },
  "failure": null
}
```

| Field            | Type              | Meaning                                                                                                                                                                                |
| ---------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | string            | The phone's ID: `ph_` followed by 12 characters from `a` to `z` and `2` to `7`.                                                                                                        |
| `object`         | string            | Always `phone`.                                                                                                                                                                        |
| `name`           | string            | Up to 80 characters. It is the phone's ID when you gave no name.                                                                                                                       |
| `status`         | string            | `creating`, `starting`, `ready`, `parking`, `parked`, `unavailable`, `failed` or `deleted`. [Concepts](/docs/getting-started/concepts#statuses) describes each one.                    |
| `country`        | string or null    | The two-letter country code asked for at create, or `null`.                                                                                                                            |
| `metadata`       | object            | Your string pairs, or `{}`.                                                                                                                                                            |
| `created_at`     | timestamp         | When the phone was created.                                                                                                                                                            |
| `last_active_at` | timestamp or null | When the phone was last active: when it became ready, or when a request last addressed it while it was ready, to within about 15 seconds. It is `null` until the phone is first ready. |
| `session`        | object or null    | The open session, or `null` when the phone isn't running.                                                                                                                              |
| `failure`        | object or null    | The latest failure, as `code`, `message` and `at`, or `null`. It is cleared when the phone starts again or becomes ready.                                                              |

The `session` object has these fields:

| Field          | Type              | Meaning                                                                                                                                 |
| -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | string            | The session's ID: `ses_` followed by 12 characters.                                                                                     |
| `started_at`   | timestamp         | When the session was admitted. Billing starts here.                                                                                     |
| `ready_at`     | timestamp or null | When the phone became ready in this session, or `null` until it does.                                                                   |
| `idle_timeout` | integer           | Seconds after `last_active_at` at which the phone parks itself.                                                                         |
| `max_duration` | integer           | The most seconds the session may run.                                                                                                   |
| `parks_at`     | timestamp         | When the phone will park itself: `last_active_at` plus `idle_timeout`, or the session's deadline if that comes first.                   |
| `park_reason`  | string            | Which rule sets `parks_at`: `idle` or `max_duration`. Until the phone is ready, the idle timer hasn't started, so it is `max_duration`. |
| `reserved_usd` | money             | The credit held for the session, renewals included.                                                                                     |

## Create a phone [#create-a-phone]

`POST /v1/phones` needs the `phones:create` scope.

Creates a phone and opens its first session, so billing starts at once. The body is optional, and a request without one creates a phone with every default. A key limited to certain phones can't create phones.

| Header            | Rules                                                                                                                                    |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `Idempotency-Key` | Optional, and recommended on every create. 8 to 128 characters: letters, digits, `_`, `.`, `:` and `-`, starting with a letter or digit. |

| Field          | Type    | Default        | Rules                                                                                                                                                                                                                                                        |
| -------------- | ------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`         | string  | The phone's ID | 1 to 80 characters, after surrounding spaces are trimmed.                                                                                                                                                                                                    |
| `country`      | string  | Any country    | One of the codes that [`GET /v1/account`](/docs/api-reference/account) lists in `countries`, such as `DE`. It is where the phone appears to be. Any other code is refused with `400 validation_failed` before a phone is made, so nothing is held or billed. |
| `metadata`     | object  | `{}`           | Up to 20 pairs. A key has 1 to 40 characters, which are letters, digits, `_`, `:` and `-`, and it starts with a letter or digit. A value is a string of up to 256 characters.                                                                                |
| `idle_timeout` | integer | 300            | Seconds, from 60 to 3600.                                                                                                                                                                                                                                    |
| `max_duration` | integer | 900            | Shortens to fit available credit when omitted. Seconds, from 60 to 10800.                                                                                                                                                                                    |
| `wait`         | boolean | `true`         | Whether to wait, for up to about 50 seconds, until the phone is ready.                                                                                                                                                                                       |

```json title="POST /v1/phones"
{
  "name": "checkout-test",
  "metadata": { "customer": "acme" },
  "idle_timeout": 300,
  "max_duration": 1800
}
```

```bash
key="checkout-test-$(date +%s)"   # a new key for each phone; send the same one to repeat this create
curl https://phonebox.dev/v1/phones \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $key" \
  -d '{"name": "checkout-test", "metadata": {"customer": "acme"}, "idle_timeout": 300, "max_duration": 1800}'
```

Both successful answers carry the phone, with its path in the `Location` header:

* `201 Created` means the phone is ready.
* `202 Accepted` means the phone was created but isn't ready yet. Either `wait` was `false`, the wait of about 50 seconds ran out, or the wait itself failed. Poll `GET /v1/phones/{id}?wait=ready` while the phone is `creating` or `starting`. Any other status ends the loop: `ready` means you can use the phone, and any other means you read its `status` and `failure`, as [Get a phone](/docs/api-reference/phones#get-a-phone) describes. If your key was revoked in the meantime, the poll answers `401 key_revoked`.

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "creating",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": null,
  "session": {
    "id": "ses_q2w6e4r5t3y7",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": null,
    "idle_timeout": 300,
    "max_duration": 1800,
    "parks_at": "2026-09-29T10:30:00.412Z",
    "park_reason": "max_duration",
    "reserved_usd": "1.800000"
  },
  "failure": null
}
```

A repeat with the same `Idempotency-Key` and body within 24 hours answers with the phone the first request created, as it is now: `201` when it is ready, and `202` otherwise. That includes a phone that has since failed, which comes back with `status` set to `failed` and its `failure`: create the next phone with a new key.

When the phone fails while the request waits, the answer is that failure as an error, with the phone's ID in `details.phone`. The phone won't recover, so the error has `retryable` set to `false` and no `Retry-After`, and its `next` ends by telling you to create a new phone, with a new idempotency key if you use one. Examples are `503 capacity_unavailable` when no device could be found for it, and `400 validation_failed` whose `details.issues` names `country` when no phones are available in the country you asked for. A failure before the phone was ever ready costs nothing.

| Error                       | When                                                                                                                                                                                                                                                       |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`     | A body field or the `Idempotency-Key` breaks its rules. `details.issues` lists each problem.                                                                                                                                                               |
| `413 payload_too_large`     | The body is larger than 100 KB.                                                                                                                                                                                                                            |
| `403 insufficient_scope`    | The key lacks `phones:create`, or it is limited to certain phones. `details.required` is `phones:create`.                                                                                                                                                  |
| `409 idempotency_conflict`  | The `Idempotency-Key` was used in the last 24 hours with a different body. `wait` isn't compared. `details.phone` names the phone the key created, and `next` says to send the original body with that key, because a new key would create a second phone. |
| `402 project_frozen`        | The project is frozen. Contact support.                                                                                                                                                                                                                    |
| `503 service_paused`        | Phonebox is paused for maintenance.                                                                                                                                                                                                                        |
| `429 rate_limited`          | The project made 60 lifecycle requests this minute.                                                                                                                                                                                                        |
| `409 running_limit_reached` | The project already runs its maximum number of phones, which `details.limit` gives.                                                                                                                                                                        |
| `409 phone_limit_reached`   | The project already has its maximum number of phones, not counting deleted or failed ones. `details.limit` gives it.                                                                                                                                       |
| `503 capacity_unavailable`  | No phones are free right now. Try again after `Retry-After`.                                                                                                                                                                                               |
| `402 insufficient_credits`  | Your balance, minus what is reserved, can't cover the reservation. `details.max_affordable_seconds` is the longest `max_duration` you can afford now.                                                                                                      |
| `402 spend_limit_reached`   | The reservation would take this month's spending past the monthly limit.                                                                                                                                                                                   |

The admission checks run in the order of this table, from `project_frozen` down.

## List phones [#list-phones]

`GET /v1/phones` needs the `phones:read` scope.

Lists the project's phones, newest first.

| Parameter       | Type    | Default                                 | Rules                                                                                                  |
| --------------- | ------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `status`        | string  | Every status but `failed` and `deleted` | One status. `?status=failed` lists failed phones, and `?status=deleted` deleted ones.                  |
| `metadata[key]` | string  | None                                    | One metadata pair to match, such as `metadata[customer]=acme`. The key follows the metadata key rules. |
| `limit`         | integer | 50                                      | From 1 to 100.                                                                                         |
| `cursor`        | string  | None                                    | The `next_cursor` of the previous page.                                                                |

```bash
curl -g "https://phonebox.dev/v1/phones?status=parked&metadata[customer]=acme" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

curl needs `-g` to send the brackets as they are.

```json title="Response: phones"
{
  "data": [
    {
      "id": "ph_3nq5w2z7c4hd",
      "object": "phone",
      "name": "acme-support",
      "status": "parked",
      "country": null,
      "metadata": { "customer": "acme" },
      "created_at": "2026-09-29T12:40:07.118Z",
      "last_active_at": "2026-09-29T12:58:41.036Z",
      "session": null,
      "failure": null
    },
    {
      "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-29T10:12:27.506Z",
      "session": null,
      "failure": null
    }
  ],
  "next_cursor": null
}
```

A key limited to certain phones gets every one of its phones that matches, on one page. For such a key, `limit` and `cursor` have no effect, and `next_cursor` is always `null`.

| Error                   | When                                                                                                                                                              |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed` | An unknown `status`, a `limit` outside 1 to 100, a parameter given twice, more than one metadata pair, a malformed metadata key, or an invalid or expired cursor. |

## Get a phone [#get-a-phone]

`GET /v1/phones/{id}` needs the `phones:read` scope.

Returns the phone. With `wait`, it long-polls until the phone reaches a status.

| Parameter | Type    | Default | Rules                                                                |
| --------- | ------- | ------- | -------------------------------------------------------------------- |
| `wait`    | string  | None    | `ready` or `parked`: wait until the phone has that status.           |
| `timeout` | integer | 50      | The most seconds to wait, from 1 to 55. It applies only with `wait`. |

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta?wait=ready&timeout=50" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

A wait answers `200 OK` with the phone as soon as it has the status you asked for. It answers early when the phone is in a status it can't leave for that one without another request: `parking`, `parked`, `unavailable`, `failed` or `deleted` for `ready`, and `failed` or `deleted` for `parked`. Otherwise it answers when `timeout` runs out, with the phone as it is.

Poll again only while the phone is on its way: `creating` or `starting` when you wait for `ready`, and `parking` when you wait for `parked`. Any other status ends the loop, so read `status` and `failure` and act on them:

* `failed` or `deleted`: the phone can't be used again, so create a new one, with a new idempotency key if you use one.
* `parked` or `unavailable`, when you waited for `ready`: the phone isn't running, so start it. When `failure` shows that a start failed, start it again later rather than at once.
* `parking`, when you waited for `ready`: wait with `?wait=parked`, then start it.
* `ready` or `starting`, when you waited for `parked`: someone started the phone again, so park it again if you still mean to. An `unavailable` phone isn't billed, so there is nothing to wait for.

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "ready",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:01:04.771Z",
  "session": {
    "id": "ses_q2w6e4r5t3y7",
    "started_at": "2026-09-29T10:00:00.412Z",
    "ready_at": "2026-09-29T10:01:04.771Z",
    "idle_timeout": 300,
    "max_duration": 1800,
    "parks_at": "2026-09-29T10:06:04.771Z",
    "park_reason": "idle",
    "reserved_usd": "1.800000"
  },
  "failure": null
}
```

If the phone fails during the wait, the answer is that failure as an error, with the phone's ID in `details.phone`, as for create. A phone that had failed before the poll began comes back as it is, with `status` set to `failed` and its `failure`.

A `wait=parked` poll doesn't count as activity, so it never keeps the phone from parking.

| Error                                     | When                                                               |
| ----------------------------------------- | ------------------------------------------------------------------ |
| `400 validation_failed`                   | `wait` isn't `ready` or `parked`, or `timeout` is outside 1 to 55. |
| The phone's failure, with `details.phone` | The phone failed during the wait.                                  |

## Update a phone [#update-a-phone]

`PATCH /v1/phones/{id}` needs the `phones:control` scope.

Renames a phone or replaces its metadata, in any status but `deleted`. The body is optional, and a field you leave out stays as it is.

| Field      | Type   | Default   | Rules                                                                                                                                   |
| ---------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `name`     | string | Unchanged | 1 to 80 characters, after surrounding spaces are trimmed.                                                                               |
| `metadata` | object | Unchanged | Replaces all of the phone's metadata, so send every pair you want to keep. `{}` removes them all. The rules are the same as for create. |

```json title="PATCH /v1/phones/{id}"
{
  "metadata": { "customer": "acme", "plan": "trial" }
}
```

```bash
curl -X PATCH https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"metadata": {"customer": "acme", "plan": "trial"}}'
```

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "parked",
  "country": null,
  "metadata": { "customer": "acme", "plan": "trial" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:12:27.506Z",
  "session": null,
  "failure": null
}
```

| Error                   | When                            |
| ----------------------- | ------------------------------- |
| `400 validation_failed` | A field breaks its rules.       |
| `413 payload_too_large` | The body is larger than 100 KB. |
| `409 phone_deleted`     | The phone was deleted.          |

## Delete a phone [#delete-a-phone]

`DELETE /v1/phones/{id}` needs the `phones:delete` scope, which only Admin keys have.

Destroys the phone's device with every app, file and signed-in account on it. This can't be undone. An open session ends with the end reason `deleted` and is billed up to that moment, with the one-minute minimum, even if the phone was never ready. The answer is the phone with `status` set to `deleted`, and deleting it again returns the same.

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "deleted",
  "country": null,
  "metadata": { "customer": "acme", "plan": "trial" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:12:27.506Z",
  "session": null,
  "failure": null
}
```

| Error                    | When                                                               |
| ------------------------ | ------------------------------------------------------------------ |
| `403 insufficient_scope` | The key isn't an Admin key. `details.required` is `phones:delete`. |
| `429 rate_limited`       | The project made 60 lifecycle requests this minute.                |

## Start a phone [#start-a-phone]

`POST /v1/phones/{id}/start` needs the `phones:control` scope.

Starts a `parked` or `unavailable` phone: it opens a new session, which reserves credit for `max_duration`, and resumes the device with its apps, files and sign-ins. On a running phone, one that is `creating`, `starting` or `ready`, it renews the session instead: the deadline moves to `max_duration` from now, only the extra time is reserved, and a ready phone's idle timer restarts. The body is optional.

| Field          | Type    | Default                     | Rules                                                                  |
| -------------- | ------- | --------------------------- | ---------------------------------------------------------------------- |
| `idle_timeout` | integer | The phone's current setting | Seconds, from 60 to 3600.                                              |
| `max_duration` | integer | The phone's current setting | Seconds, from 60 to 10800.                                             |
| `wait`         | boolean | `true`                      | Whether to wait, for up to about 50 seconds, until the phone is ready. |

The timers you give become the phone's settings, so later starts keep them.

```json title="POST /v1/phones/{id}/start"
{
  "idle_timeout": 900,
  "max_duration": 7200
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/start \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"idle_timeout": 900, "max_duration": 7200}'
```

It answers `200 OK` with the ready phone, or `202 Accepted` with the phone still `starting`, in which case you poll `GET /v1/phones/{id}?wait=ready` while it is `starting`.

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "starting",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T10:12:27.506Z",
  "session": {
    "id": "ses_x7c3v5b2n6m4",
    "started_at": "2026-09-29T14:20:02.118Z",
    "ready_at": null,
    "idle_timeout": 900,
    "max_duration": 7200,
    "parks_at": "2026-09-29T16:20:02.118Z",
    "park_reason": "max_duration",
    "reserved_usd": "7.200000"
  },
  "failure": null
}
```

When the start fails while the request waits, the answer is that failure as an error, with the phone's ID in `details.phone`. A phone that doesn't become ready within 5 minutes of a start parks again with its data, and its `failure` says why; that error keeps its code's usual `retryable`, because you can start the phone again. A phone whose device is lost becomes `failed` instead, and its error isn't retryable: create a new phone, with a new idempotency key if you use one. If the wait fails for any other reason, such as a key revoked in the meantime, the start has already happened, so the request answers `202 Accepted` with the phone as it was last seen.

| Error                                     | When                                                                                                                                          |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`                   | A field breaks its rules.                                                                                                                     |
| `413 payload_too_large`                   | The body is larger than 100 KB.                                                                                                               |
| `409 phone_parking`                       | The phone is still parking. This error is retryable: wait with `?wait=parked`, then start it.                                                 |
| `409 phone_failed`, `409 phone_deleted`   | The phone can't be used again. Create a new one, with a new idempotency key if you use one.                                                   |
| `402 project_frozen`                      | The project is frozen. Contact support.                                                                                                       |
| `503 service_paused`                      | Phonebox is paused for maintenance.                                                                                                           |
| `429 rate_limited`                        | The project made 60 lifecycle requests this minute.                                                                                           |
| `409 running_limit_reached`               | A new start would pass the running limit, which `details.limit` gives.                                                                        |
| `503 capacity_unavailable`                | No phones are free for a new start. Try again after `Retry-After`.                                                                            |
| `402 insufficient_credits`                | Your available balance can't cover the reservation, or a renewal's extra time. `details.max_affordable_seconds` says how much you can afford. |
| `402 spend_limit_reached`                 | The reservation would take this month's spending past the monthly limit.                                                                      |
| The phone's failure, with `details.phone` | The phone failed to start during the wait.                                                                                                    |

## Park a phone [#park-a-phone]

`POST /v1/phones/{id}/park` needs the `phones:control` scope.

Parks a running phone: the session closes and billing stops at once, the phone becomes `parking`, and it becomes `parked` once its device has stopped. A phone that isn't running is returned as it is, and a phone that is already parking is waited on. The body is optional.

| Field  | Type    | Default | Rules                                                                   |
| ------ | ------- | ------- | ----------------------------------------------------------------------- |
| `wait` | boolean | `true`  | Whether to wait, for up to about 50 seconds, until the phone is parked. |

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/park \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

It answers `200 OK` with the parked phone, or `202 Accepted` with the phone still `parking`, in which case you can poll `GET /v1/phones/{id}?wait=parked` while it is `parking`. When the wait fails for any reason but the phone's own failure, the answer is `202` too, with the phone as it was last seen. Billing has stopped either way.

```json title="Response: phone"
{
  "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
}
```

| Error                                     | When                                                                                                                                                                 |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`                   | `wait` isn't a boolean, or the body has another field.                                                                                                               |
| `413 payload_too_large`                   | The body is larger than 100 KB.                                                                                                                                      |
| `409 phone_failed`, `409 phone_deleted`   | The phone can't be used again. Create a new one, with a new idempotency key if you use one.                                                                          |
| `429 rate_limited`                        | The project made 60 lifecycle requests this minute.                                                                                                                  |
| The phone's failure, with `details.phone` | The phone failed while it parked, for example because its device was lost. The error isn't retryable: create a new phone, with a new idempotency key if you use one. |

## Keep a phone awake [#keep-a-phone-awake]

`POST /v1/phones/{id}/heartbeat` needs the `phones:control` scope.

Restarts a ready phone's idle timer and returns the phone. It takes no body and doesn't move the session's deadline: [start the phone](/docs/api-reference/phones#start-a-phone) to renew it. A phone that is still `creating` or `starting` is returned as it is.

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/heartbeat \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "ready",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T14:32:50.264Z",
  "session": {
    "id": "ses_x7c3v5b2n6m4",
    "started_at": "2026-09-29T14:20:02.118Z",
    "ready_at": "2026-09-29T14:20:41.309Z",
    "idle_timeout": 900,
    "max_duration": 7200,
    "parks_at": "2026-09-29T14:47:50.264Z",
    "park_reason": "idle",
    "reserved_usd": "7.200000"
  },
  "failure": null
}
```

| Error                                   | When                                                                                        |
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
| `409 phone_parking`                     | The phone is parking. This error is retryable: wait with `?wait=parked`.                    |
| `409 phone_not_running`                 | The phone is parked or unavailable, as `details.status` says.                               |
| `409 phone_failed`, `409 phone_deleted` | The phone can't be used again. Create a new one, with a new idempotency key if you use one. |


---

# Sessions

Source: https://phonebox.dev/docs/api-reference/sessions

> List a phone's billed sessions, with when each one ran, why it ended and what it cost.



A session is one billed period on one phone, from the moment a create or start is admitted until billing stops. [Concepts](/docs/getting-started/concepts#sessions) explains sessions, and [Pricing and credits](/docs/billing/pricing) explains how they are charged.

## List a phone's sessions [#list-a-phones-sessions]

`GET /v1/phones/{id}/sessions` needs the `phones:read` scope.

Lists the phone's sessions, newest first, the open one included. It works in every status, so a deleted phone's sessions stay listed.

| Parameter | Type    | Default | Rules                                   |
| --------- | ------- | ------- | --------------------------------------- |
| `limit`   | integer | 50      | From 1 to 100.                          |
| `cursor`  | string  | None    | The `next_cursor` of the previous page. |

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/sessions?limit=3" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: sessions"
{
  "data": [
    {
      "id": "ses_h4j6k2m7n3p5",
      "started_at": "2026-09-29T16:02:11.730Z",
      "ready_at": "2026-09-29T16:02:48.215Z",
      "ended_at": null,
      "end_reason": null,
      "billed_seconds": null,
      "cost_usd": null,
      "reserved_usd": "7.200000"
    },
    {
      "id": "ses_b5c3d7f2g6h4",
      "started_at": "2026-09-29T15:05:40.052Z",
      "ready_at": null,
      "ended_at": "2026-09-29T15:10:40.603Z",
      "end_reason": "failed",
      "billed_seconds": 0,
      "cost_usd": "0.000000",
      "reserved_usd": "7.200000"
    },
    {
      "id": "ses_x7c3v5b2n6m4",
      "started_at": "2026-09-29T14:20:02.118Z",
      "ready_at": "2026-09-29T14:20:41.309Z",
      "ended_at": "2026-09-29T14:41:33.004Z",
      "end_reason": "parked",
      "billed_seconds": 1291,
      "cost_usd": "1.291000",
      "reserved_usd": "7.200000"
    }
  ],
  "next_cursor": "Hq4Vn8Kp2Lm7Tx3Rz6Wc9Yb5Gd1Fs0Ja"
}
```

| Field            | Type              | Meaning                                                                  |
| ---------------- | ----------------- | ------------------------------------------------------------------------ |
| `id`             | string            | The session's ID: `ses_` followed by 12 characters.                      |
| `started_at`     | timestamp         | When the create or start was admitted. Billing starts here.              |
| `ready_at`       | timestamp or null | When the phone became ready, or `null` if it hasn't.                     |
| `ended_at`       | timestamp or null | When billing stopped, or `null` while the session is open.               |
| `end_reason`     | string or null    | Why the session ended, from the table below, or `null` while it is open. |
| `billed_seconds` | integer or null   | The seconds charged, or `null` while the session is open.                |
| `cost_usd`       | money or null     | What the session cost, or `null` while it is open.                       |
| `reserved_usd`   | money             | The credit the session reserved, renewals included.                      |

A closed session is billed for the seconds from `started_at` to `ended_at`, rounded up, with a minimum of 60 and never more than it reserved. Each second costs $0.001, so `cost_usd` is `billed_seconds` times $0.001. A session that ended before the phone was ever ready costs nothing, with `billed_seconds` at 0, unless you parked or deleted the phone yourself.

| `end_reason`     | Meaning                                                                                                                          |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `parked`         | You parked the phone.                                                                                                            |
| `idle`           | The phone's `idle_timeout` passed after its `last_active_at`, which a request addressing it moves at most once every 15 seconds. |
| `max_duration`   | The session reached its `max_duration`.                                                                                          |
| `deleted`        | You deleted the phone.                                                                                                           |
| `failed`         | The phone failed, or a start couldn't finish in time and the phone parked again.                                                 |
| `unavailable`    | The phone became unavailable, for example for maintenance.                                                                       |
| `project_frozen` | The project was frozen, which parks its running phones.                                                                          |
| `service_paused` | Phonebox was paused for maintenance, which parks every running phone.                                                            |

| Error                   | When                                                                                              |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `400 validation_failed` | `limit` is outside 1 to 100, a parameter is given twice, or the cursor is invalid or has expired. |


---

# Observe

Source: https://phonebox.dev/docs/api-reference/observe

> Read the screen as numbered elements or compact text, and take screenshots with their scale.



Both endpoints read the phone's screen, so the phone must be `ready`, and both count as activity that keeps the phone from idling, unless the key is Read-only. [Observe the screen](/docs/using-phones/observe) explains every field, which elements are listed, and the text form.

## Observe the screen [#observe-the-screen]

`GET /v1/phones/{id}/observe` needs the `phones:read` scope.

Reads the screen and stores its element list as a snapshot for 15 minutes, so that actions can target elements by `ref` together with the `snapshot_id`.

| Parameter    | Type    | Default            | Rules                                                          |
| ------------ | ------- | ------------------ | -------------------------------------------------------------- |
| `screenshot` | string  | `none`             | `none`, `jpeg` or `png`. Adds a screenshot to the JSON.        |
| `max_width`  | integer | The screen's width | From 160 to 2000. The widest the screenshot may be, in pixels. |
| `format`     | string  | `json`             | `json`, or `text` for the text form alone.                     |

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/observe?screenshot=jpeg&max_width=720" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The JSON answer is an observation. This one is shortened: `data` holds the image in base64.

```json title="Response: observation"
{
  "snapshot_id": "snp_4f2k7m3q6z3a",
  "taken_at": "2026-09-29T10:01:05.662Z",
  "app": { "package": "com.android.launcher3", "activity": null },
  "keyboard": { "visible": false, "focused_editable": false },
  "screen": { "width": 1080, "height": 2400 },
  "elements": [
    {
      "ref": 1, "type": "EditText", "text": "Search apps", "desc": null, "id": "com.android.launcher3:id/search",
      "bounds": [60, 200, 1020, 300], "center": [540, 250], "state": ["clickable", "editable"]
    },
    {
      "ref": 2, "type": "TextView", "text": "Chrome", "desc": null, "id": "com.android.launcher3:id/icon",
      "bounds": [100, 1800, 300, 2000], "center": [200, 1900], "state": ["clickable"]
    }
  ],
  "truncated": false,
  "text": "app com.android.launcher3\nkeyboard hidden\nscreen 1080x2400\n[1] EditText \"Search apps\" #search clickable editable (540,250)\n[2] TextView \"Chrome\" #icon clickable (200,1900)",
  "screenshot": { "format": "jpeg", "width": 720, "height": 1600, "scale": 0.666667, "data": "/9j/4AAQSkZJRgABAQAAAQABAAD…" }
}
```

`screenshot` is present only when you ask for one. Element coordinates are always device pixels, and `screenshot.scale` is the image's width divided by the screen's. The whole answer stays under 4 MB, so Phonebox shrinks a screenshot further when it has to.

With `format=text`, the answer is `text/plain; charset=utf-8`, holding the `text` field alone, and its `X-Snapshot-Id` header carries the snapshot ID. The text form never includes a screenshot.

| Error                   | When                                             |
| ----------------------- | ------------------------------------------------ |
| `400 validation_failed` | A parameter is outside its range or given twice. |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.

## Take a screenshot [#take-a-screenshot]

`GET /v1/phones/{id}/screenshot` needs the `phones:read` scope.

Returns the screen as an image, not JSON.

| Parameter   | Type    | Default            | Rules                                                     |
| ----------- | ------- | ------------------ | --------------------------------------------------------- |
| `format`    | string  | `png`              | `png` or `jpeg`.                                          |
| `max_width` | integer | The screen's width | From 160 to 2000. The widest the image may be, in pixels. |
| `quality`   | integer | 70                 | From 30 to 95. JPEG quality. A PNG ignores it.            |

```bash
curl -o screen.jpg "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/screenshot?format=jpeg&max_width=720" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

The answer is `image/png` or `image/jpeg`, always under 4 MB. Phonebox makes the image narrower when it would be larger. Three headers describe it:

| Header            | Meaning                                                                                                                   |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `X-Screen-Width`  | The screen's width in device pixels.                                                                                      |
| `X-Screen-Height` | The screen's height in device pixels.                                                                                     |
| `X-Screen-Scale`  | The image's width divided by the screen's, to six decimal places. Divide a point on the image by it to get device pixels. |

| Error                   | When                                             |
| ----------------------- | ------------------------------------------------ |
| `400 validation_failed` | A parameter is outside its range or given twice. |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.


---

# Actions

Source: https://phonebox.dev/docs/api-reference/actions

> Run a batch of actions, with every action type, target form and limit, the result format, and the errors of a batch and of each action.



[Actions and targets](/docs/using-phones/actions) explains how batches, targets and failures work, with worked examples. This page is the exact reference.

## Run actions [#run-actions]

`POST /v1/phones/{id}/actions` needs the `phones:control` scope.

Runs up to 20 actions on a `ready` phone, in order, and stops at the first one that fails. The body is required and may be up to 200 KB.

| Field     | Type   | Default | Rules                                                                                   |
| --------- | ------ | ------- | --------------------------------------------------------------------------------------- |
| `actions` | array  | None    | From 1 to 20 [actions](/docs/api-reference/actions#action-types).                       |
| `observe` | string | `ui`    | `none`, `ui`, `screenshot` or `both`: what the response shows of the screen afterwards. |

The `ms` of every `wait` and the `timeout_ms` of every `wait_for` in a batch may add up to 60,000 at most. A `wait_for` without `timeout_ms` counts as 10,000.

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "id": "email" } },
    { "type": "type", "text": "alex@example.com", "clear": true },
    { "type": "tap", "target": { "text": "Continue" } },
    { "type": "wait_for", "target": { "text": "Enter your password" }, "timeout_ms": 15000 }
  ],
  "observe": "none"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/actions \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"actions": [{"type": "tap", "target": {"id": "email"}}, {"type": "type", "text": "alex@example.com", "clear": true}, {"type": "tap", "target": {"text": "Continue"}}, {"type": "wait_for", "target": {"text": "Enter your password"}, "timeout_ms": 15000}], "observe": "none"}'
```

A batch that ran answers `200 OK`, whether or not every action succeeded:

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "tap", "ok": true, "ms": 231, "resolved": { "ref": 2, "center": [540, 700] } },
    { "index": 1, "type": "type", "ok": true, "ms": 1406 },
    { "index": 2, "type": "tap", "ok": true, "ms": 198, "resolved": { "ref": 3, "center": [540, 950] } },
    { "index": 3, "type": "wait_for", "ok": true, "ms": 2517 }
  ],
  "completed": 4
}
```

| Field                               | Meaning                                                                                                                                                     |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `results`                           | One entry for each action that ran, in order, ending with the one that failed if one did. Actions after a failure don't run and have no entry.              |
| `results[].index`, `results[].type` | The action's position in the batch, from 0, and its type.                                                                                                   |
| `results[].ok`                      | Whether the action succeeded.                                                                                                                               |
| `results[].ms`                      | How long it took, in milliseconds.                                                                                                                          |
| `results[].resolved`                | For `tap`, `double_tap` and `long_press`: the `center` acted on and, when the target was an element, its `ref` on the screen as it was read for the action. |
| `results[].error`                   | For a failed action: `code`, `message`, `retryable`, `next` and `details`, as in the [action errors](/docs/api-reference/actions#action-errors).            |
| `completed`                         | How many actions succeeded.                                                                                                                                 |
| `observation`                       | The screen afterwards, as `observe` asked.                                                                                                                  |

`observe` decides what `observation` holds:

| `observe`    | After a batch that completed                             | After a batch that stopped early     |
| ------------ | -------------------------------------------------------- | ------------------------------------ |
| `none`       | No `observation`.                                        | The element list.                    |
| `ui`         | The element list.                                        | The element list.                    |
| `screenshot` | Only `taken_at` and a JPEG `screenshot` 720 pixels wide. | The element list and the screenshot. |
| `both`       | The element list and the screenshot.                     | The element list and the screenshot. |

The element list is a full [observation](/docs/api-reference/observe#observe-the-screen), with a new `snapshot_id` whose refs you can target. A batch that stops early includes it, so you can see where it stopped, as long as the screen can be read. The optional UI read gets at most 5 seconds, within the request deadline. If it fails, Phonebox attempts a screenshot for up to 3 more seconds. After an `action_outcome_unknown`, it goes straight to that screenshot, without repeating the action. A fallback observation contains only `taken_at` and `screenshot`, with no elements or `snapshot_id`: inspect the image, and obtain a fresh observation before using refs. If the screenshot also fails or the request has no time left, `observation` is omitted and the action results still come back.

### When a batch stops [#when-a-batch-stops]

This batch opened Settings, then found nothing labeled Bluetooth:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "open_app", "package": "com.android.settings" },
    { "type": "tap", "target": { "text": "Bluetooth" } }
  ]
}
```

```json title="Response: batch"
{
  "results": [
    { "index": 0, "type": "open_app", "ok": true, "ms": 1284 },
    {
      "index": 1, "type": "tap", "ok": false, "ms": 367,
      "error": {
        "code": "target_not_found",
        "message": "No element on the screen matches the target.",
        "retryable": false,
        "next": null,
        "details": {
          "target": { "text": "Bluetooth" },
          "candidates": [
            { "ref": 1, "type": "TextView", "text": "Search settings", "desc": null, "id": "com.android.settings:id/search", "center": [540, 210] },
            { "ref": 2, "type": "TextView", "text": "Network & internet", "desc": null, "id": null, "center": [540, 520] },
            { "ref": 3, "type": "TextView", "text": "Connected devices", "desc": null, "id": null, "center": [540, 760] },
            { "ref": 4, "type": "TextView", "text": "Apps", "desc": null, "id": null, "center": [540, 1000] }
          ],
          "snapshot_id": "snp_4f2k7m3q6z3a"
        }
      }
    }
  ],
  "completed": 1,
  "observation": {
    "snapshot_id": "snp_4f2k7m3q6z3a",
    "taken_at": "2026-09-29T14:36:02.337Z",
    "app": { "package": "com.android.settings", "activity": null },
    "keyboard": { "visible": false, "focused_editable": false },
    "screen": { "width": 1080, "height": 2400 },
    "elements": [
      { "ref": 1, "type": "TextView", "text": "Search settings", "desc": null, "id": "com.android.settings:id/search", "bounds": [60, 160, 1020, 260], "center": [540, 210], "state": ["clickable"] },
      { "ref": 2, "type": "TextView", "text": "Network & internet", "desc": null, "id": null, "bounds": [0, 460, 1080, 580], "center": [540, 520], "state": ["clickable"] },
      { "ref": 3, "type": "TextView", "text": "Connected devices", "desc": null, "id": null, "bounds": [0, 700, 1080, 820], "center": [540, 760], "state": ["clickable"] },
      { "ref": 4, "type": "TextView", "text": "Apps", "desc": null, "id": null, "bounds": [0, 940, 1080, 1060], "center": [540, 1000], "state": ["clickable"] }
    ],
    "truncated": false,
    "text": "app com.android.settings\nkeyboard hidden\nscreen 1080x2400\n[1] TextView \"Search settings\" #search clickable (540,210)\n[2] TextView \"Network & internet\" clickable (540,520)\n[3] TextView \"Connected devices\" clickable (540,760)\n[4] TextView \"Apps\" clickable (540,1000)"
  }
}
```

The action read the screen before the batch's observation did, and the screen can move in between. So Phonebox looks up each candidate of a `target_not_found` or `ambiguous_target` in the returned observation, gives it that element's `ref` and `center`, and names the observation's `snapshot_id` in `details.snapshot_id`. When any candidate isn't there anymore, `details` keeps the refs as the action saw them and names no snapshot, so observe again before you act by ref. Here, the next step is to tap one of the candidates, such as `{"ref": 3, "snapshot_id": "snp_4f2k7m3q6z3a"}`, and look again.

The actions before the one that failed have already run, so the next batch starts at the failed action, changed as needed, followed by the actions that came after it. Never send the whole batch again: here, a new batch would not open Settings a second time.

### When nothing ran [#when-nothing-ran]

Every error that Phonebox sends from this endpoint, in the [error envelope](/docs/api-reference/errors) with its `error.code`, means that no action ran, except `500 internal_error`. That covers the request's own problems, such as a bad body or a phone that isn't ready, and a phone service that failed before the first action reached the phone:

```json title="Response: error"
{
  "error": {
    "type": "provider_error",
    "code": "phone_unavailable",
    "message": "The phone is temporarily unavailable.",
    "retryable": true,
    "next": "Nothing was done on the phone; retry this action in a few seconds.",
    "request_id": "req_t3v5x2z6b4d7",
    "details": { "retry_after_seconds": 2 }
  }
}
```

A batch refused this way is safe to send again after the wait. Any other failure leaves the outcome unknown: a `500 internal_error`, a `502` or `504` without the envelope, which a proxy or the hosting platform sends, a timeout on your side, a dropped connection, or no answer at all. The batch may have run, in part or in full, so observe before you send anything again. [Failures without the envelope](/docs/api-reference/errors#failures-without-the-envelope) explains why.

| Error                      | When                                                                                                                                                                                                                                                                      |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`    | The body breaks a rule: an unknown action type or field, a value out of range, more than 20 actions, or more than 60 seconds of waits.                                                                                                                                    |
| `413 payload_too_large`    | The body is larger than 200 KB.                                                                                                                                                                                                                                           |
| `503 phone_unavailable`    | The first action couldn't reach the phone, so nothing ran. This error is retryable. When the phone's control service was out of reach, `Retry-After` and `details.retry_after_seconds` are 2 seconds. Otherwise the response has no `Retry-After`, so wait a few seconds. |
| `503 capacity_unavailable` | The phone service had no capacity for the first action, so nothing ran. Try again after `Retry-After`.                                                                                                                                                                    |
| `429 rate_limited`         | The phone service asked Phonebox to slow down before the first action, so nothing ran. `Retry-After` and `details.retry_after_seconds` say how long to wait.                                                                                                              |
| `502 provider_error`       | The phone service failed before the first action reached the phone, for example while reading the screen to find the target. Nothing ran, and this error is retryable.                                                                                                    |

The readiness errors in [Common errors](/docs/api-reference#common-errors) apply too.

## Action types [#action-types]

| `type`                                     | Fields                          | Rules and defaults                                                                                                                                                                                        |
| ------------------------------------------ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tap`                                      | `target`                        | Any [target](/docs/api-reference/actions#targets).                                                                                                                                                        |
| `double_tap`                               | `target`                        | Two taps 80 ms apart.                                                                                                                                                                                     |
| `long_press`                               | `target`, `duration_ms`         | `duration_ms` from 300 to 5000, 800 by default.                                                                                                                                                           |
| `swipe`                                    | `from`, `to`, `duration_ms`     | `from` and `to` are points, `{"x": …, "y": …}`. `duration_ms` from 50 to 5000, 300 by default.                                                                                                            |
| `scroll`                                   | `direction`, `target`, `amount` | `direction` is `up`, `down`, `left` or `right`. `target` is optional: an element to scroll inside, or the whole screen when you leave it out or give a point. `amount` from 0.1 to 1, 0.6 by default.     |
| `type`                                     | `text`, `clear`, `submit`       | `text` has 1 to 5000 characters and goes into the focused field. `clear` empties the field first and `submit` presses Enter afterwards, both `false` by default.                                          |
| `key`                                      | `key`                           | A key name, or an Android key code from 0 to 400.                                                                                                                                                         |
| `back`, `home`, `recents`, `notifications` | None                            | Press Back, go home, open the recent apps, or open the notifications.                                                                                                                                     |
| `open_app`                                 | `package`, `activity`           | `package` is an Android package name. `activity` is optional, up to 300 characters.                                                                                                                       |
| `close_app`                                | `package`, `clear_data`         | `clear_data`, `false` by default, also erases the app's data and its sign-ins.                                                                                                                            |
| `open_url`                                 | `url`, `package`                | `url` is a web address or a deep link, 3 to 2000 characters. `package` is optional: the app to open it in.                                                                                                |
| `wait`                                     | `ms`                            | From 1 to 10000 milliseconds.                                                                                                                                                                             |
| `wait_for`                                 | `target`, `gone`, `timeout_ms`  | `target` is a text, ID or description target. `gone`, `false` by default, waits for the target to disappear instead. `timeout_ms` from 100 to 30000, 10000 by default. It checks the screen every 500 ms. |

The key names are `enter`, `delete`, `tab`, `escape`, `space`, `up`, `down`, `left`, `right`, `page_up`, `page_down`, `move_home`, `move_end`, `menu`, `search`, `volume_up` and `volume_down`. A package name has at least two parts separated by dots, such as `org.wikipedia`, and at most 255 characters.

## Targets [#targets]

A target takes exactly one of these forms. [Targets](/docs/using-phones/actions#targets) explains how each one finds its element.

| Form                                            | Rules                                                                                                                                       |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `{"x": 540, "y": 950}`                          | A point in device pixels. Each coordinate is an integer from 0 to 10000.                                                                    |
| `{"ref": 3, "snapshot_id": "snp_4f2k7m3q6z3a"}` | `ref` from 1 to 1000, and the `snapshot_id` of an observation from the last 15 minutes. The element must still be on the screen, unchanged. |
| `{"text": "Sign in", "nth": 2}`                 | `text` from 1 to 500 characters, matched against text and descriptions. `nth` is optional, from 1 to 50.                                    |
| `{"id": "sign_in"}`                             | 1 to 300 characters: a full resource ID, or the part after `:id/`.                                                                          |
| `{"desc": "Back"}`                              | 1 to 500 characters, matched against content descriptions.                                                                                  |

Text, ID and description targets see the same elements that an observation lists, which are at most 250.

## Action errors [#action-errors]

A failed action's `error` has the same fields as an [error envelope](/docs/api-reference/errors), without `type` and `request_id`. These are the codes it can carry:

| Code                     | Retryable | Details                                                                                                          | What to do                                                                                                                                                                                                 |
| ------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target_not_found`       | `false`   | `target`, `candidates` (up to 10), `snapshot_id`, and `matches` when `nth` was larger than the number of matches | Use one of the candidates, scroll to bring the element into view, or observe again.                                                                                                                        |
| `ambiguous_target`       | `false`   | `candidates` (up to 10), `snapshot_id`                                                                           | 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, for example with the full resource ID. |
| `stale_snapshot`         | `false`   | `snapshot_id`                                                                                                    | The element behind the ref changed or went away, or the snapshot is older than 15 minutes. Observe again.                                                                                                  |
| `no_focused_field`       | `false`   | None                                                                                                             | A `type` action found no focused text field. Tap the field, then type.                                                                                                                                     |
| `wait_timeout`           | `true`    | `timeout_ms`, `target`                                                                                           | A `wait_for` ran out of time. Observe, and wait again with a longer `timeout_ms` if the screen is still loading.                                                                                           |
| `app_not_found`          | `false`   | `package`                                                                                                        | The app isn't installed, or its install hasn't finished. Install it, or check the package name.                                                                                                            |
| `action_outcome_unknown` | `false`   | None                                                                                                             | The phone didn't confirm the action, which may or may not have happened. Observe before anything else.                                                                                                     |
| `phone_unavailable`      | `true`    | `retry_after_seconds`, when the phone's control service was out of reach                                         | This action didn't run. After a few seconds, send it and the actions after it again.                                                                                                                       |
| `rate_limited`           | `true`    | `retry_after_seconds`                                                                                            | This action didn't run. Wait, then send it and the actions after it again.                                                                                                                                 |
| `capacity_unavailable`   | `true`    | None                                                                                                             | This action didn't run. Try it and the actions after it again later.                                                                                                                                       |
| `provider_error`         | `true`    | None                                                                                                             | The phone service refused or failed the action. Observe, then decide whether to try again.                                                                                                                 |
| `provider_timeout`       | `true`    | `not_run`                                                                                                        | The batch had already run for 90 seconds, so this action and the ones after it didn't run. `not_run` counts them. Observe, then send them in a new batch.                                                  |
| `internal_error`         | `true`    | None                                                                                                             | Something failed on our side. Observe before you decide.                                                                                                                                                   |

### Partial actions [#partial-actions]

A `type` with `submit` types and then presses Enter, and a `double_tap` taps twice. When the second step fails after the first reached the phone, the error has `details.partial` set to `true` and `retryable` set to `false`, whatever its code, and `next` says which step was done. Observe before you do anything else. The second step may itself have happened, so send it only when the screen shows that it didn't, for example a form still waiting with your text in it. Never repeat the whole action.

[Unknown outcomes](/docs/troubleshooting/unknown-outcomes) shows how to recover from these cases.


---

# Apps

Source: https://phonebox.dev/docs/api-reference/apps

> Search the app library, list a phone's installed apps, install apps from the library or your own APK in the background, follow the installs, and uninstall apps.



Every app request on a phone needs a `ready` phone. Apps and their data stay on the phone while it is parked. [Apps](/docs/using-phones/apps) shows how to open, close and link into them with actions.

## List apps [#list-apps]

`GET /v1/phones/{id}/apps` needs the `phones:read` scope.

Lists every installed app, the system apps that came with the phone included.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: apps"
{
  "data": [
    { "package": "com.android.chrome", "label": "Chrome", "version_name": "129.0.6668.100", "version_code": 666810033, "system": true },
    { "package": "com.android.settings", "label": "Settings", "version_name": "14", "version_code": 34, "system": true },
    { "package": "org.wikipedia", "label": "Wikipedia", "version_name": "2.7.50502", "version_code": 50502, "system": false }
  ]
}
```

| Field          | Type    | Meaning                                     |
| -------------- | ------- | ------------------------------------------- |
| `package`      | string  | The Android package name.                   |
| `label`        | string  | The name the launcher shows.                |
| `version_name` | string  | The version as the app displays it.         |
| `version_code` | integer | The version as a number.                    |
| `system`       | boolean | `true` for an app that came with the phone. |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply.

## Search the app library [#search-the-app-library]

`GET /v1/apps/library` needs the `phones:read` scope.

Searches Phonebox's app library: the apps a phone installs by package with [Install an app](#install-an-app). It holds store and system apps, the same for every project. It names no phone, and it isn't Google Play: an app that isn't here installs from the Play Store app on the phone.

| Parameter | Type   | Default | Rules                                                                                                           |
| --------- | ------ | ------- | --------------------------------------------------------------------------------------------------------------- |
| `query`   | string | None    | Part of a package or app name, up to 100 characters, such as `chrome`. Without it, the whole library is listed. |
| `cursor`  | string | None    | The `next_cursor` of the previous page, sent back unchanged.                                                    |

```bash
curl "https://phonebox.dev/v1/apps/library?query=chrome" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

Each page holds up to 50 apps; `next_cursor` is `null` on the last one. Phonebox refreshes the library every few hours. While it loads for the first time, a search answers `429 rate_limited`: search again after `Retry-After`.

```json title="Response: library"
{
  "data": [
    { "package": "com.android.chrome", "label": "Chrome", "version_code": 666810033, "version_name": "134.0.6998.135" }
  ],
  "next_cursor": null
}
```

## Install an app [#install-an-app]

`POST /v1/phones/{id}/apps` needs the `phones:control` scope.

Starts installing an app and answers at once, while the install continues in the background. It installs one of two things:

* **An app from Phonebox's app library**, by its package name: the library's own version of it. It doesn't reach Google Play, or any uploaded APK: a Play Store app installs through the Play Store app on the phone, as [Apps](/docs/using-phones/apps#apps-from-the-play-store) describes, and your own APK by `upload`.
* **Your own APK**, by an [upload](/docs/api-reference/uploads) whose status is `ready`. It installs the package the APK declares at the APK's `versionCode`, and its entry in [List app installs](#list-app-installs) succeeds only once the phone lists that package at that version.

| Field     | Type    | Default | Rules                                                                                                                                                                       |
| --------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `package` | string  | None    | An Android package name: at least two parts separated by dots, starting with a letter, up to 255 characters. Give this or `upload`.                                         |
| `upload`  | string  | None    | An upload's ID, `upl_…`, from this project. Give this or `package`.                                                                                                         |
| `replace` | boolean | `false` | With `upload`: uninstall the app first when it is on the phone, which deletes its data and sign-ins. Use it for a build signed with another key, or an older `versionCode`. |

```json title="POST /v1/phones/{id}/apps"
{
  "package": "com.example.app"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"package": "com.example.app"}'
```

It answers `202 Accepted`:

```json title="Response: install"
{
  "package": "com.example.app",
  "status": "running"
}
```

An install by upload answers with its upload too:

```json title="Response: install"
{
  "package": "com.example.app",
  "upload": "upl_q3m7x2k6v4ta",
  "install": "ins_7m2k6q3x4v5a",
  "status": "running"
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"upload": "upl_q3m7x2k6v4ta"}'
```

Follow the install with [List app installs](/docs/api-reference/apps#list-app-installs). Until it has finished, `open_app` fails with `app_not_found`.

| Error                        | When                                                                                                                                                                                                                                                                                                                                                                   |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`      | The body names neither or both of `package` and `upload`, `package` isn't a valid package name, or `replace` comes without `upload`.                                                                                                                                                                                                                                   |
| `404 upload_not_found`       | No upload with this ID exists in the project.                                                                                                                                                                                                                                                                                                                          |
| `409 upload_not_ready`       | The upload isn't `ready`: `details.status` says what it is, and `next` what to do. With `retryable: true`, the upload is being prepared for this phone: send the install again in a minute.                                                                                                                                                                            |
| `409 version_downgrade`      | The phone has a newer `versionCode` of the upload's app: `details.installed_version_code` and `details.version_code`. Nothing ran. Build with a higher `versionCode`, or send `replace: true`.                                                                                                                                                                         |
| `413 payload_too_large`      | The body is larger than 100 KB.                                                                                                                                                                                                                                                                                                                                        |
| `409 install_in_progress`    | Another install is still running on the phone, which runs at most two installs at once, and one per app. This error is retryable. Installs take a few seconds, so send it again shortly, a few times at most. [List app installs](/docs/api-reference/apps#list-app-installs) shows when the running install has finished, or failed. `details.package` names the app. |
| `422 app_not_available`      | The app isn't in Phonebox's app library. `details.package` names it, and `next` says to use the Play Store app on the phone or upload the APK. Your own uploads install only by `upload`.                                                                                                                                                                              |
| `502 action_outcome_unknown` | The phone service didn't confirm the install, so it may have started. List the installs before you try again. For an install by upload, `details.install` names its row there, which Phonebox follows to its end: don't send the install again until that row has ended.                                                                                               |

With `replace`, Phonebox first checks that the phone can start another install, so an `install_in_progress` refusal then comes before anything is uninstalled. An error that comes after the uninstall has `details.uninstalled: true`: the app and its data are already gone, and `next` says when sending the same request again is safe.

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.

## List app installs [#list-app-installs]

`GET /v1/phones/{id}/apps/installs` needs the `phones:read` scope.

Lists the phone's recent install attempts.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps/installs \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: installs"
{
  "data": [
    { "package": "org.wikipedia", "upload": null, "install": null, "status": "succeeded", "error": null, "next": null, "started_at": "2026-09-29T10:02:10.518Z", "updated_at": "2026-09-29T10:02:51.064Z" },
    { "package": "com.example.app", "upload": "upl_q3m7x2k6v4ta", "install": "ins_7m2k6q3x4v5a", "status": "succeeded", "error": null, "next": null, "started_at": "2026-09-29T10:03:02.000Z", "updated_at": "2026-09-29T10:03:05.000Z" }
  ]
}
```

| Field                      | Type              | Meaning                                                                                                                                                                                               |
| -------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `package`                  | string            | The package being installed.                                                                                                                                                                          |
| `upload`                   | string or null    | The upload an install by upload put on the phone, or `null` for an app from the library.                                                                                                              |
| `install`                  | string or null    | An install by upload's own ID, `ins_…`, which its start answered with. Find your install's row by it. `null` for an app from the library.                                                             |
| `status`                   | string            | `running`, `succeeded` or `failed`. An install by upload succeeds only once the phone lists its package at the upload's `version_code`.                                                               |
| `error`                    | string or null    | For a failed install, its reason as a machine code, such as `install_failed`. Otherwise `null`.                                                                                                       |
| `next`                     | string or null    | What to do about a failed install by upload. When the phone already had the app, it says a build signed with another key may be installed, and to uninstall it or install again with `replace: true`. |
| `started_at`, `updated_at` | timestamp or null | When the install started and last changed, or `null` when that isn't known.                                                                                                                           |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply.

## Uninstall an app [#uninstall-an-app]

`DELETE /v1/phones/{id}/apps/{package}` needs the `phones:control` scope.

Removes an app with all of its data and sign-ins. `{package}` is the app's package name.

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/apps/org.wikipedia \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: uninstall"
{
  "package": "org.wikipedia",
  "uninstalled": true
}
```

| Error                        | When                                                                                      |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `400 validation_failed`      | `{package}` isn't a valid package name.                                                   |
| `404 app_not_found`          | The app isn't installed. `details.package` names it.                                      |
| `502 action_outcome_unknown` | The phone service didn't confirm the uninstall. List the apps to see whether it happened. |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.


---

# Uploads

Source: https://phonebox.dev/docs/api-reference/uploads

> Upload your own APK, such as the debug build your coding agent just made, so you can install it on any of your phones.



An upload holds one APK of yours. You create it, send the file to the one-off `upload_url` it gives you, and complete it. Phonebox then reads the APK's manifest and stores it, and once its `status` is `ready`, [Install an app](/docs/api-reference/apps#install-an-app) with `{"upload": "upl_…"}` puts it on a phone. The file never passes through `/v1`, so the API's 4 MB body limit doesn't apply.

Uploads belong to the project, and every key of the project can use them, except a key limited to certain phones: it sees and uses only the uploads it created itself, so the customers a shared project serves never see each other's builds.

The CLI and the SDK do all of it in one call: `phonebox apps "$PHONE" install ./app-debug.apk`, or `phone.installApk("./app-debug.apk")`.

| Limit               | Value                                                                                                                                                                                                       |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| File size           | 524,288,000 bytes (500 MiB), sent within 2 minutes                                                                                                                                                          |
| A project's uploads | 524,288,000 bytes (500 MiB) in all, counting uploads that are `processing` or `ready`                                                                                                                       |
| New uploads         | 50 a project each UTC day, for a project with credits that isn't frozen                                                                                                                                     |
| Upload URL          | For one file, sent within an hour                                                                                                                                                                           |
| Kept                | 7 days after the upload's last install, or after its file arrived if it was never installed. Once a newer build of the same package is ready, an older upload is kept only 24 hours after its last install. |
| The same file again | Completing an upload with a file the project already has ready answers with that ready upload, which is kept 7 more days. The new upload becomes a `duplicate` that names it.                               |

## Create an upload [#create-an-upload]

`POST /v1/apps/uploads` needs the `phones:control` scope.

Takes no body, or an empty JSON object. It answers `201 Created` with the upload and where to send its file:

```bash
curl -X POST https://phonebox.dev/v1/apps/uploads \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: new upload"
{
  "object": "upload",
  "id": "upl_q3m7x2k6v4ta",
  "status": "awaiting_file",
  "package": null,
  "version_code": null,
  "version_name": null,
  "min_sdk": null,
  "size_bytes": null,
  "sha256": null,
  "created_at": "2026-09-29T10:00:00.000Z",
  "expires_at": "2026-09-29T11:00:00.000Z",
  "error": null,
  "upload_url": "https://phonebox-files.example/api/storage/upload?token=…",
  "upload_method": "POST",
  "upload_headers": { "Content-Type": "application/vnd.android.package-archive" },
  "max_bytes": 524288000
}
```

| Field            | Type    | Meaning                                                                                             |
| ---------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `upload_url`     | string  | Where the file goes: send it one file, within an hour. It needs no API key, so keep it to yourself. |
| `upload_method`  | string  | `POST`.                                                                                             |
| `upload_headers` | object  | Headers to send with the file.                                                                      |
| `max_bytes`      | integer | The largest file it takes.                                                                          |

The rest is the [upload object](#the-upload-object).

| Error                       | When                                                                                                                                     |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `402 insufficient_credits`  | The project has no credits. Uploads need a funded project.                                                                               |
| `402 project_frozen`        | The project is frozen.                                                                                                                   |
| `409 storage_limit_reached` | The project's uploads already hold 500 MiB. Delete ones you no longer need. `details.limit_bytes` and `details.used_bytes` say how much. |
| `429 rate_limited`          | The project created 50 uploads today (UTC). `Retry-After` and `details.retry_after_seconds` say when it can create more.                 |
| `503 storage_full`          | Phonebox's app storage is full right now. Try again later.                                                                               |

### Send the file [#send-the-file]

Send the APK's bytes as the request body to `upload_url`, with `upload_method` and `upload_headers`. It answers with the file's `storageId`:

```bash
curl -X POST "$UPLOAD_URL" \
  -H "Content-Type: application/vnd.android.package-archive" \
  --data-binary @app/build/outputs/apk/debug/app-debug.apk
```

```json
{ "storageId": "kg28ph47rkkczabaeeqf536a5n8faqa5" }
```

## Complete an upload [#complete-an-upload]

`POST /v1/apps/uploads/{id}/complete` needs the `phones:control` scope.

Tells Phonebox the file has arrived. It answers `202 Accepted` with the upload `processing`: Phonebox reads the APK's manifest and stores the APK, which takes a few seconds, and up to about a minute for a large one. Wait with [Get an upload](#get-an-upload) and `?wait=ready`.

| Field        | Type   | Default | Rules                                                                                                            |
| ------------ | ------ | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `storage_id` | string | None    | The `storageId` that `upload_url` answered with. The answer itself, `{"storageId": "…"}`, is taken as it is too. |

```json title="POST /v1/apps/uploads/{id}/complete"
{
  "storage_id": "kg28ph47rkkczabaeeqf536a5n8faqa5"
}
```

```bash
curl https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta/complete \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"storage_id": "kg28ph47rkkczabaeeqf536a5n8faqa5"}'
```

Since complete takes the upload URL's answer as it is, one pipeline sends the file and completes the upload:

```bash
curl -sS -X POST "$UPLOAD_URL" -H "Content-Type: application/vnd.android.package-archive" \
  --data-binary @app/build/outputs/apk/debug/app-debug.apk |
curl -sS https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta/complete \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" -H "Content-Type: application/json" -d @-
```

A file the project already has ready answers `200` with that ready upload, not this one: install the upload in the answer. This upload becomes a `duplicate` that names it, in its `error.next`, and in `upload_not_ready` if you install it. A file over the size limit ends the upload at once as `too_large`, answered `200`. Completing the same upload again with the same `storage_id` answers with the upload as it is.

| Error                       | When                                                                                                                                                                  |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`     | No file has this `storage_id`, it wasn't sent to this upload's `upload_url`, another upload has it, or this upload already has its file. `details.issues` says which. |
| `404 upload_not_found`      | No upload with this ID exists in the project.                                                                                                                         |
| `409 storage_limit_reached` | This file would take the project's uploads past 500 MiB. The upload keeps its file: delete other uploads, then complete it again.                                     |
| `503 storage_full`          | Phonebox's app storage is full right now. The upload keeps its file for the rest of its hour: complete it again later.                                                |

## Get an upload [#get-an-upload]

`GET /v1/apps/uploads/{id}` needs the `phones:read` scope.

| Parameter | Type    | Default | Rules                                                                                                                                                    |
| --------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wait`    | string  | None    | `ready`: wait while the upload is `processing`. It answers as soon as the upload is `ready` or has ended, and at once for an upload in any other status. |
| `timeout` | integer | `50`    | 1 to 55 seconds of waiting.                                                                                                                              |

```bash
curl "https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta?wait=ready" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: upload"
{
  "object": "upload",
  "id": "upl_q3m7x2k6v4ta",
  "status": "ready",
  "package": "com.example.app",
  "version_code": 42,
  "version_name": "1.4.2",
  "min_sdk": 26,
  "size_bytes": 8388608,
  "sha256": "3b6f0c1f3e0d8b2f2c9a8e1f7d4c5b6a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
  "created_at": "2026-09-29T10:00:00.000Z",
  "expires_at": "2026-10-06T10:00:07.000Z",
  "error": null
}
```

Poll again only while `status` is `processing`.

### The upload object [#the-upload-object]

| Field          | Type            | Meaning                                                                                                                                                                                                                                          |
| -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `object`       | string          | `upload`.                                                                                                                                                                                                                                        |
| `id`           | string          | The upload's ID, `upl_` and 12 characters.                                                                                                                                                                                                       |
| `status`       | string          | `awaiting_file`, `processing`, `ready`, `invalid_apk`, `too_large`, `storage_full`, `failed`, `duplicate` or `deleted`.                                                                                                                          |
| `package`      | string or null  | The package the APK's manifest declares, once it has been read. An install by this upload installs this package.                                                                                                                                 |
| `version_code` | integer or null | The APK's `versionCode`.                                                                                                                                                                                                                         |
| `version_name` | string or null  | The APK's `versionName`.                                                                                                                                                                                                                         |
| `min_sdk`      | integer or null | The lowest Android API level the APK supports, when it says.                                                                                                                                                                                     |
| `size_bytes`   | integer or null | The file's size.                                                                                                                                                                                                                                 |
| `sha256`       | string or null  | The file's SHA-256, in hex.                                                                                                                                                                                                                      |
| `created_at`   | timestamp       | When the upload was created.                                                                                                                                                                                                                     |
| `expires_at`   | timestamp       | When Phonebox deletes the upload: an hour after it was created while it waits for its file; then 7 days after its file arrived, and 7 days after each install; once a newer build of the same package is ready, 24 hours after its last install. |
| `error`        | object or null  | Why the upload ended without becoming ready: `{code, message, next}`.                                                                                                                                                                            |

| Status          | Meaning                                                                                                                                          | Next                                                                              |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `awaiting_file` | Waiting for its file and a complete.                                                                                                             | Send the file, then complete it.                                                  |
| `processing`    | Phonebox is reading and storing it.                                                                                                              | Wait with `?wait=ready`.                                                          |
| `ready`         | It can be installed.                                                                                                                             | Install it with `{"upload": "upl_…"}`.                                            |
| `invalid_apk`   | The file isn't an APK Phonebox can install: `error.next` says why.                                                                               | Build an APK and upload it.                                                       |
| `too_large`     | The file is over 500 MiB. `error.code` is `payload_too_large`.                                                                                   | Upload a smaller build.                                                           |
| `storage_full`  | Phonebox's app storage is full.                                                                                                                  | Try again later with a new upload.                                                |
| `failed`        | Phonebox couldn't store the file. `error.code` is `upload_failed`, or `platform_not_configured` when Phonebox can't store apps at all right now. | Create a new upload and send the file again, later for `platform_not_configured`. |
| `duplicate`     | Its file is another upload of the project, which is ready. `error.code` is `duplicate_upload`, and `error.next` names that upload.               | Install the upload `error.next` names.                                            |
| `deleted`       | Deleted, or expired.                                                                                                                             | Upload the APK again.                                                             |

| Error                   | When                                          |
| ----------------------- | --------------------------------------------- |
| `400 validation_failed` | `wait` or `timeout` is out of range.          |
| `404 upload_not_found`  | No upload with this ID exists in the project. |

## List uploads [#list-uploads]

`GET /v1/apps/uploads` needs the `phones:read` scope.

Lists the project's uploads, newest first, leaving out deleted ones. It takes `limit` (1 to 100, `50` by default) and `cursor`, as every list does.

```bash
curl https://phonebox.dev/v1/apps/uploads \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: uploads"
{
  "data": [
    {
      "object": "upload", "id": "upl_q3m7x2k6v4ta", "status": "ready", "package": "com.example.app", "version_code": 42,
      "version_name": "1.4.2", "min_sdk": 26, "size_bytes": 8388608,
      "sha256": "3b6f0c1f3e0d8b2f2c9a8e1f7d4c5b6a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
      "created_at": "2026-09-29T10:00:00.000Z", "expires_at": "2026-10-06T10:00:07.000Z", "error": null
    }
  ],
  "next_cursor": null
}
```

## Delete an upload [#delete-an-upload]

`DELETE /v1/apps/uploads/{id}` needs the `phones:control` scope.

Deletes the upload and the APK Phonebox stored for it. Apps already installed from it stay on their phones. It answers with the upload, `status: "deleted"`; deleting it again answers the same.

```bash
curl -X DELETE https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

| Error                  | When                                          |
| ---------------------- | --------------------------------------------- |
| `404 upload_not_found` | No upload with this ID exists in the project. |


---

# Files

Source: https://phonebox.dev/docs/api-reference/files

> List, upload, download and delete files in a phone's shared storage, and the rules for their paths.



Files live in the phone's shared storage under `/sdcard` and stay there while the phone is parked. Every file request needs a `ready` phone. [Files](/docs/using-phones/files) covers the same requests from the CLI and the SDK.

## Paths [#paths]

Each request names its file or directory in the `path` query parameter:

* A path is `/sdcard`, or a path inside it. `/storage/emulated/0` is another name for the same place and works the same way.
* It has at most 1024 characters, and it can't contain `.` or `..` segments.
* URL-encode it when it has spaces or other special characters.

A path that breaks these rules fails with `400 validation_failed`, whose `details.issues` names `path`.

## List files [#list-files]

`GET /v1/phones/{id}/files` needs the `phones:read` scope.

Lists one directory.

| Parameter | Type   | Default   | Rules                  |
| --------- | ------ | --------- | ---------------------- |
| `path`    | string | `/sdcard` | The directory to list. |

```bash
curl "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: files"
{
  "path": "/sdcard/Download",
  "data": [
    { "name": "report.pdf", "type": "file", "size": 482133, "modified_at": "2026-09-29T10:09:14.000Z" },
    { "name": "receipts", "type": "directory", "size": 4096, "modified_at": "2026-09-29T10:04:52.000Z" }
  ]
}
```

| Field                | Type              | Meaning                                                |
| -------------------- | ----------------- | ------------------------------------------------------ |
| `path`               | string            | The directory listed.                                  |
| `data[].name`        | string            | The entry's name.                                      |
| `data[].type`        | string            | `file`, `directory` or `other`.                        |
| `data[].size`        | number            | Its size in bytes.                                     |
| `data[].modified_at` | timestamp or null | When it last changed, or `null` when that isn't known. |

| Error                   | When                                                                                                   |
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `400 validation_failed` | The path breaks the rules, or it names a file. For a file, `next` gives the request that downloads it. |
| `404 file_not_found`    | No directory exists at the path. `details.path` repeats it.                                            |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.

## Upload a file [#upload-a-file]

`PUT /v1/phones/{id}/files` needs the `phones:control` scope.

Writes the request body, as raw bytes, to the file that `path` names. The body can be at most 4 MB, which is 4,000,000 bytes.

| Parameter | Type   | Default | Rules                                                       |
| --------- | ------ | ------- | ----------------------------------------------------------- |
| `path`    | string | None    | The file's full path, ending with its name. It is required. |

```bash
curl -X PUT "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @report.pdf
```

Directories on the way that don't exist yet are created. It answers `201 Created`:

```json title="Response: uploaded file"
{
  "path": "/sdcard/Download/report.pdf",
  "size": 482133
}
```

| Error                        | When                                                                                             |
| ---------------------------- | ------------------------------------------------------------------------------------------------ |
| `400 validation_failed`      | The path breaks the rules, or it doesn't end with a file name.                                   |
| `413 payload_too_large`      | The body is larger than 4 MB.                                                                    |
| `502 action_outcome_unknown` | The phone service didn't confirm the upload. List the directory to see whether the file arrived. |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.

## Download a file [#download-a-file]

`GET /v1/phones/{id}/files/content` needs the `phones:read` scope.

Returns the file's bytes as `application/octet-stream`, with its name in the `Content-Disposition` header.

| Parameter | Type   | Default | Rules                                 |
| --------- | ------ | ------- | ------------------------------------- |
| `path`    | string | None    | The file to download. It is required. |

```bash
curl -o report.pdf "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files/content?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

A file can be downloaded when it is at most 4 MB. When its size isn't known in advance, a download that passes 4 MB is cut off, so check that you received the whole file.

| Error                   | When                                                                                                         |
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| `400 validation_failed` | The path breaks the rules, or it names a directory. For a directory, `next` gives the request that lists it. |
| `404 file_not_found`    | No file exists at the path. `details.path` repeats it.                                                       |
| `413 payload_too_large` | The file is larger than 4 MB.                                                                                |

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.

## Delete a file [#delete-a-file]

`DELETE /v1/phones/{id}/files` needs the `phones:control` scope.

Deletes a file. Directories return `400 validation_failed` without deleting any contents. Delete folders in the Files app on the phone instead; the console’s Files tab has an **Open Files** button.

| Parameter | Type   | Default | Rules                                                                                                         |
| --------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------- |
| `path`    | string | None    | The file to delete. It is required; directories and the storage root cannot be deleted through this endpoint. |

```bash
curl -X DELETE "https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/files?path=/sdcard/Download/report.pdf" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: delete file"
{
  "path": "/sdcard/Download/report.pdf",
  "deleted": true
}
```

| Error                        | When                                                                                                                                                                                     |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`      | The path breaks the rules, or it names a directory or the storage root. For directories, `next` explains how to use the phone’s Files app; no contents are deleted.                      |
| `404 file_not_found`         | Nothing exists at the path. `details.path` repeats it.                                                                                                                                   |
| `502 action_outcome_unknown` | The phone service didn't confirm the delete, and a follow-up read couldn't confirm that the path is absent. Follow `next` to list the parent directory before deciding whether to retry. |

After an unconfirmed deletion, Phonebox reads the parent directory once to check the result, without repeating the delete. If the path is confirmed absent, it returns `deleted: true`. A path already absent before the initial delete still returns `404 file_not_found`; a cleanup task can treat this as its desired state already reached.

The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply too.


---

# Device

Source: https://phonebox.dev/docs/api-reference/device

> Read the phone's device info, read and set the clipboard, location, locale and timezone, and reboot or reset a phone.



These requests read or change the phone itself. Each needs a `ready` phone. Reads need the `phones:read` scope, except the clipboard's, which needs `phones:control` like every change; a reset needs `phones:delete`. [Device settings](/docs/using-phones/device) shows them in context.

Some phones don't offer every feature on this page. A request for one the phone doesn't offer fails with `422 feature_unavailable`, and `details.feature` names it, such as `device_info`, `location`, `timezone`, `locale` or `reset`. Nothing was sent to the phone.

Each one that writes is sent to the phone once and never retried. When the phone doesn't confirm it, the request fails with `502 action_outcome_unknown`: read the setting back, or observe, before you send it again. The readiness and phone-service errors in [Common errors](/docs/api-reference#common-errors) apply to every request on this page.

## Get device info [#get-device-info]

`GET /v1/phones/{id}/device` needs the `phones:read` scope.

The phone's brand, model, Android version, screen size in device pixels and mobile carrier. Any of them is `null` when the phone doesn't report it. Nothing else about the phone's hardware or identity is returned.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/device \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: device"
{
  "brand": "google",
  "model": "Pixel 9",
  "android_version": "15",
  "screen": { "width": 1080, "height": 2424, "density": 420 },
  "carrier": "T-Mobile"
}
```

## Read the clipboard [#read-the-clipboard]

`GET /v1/phones/{id}/clipboard` needs the `phones:control` scope.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/clipboard \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: clipboard"
{
  "text": "Order 58213 confirmed"
}
```

| Error                       | When                                                                                                                                                                                                                                                   |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `409 clipboard_unavailable` | 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 [observe](/docs/api-reference/observe#observe-the-screen). Setting the clipboard still works. |

## Set the clipboard [#set-the-clipboard]

`PUT /v1/phones/{id}/clipboard` needs the `phones:control` scope.

| Field  | Type   | Default | Rules                                            |
| ------ | ------ | ------- | ------------------------------------------------ |
| `text` | string | None    | 1 to 10,000 characters. Whitespace is preserved. |

Clearing the clipboard with empty text is not supported. Sending `{"text":""}` returns
`400 validation_failed` before any phone action runs. Do not retry an empty write or restart the phone to clear it.

```json title="PUT /v1/phones/{id}/clipboard"
{
  "text": "https://example.com/reset?code=7F3K9Q"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/clipboard \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "https://example.com/reset?code=7F3K9Q"}'
```

The answer repeats the text. Activity records it only as `[redacted]`.

```json title="Response: clipboard"
{
  "text": "https://example.com/reset?code=7F3K9Q"
}
```

| Error                   | When                                                                            |
| ----------------------- | ------------------------------------------------------------------------------- |
| `400 validation_failed` | `text` is missing, empty or longer than 10,000 characters. No phone action ran. |

## Read the location [#read-the-location]

`GET /v1/phones/{id}/location` needs the `phones:read` scope.

The location the phone reports to apps. Until you set one, it is the phone's default, New York.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/location \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: current location"
{
  "lat": 40.7128,
  "lng": -74.006
}
```

## Set the location [#set-the-location]

`PUT /v1/phones/{id}/location` needs the `phones:control` scope.

Sets the location that the phone reports to apps and, by default, the phone's timezone to the one at that location, so its clock matches the place. The timezone comes from the nearest large place, or, far out at sea, from the longitude; near a border it can be the neighbour's, so check `timezone` in the answer and [set the timezone](#set-the-timezone) yourself if it is wrong.

| Field                    | Type    | Default | Rules                                                                                  |
| ------------------------ | ------- | ------- | -------------------------------------------------------------------------------------- |
| `lat`                    | number  | None    | Latitude in decimal degrees, from -90 to 90.                                           |
| `lng`                    | number  | None    | Longitude in decimal degrees, from -180 to 180.                                        |
| `timezone_from_location` | boolean | `true`  | Also set the timezone found at the location. Send `false` to change only the location. |

```json title="PUT /v1/phones/{id}/location"
{
  "lat": 40.748817,
  "lng": -73.985428
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/location \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"lat": 40.748817, "lng": -73.985428}'
```

The answer names the timezone it set, or `null` when `timezone_from_location` was `false`, or the phone can't change its timezone or refused the one found.

```json title="Response: location"
{
  "lat": 40.748817,
  "lng": -73.985428,
  "timezone": "America/New_York"
}
```

The location and the timezone are two changes, each sent once. When the timezone fails after the location was set, the error has `details.location_set: true` and `details.timezone`, and `next` is the `PUT /v1/phones/{id}/timezone` request that sets it.

| Error                     | When                                       |
| ------------------------- | ------------------------------------------ |
| `400 validation_failed`   | `lat` or `lng` is missing or out of range. |
| `422 feature_unavailable` | The phone can't change its location.       |

## Reset the location [#reset-the-location]

`DELETE /v1/phones/{id}/location` needs the `phones:control` scope.

Goes back to the phone's default location, New York, and, when the phone can change its timezone, to the timezone found there, so its clock matches the place as after [Set the location](#set-the-location). The two changes are each sent once. When the timezone fails after the location was reset, the error has `details.location_reset: true` and `details.timezone`, and `next` is the `PUT /v1/phones/{id}/timezone` request that sets it.

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/location \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: reset location"
{
  "reset": true
}
```

## Read the locale [#read-the-locale]

`GET /v1/phones/{id}/locale` needs the `phones:read` scope.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/locale \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: current locale"
{
  "locale": "en-US"
}
```

## Set the locale [#set-the-locale]

`PUT /v1/phones/{id}/locale` needs the `phones:control` scope.

Changes the phone's language and region. The phone's interface restarts, so observe again before you act, and expect its labels in the new language.

| Field    | Type   | Default | Rules                                                                                                                                                                                                               |
| -------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `locale` | string | None    | A language code of 2 or 3 lowercase letters, then an optional script such as `Hant`, then an optional region: two capital letters or a 3-digit area code. Examples are `en-US`, `de-DE`, `zh-Hant-TW` and `es-419`. |

```json title="PUT /v1/phones/{id}/locale"
{
  "locale": "fr-FR"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/locale \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"locale": "fr-FR"}'
```

```json title="Response: locale"
{
  "locale": "fr-FR"
}
```

| Error                   | When                                            |
| ----------------------- | ----------------------------------------------- |
| `400 validation_failed` | `locale` is missing or doesn't have that shape. |

## Read the timezone [#read-the-timezone]

`GET /v1/phones/{id}/timezone` needs the `phones:read` scope.

The phone's IANA timezone, or `null` while it uses its default.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/timezone \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: current timezone"
{
  "timezone": "Europe/Paris"
}
```

## Set the timezone [#set-the-timezone]

`PUT /v1/phones/{id}/timezone` needs the `phones:control` scope.

| Field      | Type   | Default | Rules                                                                                                                            |
| ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `timezone` | string | None    | An IANA time zone name, spelled exactly, such as `Europe/Paris` or `America/New_York`. Offsets such as `+05:30` aren't accepted. |

```json title="PUT /v1/phones/{id}/timezone"
{
  "timezone": "Europe/Paris"
}
```

```bash
curl -X PUT https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/timezone \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"timezone": "Europe/Paris"}'
```

```json title="Response: timezone"
{
  "timezone": "Europe/Paris"
}
```

| Error                   | When                                             |
| ----------------------- | ------------------------------------------------ |
| `400 validation_failed` | `timezone` is missing or isn't a time zone name. |

## Reboot [#reboot]

`POST /v1/phones/{id}/reboot` needs the `phones:control` scope.

Restarts the phone, keeping everything on it. It takes no body and answers `202 Accepted` with the phone, now `starting`, which has to prove it is ready again, as after a start. Wait with `GET /v1/phones/{id}?wait=ready` before you use it. The session stays open, so the reboot's time is billed like any other.

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/reboot \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: phone"
{
  "id": "ph_7kx2m6q4v3ta",
  "object": "phone",
  "name": "checkout-test",
  "status": "starting",
  "country": null,
  "metadata": { "customer": "acme" },
  "created_at": "2026-09-29T10:00:00.412Z",
  "last_active_at": "2026-09-29T14:26:13.402Z",
  "session": {
    "id": "ses_x7c3v5b2n6m4",
    "started_at": "2026-09-29T14:20:02.118Z",
    "ready_at": "2026-09-29T14:20:41.309Z",
    "idle_timeout": 900,
    "max_duration": 7200,
    "parks_at": "2026-09-29T14:41:13.402Z",
    "park_reason": "idle",
    "reserved_usd": "7.200000"
  },
  "failure": null
}
```

A phone can reboot once every 10 minutes. A second reboot within that time fails with `429 rate_limited`, and both `details.retry_after_seconds` and the `Retry-After` header give the seconds left:

```json title="Response: error"
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Too many requests. Wait a moment and try again.",
    "retryable": true,
    "next": "A phone can reboot once every 10 minutes.",
    "request_id": "req_n6p2r4s7t3v5",
    "details": { "retry_after_seconds": 347 }
  }
}
```

When the phone service refuses a reboot outright, the phone didn't restart, and the 10-minute allowance isn't used up.

| Error                        | When                                                                                                                                                                                 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `429 rate_limited`           | The phone rebooted less than 10 minutes ago.                                                                                                                                         |
| `502 action_outcome_unknown` | The phone service didn't confirm the reboot, so the phone may be restarting. `next` is `GET /v1/phones/{id}?wait=ready`: wait for ready, and never send a second reboot to find out. |

## Reset [#reset]

`POST /v1/phones/{id}/reset` needs the `phones:delete` scope, which only Admin keys have.

Wipes the phone back to a clean state: its installed apps and their data, its files, its signed-in accounts and its clipboard are deleted, and can't be brought back. The phone keeps its ID and its session. It takes no body and answers `202 Accepted` with the phone, now `starting`, and `wiped`, which lists what was deleted. The phone comes back in about a minute: wait with `GET /v1/phones/{id}?wait=ready` before you use it. The session stays open, so the reset's time is billed like any other.

The reset is sent once and never retried. A second reset while the phone is still starting fails with `409 phone_starting`, and one within a minute of the last fails with `429 rate_limited`.

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/reset \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: reset"
{
  "phone": {
    "id": "ph_7kx2m6q4v3ta",
    "object": "phone",
    "name": "checkout-test",
    "status": "starting",
    "country": null,
    "metadata": { "customer": "acme" },
    "created_at": "2026-09-29T10:00:00.412Z",
    "last_active_at": "2026-09-29T14:26:13.402Z",
    "session": {
      "id": "ses_x7c3v5b2n6m4",
      "started_at": "2026-09-29T14:20:02.118Z",
      "ready_at": "2026-09-29T14:20:41.309Z",
      "idle_timeout": 900,
      "max_duration": 7200,
      "parks_at": "2026-09-29T14:41:13.402Z",
      "park_reason": "idle",
      "reserved_usd": "7.200000"
    },
    "failure": null
  },
  "wiped": ["apps", "files", "accounts", "clipboard"]
}
```

| Error                        | When                                                                                                                                                                              |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `403 insufficient_scope`     | The key doesn't have `phones:delete`. Use an Admin key.                                                                                                                           |
| `422 feature_unavailable`    | The phone can't be reset. Delete it and create a new one instead.                                                                                                                 |
| `502 action_outcome_unknown` | The phone service didn't confirm the reset, so the phone may be resetting. `next` is `GET /v1/phones/{id}?wait=ready`: wait for ready, and never send a second reset to find out. |


---

# Recordings

Source: https://phonebox.dev/docs/api-reference/recordings

> Record a phone's screen, stop the recording, list a phone's recordings, download a video and delete a recording.



A recording captures the phone's screen as a video, so you can watch what an agent did, share a failing run, or make a demo. It captures everything on the screen, passwords and codes included, so treat its video like the phone's own data.

* One recording runs at a time on each phone. Starting and stopping one needs a `ready` phone; listing, downloading and deleting work in any status.
* A project keeps at most 20 recordings. Phonebox deletes each one 7 days after it started, and `expires_at` says when.
* A recording is `recording` until you stop it, then `processing` until its video is ready, then `ready`. One that couldn't be saved is `failed`.
* Starting, stopping and deleting are sent to the phone once and never retried. Recording is included in the phone's running time: it costs nothing extra.
* Some phones can't record. Starting a recording on one fails with `422 feature_unavailable`, with `details.feature` set to `recordings`.

## Start a recording [#start-a-recording]

`POST /v1/phones/{id}/recordings` needs the `phones:control` scope.

| Field  | Type   | Default | Rules                                                    |
| ------ | ------ | ------- | -------------------------------------------------------- |
| `name` | string | None    | A name to tell the recording apart, up to 80 characters. |

```json title="POST /v1/phones/{id}/recordings"
{
  "name": "checkout flow"
}
```

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/recordings \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "checkout flow"}'
```

It answers `201 Created` with the recording.

```json title="Response: recording"
{
  "object": "recording",
  "id": "rec_q3m7x2k6v4tb",
  "phone": "ph_7kx2m6q4v3ta",
  "name": "checkout flow",
  "status": "recording",
  "started_at": "2026-09-30T10:00:00.000Z",
  "ended_at": null,
  "duration_ms": null,
  "size_bytes": null,
  "expires_at": "2026-10-07T10:00:00.000Z"
}
```

| Error                         | When                                                                                                                                                                                                           |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `409 recording_in_progress`   | Another recording is running on this phone. `details.recording` names it, when Phonebox can tell, and `next` is the request that stops it.                                                                     |
| `409 recording_limit_reached` | The project already keeps 20 recordings, on any of its phones. Delete one, on this phone or another, or wait for one to expire. A deleted phone's recordings count until they expire, 7 days after they start. |
| `422 feature_unavailable`     | The phone can't record.                                                                                                                                                                                        |

## List recordings [#list-recordings]

`GET /v1/phones/{id}/recordings` needs the `phones:read` scope.

The phone's recordings, newest first.

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/recordings \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: recordings"
{
  "data": [
    {
      "object": "recording",
      "id": "rec_q3m7x2k6v4tb",
      "phone": "ph_7kx2m6q4v3ta",
      "name": "checkout flow",
      "status": "ready",
      "started_at": "2026-09-30T10:00:00.000Z",
      "ended_at": "2026-09-30T10:02:14.000Z",
      "duration_ms": 134000,
      "size_bytes": 18350080,
      "expires_at": "2026-10-07T10:00:00.000Z"
    }
  ]
}
```

## Stop a recording [#stop-a-recording]

`POST /v1/phones/{id}/recordings/{rid}/stop` needs the `phones:control` scope.

Stops the recording and answers with it, now `processing` or already `ready`. Stopping one that has already stopped answers with it as it is, and sends nothing to the phone.

```bash
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/recordings/rec_q3m7x2k6v4tb/stop \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

| Error                     | When                                             |
| ------------------------- | ------------------------------------------------ |
| `404 recording_not_found` | No recording with this ID belongs to this phone. |

## Download a recording [#download-a-recording]

`GET /v1/phones/{id}/recordings/{rid}/video` needs the `phones:read` scope.

The video, as an MP4 file named after the recording's ID. It streams from Phonebox itself.

```bash
curl -o checkout.mp4 https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/recordings/rec_q3m7x2k6v4tb/video \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

| Error                     | When                                                                                                                           |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `404 recording_not_found` | No recording with this ID belongs to this phone, or its video is gone.                                                         |
| `409 recording_not_ready` | The recording is still running, or its video is still processing. Stop it first, then list the recordings until it is `ready`. |

## Delete a recording [#delete-a-recording]

`DELETE /v1/phones/{id}/recordings/{rid}` needs the `phones:control` scope.

Deletes the recording and its video. This can't be undone. A recording that is still running is stopped first.

```bash
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/recordings/rec_q3m7x2k6v4tb \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: delete recording"
{
  "id": "rec_q3m7x2k6v4tb",
  "deleted": true
}
```

| Error                       | When                                                                                                                             |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `404 recording_not_found`   | No recording with this ID belongs to this phone.                                                                                 |
| `409 recording_in_progress` | The recording is still running and the phone isn't, so it can't be stopped. Start the phone, stop the recording, then delete it. |


---

# Live

Source: https://phonebox.dev/docs/api-reference/live

> Create a link that lets a person watch and control a phone in a browser, and how long it lasts.



A live view link opens the phone's screen in a browser, where a person can watch and control it without an account or a key. [Live view and human handoff](/docs/using-phones/live-view) describes the page and how to hand a phone over safely.

## Create a live view link [#create-a-live-view-link]

`POST /v1/phones/{id}/live` needs the `phones:control` scope.

Creates a link for a `ready` phone. The body is optional.

| Field        | Type    | Default | Rules                                                                      |
| ------------ | ------- | ------- | -------------------------------------------------------------------------- |
| `expires_in` | integer | 3600    | Seconds until the link stops working, from 60 to 86400, which is 24 hours. |

```json title="POST /v1/phones/{id}/live"
{
  "expires_in": 600
}
```

```bash
curl https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/live \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expires_in": 600}'
```

It answers `201 Created`:

```json title="Response: live"
{
  "url": "https://phonebox.dev/live/Rk3vQ8bN2xLm5TyW9pZc4HdJ7aGf6sEu1oVi0XqBnKw",
  "expires_at": "2026-09-29T14:40:00.084Z"
}
```

Anyone who has the URL can control the phone, and every account signed in on it, until `expires_at`. Treat the URL like a password and choose the shortest expiry that works. Phonebox stores only a hash of the link and never writes the URL to activity.

| Error                   | When                                 |
| ----------------------- | ------------------------------------ |
| `400 validation_failed` | `expires_in` is outside 60 to 86400. |
| `413 payload_too_large` | The body is larger than 100 KB.      |

The readiness errors in [Common errors](/docs/api-reference#common-errors) apply too.


---

# Account

Source: https://phonebox.dev/docs/api-reference/account

> Read the project's balance, reservations, monthly spending, limits, phone counts, price and the countries phones can be created in, and create a checkout link for credits.



The account belongs to the project that the key selects. [Pricing and credits](/docs/billing/pricing) explains balances, reservations and the spending limit.

## Get the account [#get-the-account]

`GET /v1/account` needs the `phones:read` scope.

It reads the project and touches no phone, so it is a cheap way to check that a key works.

```bash
curl https://phonebox.dev/v1/account \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: account"
{
  "project": "Checkout QA",
  "balance_usd": "21.400000",
  "reserved_usd": "7.200000",
  "spend_limit_usd": "100.000000",
  "spent_this_month_usd": "4.120000",
  "limits": { "phones": 10, "running": 2 },
  "running": 1,
  "phones": 3,
  "price": { "per_minute_usd": "0.060000", "minimum_seconds": 60 },
  "countries": [
    { "code": "AR", "name": "Argentina" },
    { "code": "AU", "name": "Australia" },
    { "code": "US", "name": "United States" }
  ]
}
```

| Field                   | Type    | Meaning                                                                                                                                                                                                                                                   |
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `project`               | string  | The project's name.                                                                                                                                                                                                                                       |
| `balance_usd`           | money   | Your credit, before reservations.                                                                                                                                                                                                                         |
| `reserved_usd`          | money   | The credit held by open sessions. What you can spend on a new session is `balance_usd` minus `reserved_usd`.                                                                                                                                              |
| `spend_limit_usd`       | money   | The monthly spending limit.                                                                                                                                                                                                                               |
| `spent_this_month_usd`  | money   | What sessions that ended this calendar month, in UTC, were charged.                                                                                                                                                                                       |
| `limits.phones`         | integer | The most phones the project can have, not counting deleted or failed ones.                                                                                                                                                                                |
| `limits.running`        | integer | The most phones the project can run at once. The limit counts all of your projects together.                                                                                                                                                              |
| `running`               | integer | Phones with an open session: `creating`, `starting` or `ready`.                                                                                                                                                                                           |
| `phones`                | integer | Phones that count toward the phone limit: every phone that isn't deleted or failed.                                                                                                                                                                       |
| `price.per_minute_usd`  | money   | The price of a phone-minute.                                                                                                                                                                                                                              |
| `price.minimum_seconds` | integer | The shortest time a session is billed for.                                                                                                                                                                                                                |
| `countries`             | array   | The countries a phone can be created in, as `{code, name}`, sorted by name. Pass a `code` as `country` when you [create a phone](/docs/api-reference/phones#create-a-phone). The example shows three of them. A country is where the phone appears to be. |

The balance, reservations, spending, limits and price are always the project's. For a key limited to certain phones, `running` and `phones` count only the phones that key may use.

This endpoint returns no errors of its own. The [common errors](/docs/api-reference#common-errors) apply.

## Create a checkout link [#create-a-checkout-link]

`POST /v1/credits/checkout` needs the `phones:control` scope.

Creates a checkout page where a person buys credits for the key's project. It is the same purchase as on the console's [Billing](/app/billing) page, so an agent or a terminal can ask for credit without sending anyone to the console. The body is optional.

| Field        | Type    | Default | Rules                                                    |
| ------------ | ------- | ------- | -------------------------------------------------------- |
| `amount_usd` | integer | 10      | The credit to buy, in whole US dollars, from 10 to 1000. |

```json title="POST /v1/credits/checkout"
{
  "amount_usd": 25
}
```

```bash
curl https://phonebox.dev/v1/credits/checkout \
  -H "Authorization: Bearer $PHONEBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": 25}'
```

It answers `201 Created`:

```json title="Response: checkout"
{
  "url": "https://checkout.example.com/c/9f2b7c1e",
  "amount_usd": 25
}
```

| Field        | Type    | Meaning                                                                                                                                               |
| ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`        | string  | The checkout page, on our payment provider's site. Give it to a person, who pays there. Tax is added at checkout, and a discount code can be entered. |
| `amount_usd` | integer | The credit the purchase adds, before tax and any discount.                                                                                            |

Nothing is charged until the person pays. The credit is added when the payment is confirmed, not when the page closes, so poll [the account](#get-the-account) until `balance_usd` shows it. A link that nobody pays costs nothing.

| Error                        | When                                                                                                        |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `400 validation_failed`      | `amount_usd` isn't a whole number from 10 to 1000, or the body has another field.                           |
| `403 insufficient_scope`     | The key is Read-only.                                                                                       |
| `429 rate_limited`           | The project created 10 checkout links within the hour. `Retry-After` says when the next one can be created. |
| `503 billing_not_configured` | Payments aren't connected on this deployment.                                                               |

The [common errors](/docs/api-reference#common-errors) apply too.


---

# Terminal setup

Source: https://phonebox.dev/docs/api-reference/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.



Terminal setup gives a terminal or an agent its first API key. The terminal starts a setup request, a person opens a link, signs in and confirms the code they see in their terminal, and the terminal then receives a new key. `phonebox setup` in the CLI runs these requests for you.

These are the only endpoints that take no API key. Everything else in [Conventions](/docs/api-reference) applies: JSON bodies are checked strictly, errors use the [envelope](/docs/api-reference/errors), and a request that carries an `Origin` header fails with `403 browser_requests_not_allowed`.

## Start terminal setup [#start-terminal-setup]

`POST /v1/setup` needs no API key.

Starts a setup request. The body is optional.

| Field    | Type   | Default | Rules                                                                                                                                                                     |
| -------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client` | string | None    | A name for the terminal, from 1 to 60 printable ASCII characters, such as `Claude Code on tals-mbp`. The person sees it when they confirm, and the key is named after it. |

```json title="POST /v1/setup"
{
  "client": "Claude Code on tals-mbp"
}
```

```bash
curl https://phonebox.dev/v1/setup \
  -H "Content-Type: application/json" \
  -d '{"client": "Claude Code on tals-mbp"}'
```

It answers `201 Created`:

```json title="Response: setup"
{
  "setup_token": "pbs_Rk3vQ8bN2xLm5TyW9pZc4HdJ7aGf6sEu1oVi0XqBnKw",
  "user_code": "K7QM-2XHD",
  "verification_url": "https://phonebox.dev/connect?code=K7QM-2XHD",
  "expires_in": 900,
  "interval": 3
}
```

| Field              | Type    | Meaning                                                                                                                                                          |
| ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setup_token`      | string  | The terminal's secret, `pbs_` followed by 43 characters. Only it can collect the key, so never show it or send it anywhere but [the poll](#poll-terminal-setup). |
| `user_code`        | string  | The code the person checks against their terminal. On its own it gives no access.                                                                                |
| `verification_url` | string  | Where the person signs up or signs in and confirms the code. Show it, or open it in their browser.                                                               |
| `expires_in`       | integer | The seconds the person has to confirm: 900, which is 15 minutes.                                                                                                 |
| `interval`         | integer | The seconds to wait between polls: 3.                                                                                                                            |

| Error                   | When                                                                                                |
| ----------------------- | --------------------------------------------------------------------------------------------------- |
| `400 validation_failed` | `client` is empty, longer than 60 characters or not printable ASCII, or the body has another field. |
| `429 rate_limited`      | More than 10 requests in a minute from one IP address.                                              |

## Poll terminal setup [#poll-terminal-setup]

`POST /v1/setup/token` needs no API key.

Asks whether the person has confirmed the request. Send it every `interval` seconds until it answers `approved` or fails.

| Field         | Type   | Rules                                                                              |
| ------------- | ------ | ---------------------------------------------------------------------------------- |
| `setup_token` | string | Required. The `setup_token` that [starting setup](#start-terminal-setup) returned. |

```json title="POST /v1/setup/token"
{
  "setup_token": "pbs_Rk3vQ8bN2xLm5TyW9pZc4HdJ7aGf6sEu1oVi0XqBnKw"
}
```

While the person hasn't answered, it returns `200 OK` with:

```json title="Response: setup status"
{
  "status": "pending"
}
```

Once they have confirmed, it returns the key, exactly once:

```json title="Response: setup status"
{
  "status": "approved",
  "api_key": "pbx_Hq2sT7vYc4Kd9mLx3NbW6pZr8Jf1GaEu5oVi0XqBnRk",
  "project": "My project"
}
```

| Field     | Type   | Meaning                                                                                                             |
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- |
| `status`  | string | `pending` or `approved`.                                                                                            |
| `api_key` | string | With `approved`: the new key. Store it now, because no later request returns it.                                    |
| `project` | string | With `approved`: the name of the project the key belongs to. A new account gets a first project named `My project`. |

The key is an Agent key with no expiry, named after `client`, such as `Claude Code on tals-mbp (terminal setup)`, or `Terminal setup` without one. It is listed on the console's [API keys](/app/keys) page, where it can be revoked. [Authentication and keys](/docs/getting-started/authentication) says what an Agent key can do.

| Error                   | When                                                                            |
| ----------------------- | ------------------------------------------------------------------------------- |
| `400 validation_failed` | `setup_token` is missing or isn't a setup token, or the body has another field. |
| `403 setup_denied`      | The person declined the request in the browser.                                 |
| `404 not_found`         | No setup request has this token.                                                |
| `409 setup_expired`     | The code wasn't confirmed, or the key wasn't collected, within 15 minutes.      |
| `409 setup_used`        | The request already delivered its key.                                          |
| `409 key_limit`         | The project the person chose already has 50 active keys.                        |
| `429 rate_limited`      | More than 60 polls in a minute for one token.                                   |

After `setup_denied`, `setup_expired` or `setup_used`, start a new request. The key is never sent twice, so a poll whose answer was lost ends in `setup_used`: start again, and revoke the lost key in the console.

Next, the account needs credit before it can run a phone: [create a checkout link](/docs/api-reference/account#create-a-checkout-link) with the new key.


---

# Errors

Source: https://phonebox.dev/docs/api-reference/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:

```json title="Response: error"
{
  "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](/docs/api-reference/errors#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 [#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](/docs/api-reference/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](/docs/api-reference#endpoints).                                                                                                                                                                                                                                                                                     |
| `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](/docs/api-reference/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](mailto: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](/docs/troubleshooting/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 [#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](/docs/api-reference/actions#action-errors) lists what each one means for the batch.

## Waiting with Retry-After [#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 [#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 `502` or `504` whose 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](/docs/troubleshooting/unknown-outcomes#when-no-answer-comes-from-phonebox) 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 [#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](/docs/interfaces/mcp#errors) shows both.


---

# Test your Android build with a coding agent

Source: https://phonebox.dev/docs/guides/test-android-builds

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



A coding agent such as Claude Code, Codex or Cursor can change your Android app, put the new build on a Phonebox phone, click through the screens it changed, and tell you what it saw. This guide sets that up with the CLI. It assumes your agent has the [agent skill](/docs/getting-started/coding-agent) and `PHONEBOX_API_KEY` in its environment.

Once there is a phone, getting a new build onto it is one command after the build:

```bash
./gradlew assembleDebug
phonebox apps "$PHONEBOX_PHONE" install app/build/outputs/apk/debug/app-debug.apk
```

## Create a phone for testing [#create-a-phone-for-testing]

Create one phone for this work, and keep reusing it: a parked phone keeps its apps and signed-in accounts, and costs nothing while it waits for the next build.

```bash
echo "android-qa-$(date +%s)" > android-qa.key   # once for this phone; a repeat of the create reads it again
phonebox create --name android-qa --idle 15m --max 2h --idempotency-key "$(cat android-qa.key)" --no-wait | tee android-qa.json
export PHONEBOX_PHONE=$(grep -o 'ph_[a-z2-7]\{12\}' android-qa.json | head -1)   # the new phone's "id"
phonebox status --wait                  # waits until the phone is ready, only reading it; safe to run again
```

* `--no-wait` prints the new phone at once, and `phonebox status --wait` then waits until it is ready. The phone bills from the moment it exists.
* The key and the phone are kept in files, because an agent's shell may not keep variables from one command to the next. In a new shell, run the `export` line again first.
* If `create` fails, run it again with the same key and the same flags: you get the phone the first attempt created, if it created one, and never a second one. If it exits without printing a phone, run `phonebox ls` and use the newest phone named `android-qa` before you create another.
* A phone is gone only when its `status` is `failed` or `deleted`, and then `create` exits with that phone's error: create the next one with a new key. An error that names the phone but is retryable, such as a start whose phone parked again, leaves the same phone to start again.
* `--idle 15m` gives the agent time between steps, and `--max 2h` caps each session. When a build takes longer than the idle timeout, park the phone while it runs and `phonebox start` it afterwards.

## Install your build in one command [#install-your-build-in-one-command]

Build the APK, then install it on the phone:

```bash
./gradlew assembleDebug
phonebox apps "$PHONEBOX_PHONE" install app/build/outputs/apk/debug/app-debug.apk
```

The command uploads the APK, waits while Phonebox reads its manifest, installs it and waits until the phone lists the app at the APK's `versionCode`. Each step is a line on stderr, and the last line on stdout is the install:

```text
{"uploading":"app/build/outputs/apk/debug/app-debug.apk","bytes":8388608}
{"processing":"upl_q3m7x2k6v4ta","package":null,"version_code":null}
{"ready":"upl_q3m7x2k6v4ta","package":"com.example.app","version_code":42}
{"installing":"com.example.app","upload":"upl_q3m7x2k6v4ta","version_code":42}
{"package":"com.example.app","upload":"upl_q3m7x2k6v4ta","install":"ins_7m2k6q3x4v5a","status":"succeeded","error":null,"next":null,"started_at":"2026-09-29T10:00:08.000Z","updated_at":"2026-09-29T10:00:11.000Z"}
```

* **Any signed APK installs**, debug builds included, up to 524,288,000 bytes (500 MiB). An Android App Bundle (`.aab`) or a split APK can't be installed on its own: build an APK. An unsigned release build is refused: sign it first.
* **A build signed with another key, or an older `versionCode`, needs `--replace`.** Android keeps one signing key per app and refuses to downgrade it, so a debug build from another machine, or one whose `versionCode` went back, fails. `--replace` uninstalls the app first, which deletes its data and sign-ins, then installs the new build. Without it, an older build is refused before anything runs (`409 version_downgrade`), and an install that fails over another signing key says so in its `next` step.
* **One upload installs on many phones.** The command prints the upload's ID; `POST /v1/phones/{id}/apps` with `{"upload": "upl_…"}` installs it on another phone without uploading again. `phonebox uploads` lists your uploads, which are deleted 7 days after their last install.
* **Rebuilding is cheap.** Installing the same file again reuses its upload (through the REST API, completing answers with that ready upload; the new one is a `duplicate` that names it), and a newer build of the package retires the older uploads within a day. A project's uploads hold at most 500 MiB, and a project creates at most 50 uploads a day. [Uploads](/docs/api-reference/uploads) has every limit and status.

The SDK does the same with `await phone.installApk("app/build/outputs/apk/debug/app-debug.apk")`. Through the REST API, create an upload, send the file to its `upload_url` with `curl`, complete it, and install it: [Uploads](/docs/api-reference/uploads) shows each request. Over MCP, the `create_app_upload`, `complete_app_upload` and `install_app` tools do the same, as [MCP](/docs/interfaces/mcp#install-your-own-build) shows.

## Check the version before you test [#check-the-version-before-you-test]

The install already waits until the phone lists your package at the build's `versionCode`. When the build arrives another way, such as from Google Play, the agent checks it before it walks a single screen. `phonebox apps` lists the installed apps with their `version_code`:

```bash
phonebox apps
```

The agent finds your package in the list and compares its `version_code` with the build you made. When the phone still has an older one, the new build hasn't arrived yet, and the agent never tests the old build in its place.

## Walk the new screens and report [#walk-the-new-screens-and-report]

With the right build installed, the agent opens the app and works through the screens you changed, looking after every action and saving screenshots as evidence:

```bash
phonebox open com.example.app
phonebox wait --text "Welcome" --timeout 20s
phonebox look
phonebox tap --text "Settings"
phonebox look
phonebox screenshot settings.jpg
phonebox tap --text "Notifications"
phonebox look
phonebox screenshot notifications.jpg
phonebox park
```

A prompt like this gives your agent the whole task:

```text
Build a debug APK of the app with the new notification settings screen, and install it on my Phonebox
phone ($PHONEBOX_PHONE) with `phonebox apps "$PHONEBOX_PHONE" install <the APK>`. If the install says
another signing key or an older version is on the phone, run it again with --replace. Open Settings >
Notifications, try each toggle, and look after every tap. Save a screenshot of each screen you visit.
Then park the phone and report what you saw, with the screenshots, and anything that looked wrong.
```

To test the build your testers get from Google Play instead, name that route in the prompt.

The agent reports from what `phonebox look` and the screenshots showed, and the version check makes sure that it tested your new build. Ask it to park the phone when it's done: an idle phone parks itself about its `idle_timeout` after the last request, but the minutes until then are billed.

## Another route: Google Play [#another-route-google-play]

To test a build the way your testers get it, the phone installs it from the Play Store, signed in as a tester. It takes more setup than the one-command install, and `phonebox apps install PACKAGE` doesn't reach Google Play: the agent drives the Play Store app on the phone.

### Set up once [#set-up-once]

1. **Choose a tester account.** Use a Google account that you keep for testing. Add it to the testers of your internal or closed testing track, and accept the testing invitation with it. For debug builds, also let it download from Internal app sharing, in that page's settings in Play Console.
2. **Sign the phone into that account.** Google's sign-in asks for a password and often a second step, so hand this part to a person. Open the Play Store with `phonebox open com.android.vending`, then create a live view link with `phonebox live --expires 15m` and open it yourself to sign in. [Sign in to apps and hand off to a human](/docs/guides/sign-in-and-handoff) walks through a handoff. The phone stays signed in while it is parked, so you do this only once.
3. **For debug builds, turn on Internal app sharing** in the phone's Play Store, in the same live view. Google's Play Console help describes where it is: in the Play Store's settings, tapping the Play Store version seven times shows developer options, which hold the switch.

### Publish each build [#publish-each-build]

* **A release build goes to a testing track.** Upload it to your internal or closed testing track in Play Console, or with the publishing tool your project already uses. A testing track takes only release builds: Play Console refuses a debuggable build on every track. A new build can take a while to reach testers after you publish it.
* **A debug build goes through Internal app sharing.** Upload the APK or app bundle on Play Console's Internal app sharing page, which takes debug builds. It gives you a link to that build.

### Install it from the Play Store [#install-it-from-the-play-store]

The agent installs or updates the app as a tester would, driving the Play Store's screens with `look` and `tap`. For a testing track, it opens the app's page in the Play Store:

```bash
phonebox open "market://details?id=com.example.app"
phonebox look
phonebox tap --text "Update"
phonebox wait --text "Open" --timeout 30s
```

For Internal app sharing, it opens the build's link instead, with `phonebox open` and the link, and then looks and taps in the same way. The first install of the app shows Install instead of Update. A download can take longer than one `wait`, so when `wait` times out, the agent looks, and waits again while the Play Store still shows the download moving. When the download stops moving, or an error appears, the agent parks the phone and reports to you.

## Clean up between builds [#clean-up-between-builds]

* The phone keeps the installed app while it is parked, and `phonebox start` resumes it for the next build.
* `phonebox apps rm com.example.app` uninstalls the app, with its data, when you need a clean install.
* `phonebox uploads rm upl_…` deletes an upload you no longer need; otherwise it is deleted 7 days after its last install.


---

# Build a multi-tenant product

Source: https://phonebox.dev/docs/guides/multi-tenant

> Give each of your end users their own phone, tag phones with metadata, limit keys to one customer's phones, and park phones between uses.



When your product runs agents on phones for your own customers, give each end user their own phone. Their apps, files and signed-in accounts then stay apart, and one customer's agent never acts on a screen another one is using. This guide shows how to organize the phones, find them again, and keep them from costing money while nobody uses them.

## One phone for each end user [#one-phone-for-each-end-user]

A phone is a persistent Android device, and what an end user sets up on it stays there: the apps you installed for them, the accounts they signed in to, the files they left. Treat the phone as theirs.

* Create a user's phone the first time they need one, and keep using it.
* Park it when their task ends. A parked phone keeps everything and costs nothing.
* Run one agent on a phone at a time. Phonebox doesn't lock phones, so two agents on one phone act on screens they didn't read.

## Tag phones with metadata [#tag-phones-with-metadata]

Give each phone up to 20 metadata pairs of your own, such as the customer's ID in your system, when you create it:

```json title="POST /v1/phones"
{
  "name": "customer-acme",
  "metadata": { "customer": "acme", "plan": "pro" }
}
```

Find a customer's phones with the metadata filter, one pair at a time:

```bash
curl -g "https://phonebox.dev/v1/phones?metadata[customer]=acme" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

`PATCH /v1/phones/{id}` changes a phone's metadata later, and replaces all of it, so send every pair you want to keep. Metadata keys have up to 40 characters and values up to 256. Use your own IDs as values, not names or email addresses: Phonebox keeps metadata values out of activity records and analytics, but every key that can see the phone can read them.

## Find or create a customer's phone [#find-or-create-a-customers-phone]

This TypeScript function returns a customer's phone, ready to use. It finds the phone by metadata, creates it on first use, and starts it when it is parked:

```ts
import { Phonebox } from "phonebox";

const pb = new Phonebox(); // reads PHONEBOX_API_KEY

export async function phoneFor(customer: string) {
  // The list leaves out failed and deleted phones, which can't be used again.
  let [phone] = (await pb.phones.list({ metadata: { customer } })).data;
  if (!phone) {
    // The same key for every attempt at this customer's next phone. It changes only once that phone is gone,
    // failed or deleted, because the old key would only return the gone phone for 24 hours.
    const failed = await pb.phones.list({ metadata: { customer }, status: "failed", limit: 100 });
    const deleted = await pb.phones.list({ metadata: { customer }, status: "deleted", limit: 100 });
    const idempotencyKey = `customer-${customer}-phone-${failed.data.length + deleted.data.length + 1}`;
    phone = await pb.phones.create({ name: `customer-${customer}`, metadata: { customer }, idempotencyKey });
  }
  if (phone.status === "parking") await phone.waitUntil("parked");
  // A phone on its way is waited on by reading it; start() would renew its session.
  if (phone.status === "creating" || phone.status === "starting") await phone.waitUntil("ready");
  if (phone.status !== "ready") await phone.start();
  return phone;
}
```

* The list leaves out failed and deleted phones, so two more lists, filtered to `failed` and to `deleted`, count the customer's gone phones for the key. A failed phone doesn't count toward your phone limit, so it can stay.
* `create()` waits until the phone is ready, for up to 10 minutes, and returns the phone even when that wait is cut short, so the function checks the status afterwards. Only an error that names the phone in `details.phone` throws a `PhoneboxError`: the phone's own failure, or someone else parking or deleting it while `create()` waits.
* A phone that is still `creating` or `starting` is waited on with `waitUntil("ready")`, which only reads it. `start()` is only for a phone that isn't running: on a running phone it would renew the session. It waits for up to 5 minutes, and throws a `PhoneboxError` when the phone fails to start, or with the code `wait_timeout` when time runs out.

The idempotency key comes from the customer and the number of their phones that are gone, failed or deleted, so it stays the same for every attempt at the same phone and changes once that phone is gone. A key that stayed after a deletion would only return the deleted phone, and the customer would get no phone for 24 hours. Without a key, a create whose reply was lost can't be told apart from one that failed, and a retry can create, and bill, a second phone. With this one, a retry of `phoneFor` after a create that timed out either finds the new phone in the list or sends the same key and gets that phone back. Keys are remembered for 24 hours. They allow only letters, digits, `_`, `.`, `:` and `-`, so build them from IDs of yours that use only those.

Two workers that call `phoneFor` for the same customer at the same moment send the same key and get one phone between them, because Phonebox checks the key and creates the phone in one step. They then share that phone, and Phonebox doesn't lock a phone, so run one customer's work at a time, for example through a queue for each customer.

## Limit keys to a customer's phones [#limit-keys-to-a-customers-phones]

A key can be limited to between 1 and 20 phones when you create it in the console. Such a key:

* sees only those phones: in lists, where it gets all of them on one page, and in the account's phone counts;
* can use only those phones, and any other phone answers `403 phone_not_allowed`;
* can never create phones, whatever its preset;
* sees and uses only the [uploads](/docs/api-reference/uploads) it created, so one customer's builds never reach another's worker. The project's other keys see every upload.

Give a worker that acts for one customer a key limited to that customer's phones, and keep the key that creates phones on a server you trust. A key's phones are fixed when you create the key, and each phone must exist by then. When a customer gets a new phone, create a new key for them and revoke the old one. Keys are created only in the console, never through the API, so plan for a person to create them.

## Park between uses [#park-between-uses]

Parking is what keeps a multi-tenant product affordable:

* Park a customer's phone as soon as their task ends, in a `finally` block, so an error doesn't leave it running.
* Set an `idle_timeout` that suits your tasks as a backstop. A phone parks itself that long after the last request that addressed it, or up to about 15 seconds sooner.
* When the customer comes back, `start()` resumes the phone with everything they left on it. A resume takes a while, so start the phone as soon as you know the customer needs it.

[Keep costs down](/docs/guides/keep-costs-down) has more on timers and spending limits.

## Limits for many customers [#limits-for-many-customers]

A project holds 10 phones by default, not counting deleted or failed ones, and runs 2 at once. With one phone for each customer, that is 10 customers and 2 active at the same time. The running limit counts all of your projects together, so a second project doesn't add to it. Email [team@phonebox.dev](mailto:team@phonebox.dev) with your project ID when you need more, and see [Limits](/docs/billing/limits) for the rest.

## Costs for each customer [#costs-for-each-customer]

Every session belongs to one phone, so a customer's cost is the sum of their phones' sessions. `GET /v1/phones/{id}/sessions` lists each session with its `cost_usd`, and the metadata filter finds the customer's phones. Sessions stay listed after a phone is deleted.


---

# Keep costs down

Source: https://phonebox.dev/docs/guides/keep-costs-down

> Park phones explicitly, keep idle timeouts short, cap sessions, mind the one-minute minimum, send heartbeats only while someone watches, and set a spending limit.



You pay only while a phone is `creating`, `starting` or `ready`, at $0.001 a second, so keeping costs down means keeping that time short. A parked phone costs nothing and keeps everything on it, so parking is never a loss. [Pricing and credits](/docs/billing/pricing) has the details.

## Park as soon as the work is done [#park-as-soon-as-the-work-is-done]

Billing stops the moment you ask to park, so park explicitly instead of waiting for the idle timer. Do it even when the task failed, for example in a `finally` block:

```ts
const night = new Date().toISOString().slice(0, 10); // one phone a night: a rerun the same night gets it back
const phone = await pb.phones.create({ name: "nightly-check", idempotencyKey: `nightly-check-${night}` });
try {
  if (phone.status !== "ready") await phone.waitUntil("ready"); // create() returns the phone even if its wait is cut short
  await runChecks(phone);
} finally {
  await phone.park();
}
```

From the CLI, end every session with `phonebox park`. Over MCP, call `park_phone`. The agent skill tells coding agents to do this.

## Use short idle timeouts [#use-short-idle-timeouts]

A phone parks itself its `idle_timeout` after its `last_active_at`, the last time a request addressed it: 300 seconds by default, and anything from 60 to 3600. `last_active_at` moves at most once every 15 seconds, so the park can come up to about 15 seconds sooner than `idle_timeout` after your last request. Every one of those seconds after the last request is billed, so an agent that forgets to park costs up to the whole timeout.

Choose the shortest timeout that covers the longest pause in your agent's work, plus about 15 seconds for that margin. An agent that acts every few seconds does well with 120 seconds. For a long pause, such as waiting for a build, park the phone and start it again when the build is done: parking stops billing and keeps everything on the phone. Or raise `idle_timeout` for that phone, when you create it or with a [start](/docs/api-reference/phones#start-a-phone), which gives a running phone's session the timers you send.

```json title="POST /v1/phones"
{
  "name": "form-filler",
  "idle_timeout": 120,
  "max_duration": 900
}
```

## Cap each session with max\_duration [#cap-each-session-with-max_duration]

`max_duration` parks a phone however busy it is, at 900 seconds by default and at most 10800. It is your ceiling for one session: a loop that never stops can't run past it. It also sets the reservation, so a shorter `max_duration` holds less credit and lets more phones start from the same balance. When a long task needs more time, [start the phone again](/docs/api-reference/phones#start-a-phone) to renew it.

## Mind the one-minute minimum [#mind-the-one-minute-minimum]

Every session is billed for at least 60 seconds, and from the moment its create or start is admitted, so the time the phone takes to become ready is billed too. In our measurements, a create took 38 to 63 seconds to become ready, and a resume 40 to 103. So ten tasks of ten seconds each, each in its own resumed session, cost from $0.60 to $1.13: every session bills its start-up and at least the one-minute minimum. In one shared session, they cost one start-up and 100 seconds, $0.14 to $0.20. When tasks come in quick succession, keep the phone running between them and park it after the last one, or let a short idle timeout park it.

## Send heartbeats only while someone watches [#send-heartbeats-only-while-someone-watches]

`POST /v1/phones/{id}/heartbeat` restarts the idle timer. Send heartbeats only while a person is actually watching the phone, for example in your own interface, and stop when they leave, so that the idle timer can park the phone. A heartbeat loop that runs unattended keeps the phone billing until its `max_duration`.

The console's phone viewer sends heartbeats while it is open and visible, and an open live view page keeps the phone awake too. Close them when you're done.

## Set a monthly spending limit [#set-a-monthly-spending-limit]

Each project has a monthly spending limit, $100 by default, which you change in the console's Settings. A new session that would take the month past it is refused with `402 spend_limit_reached`, and sessions already running finish as they would. Set it to what you expect to spend, so that a runaway agent stops at the limit instead of at your balance. A limit of $0 stops every new session.

## Watch what you spend [#watch-what-you-spend]

* `GET /v1/account` shows your balance, what running phones have reserved, and what you spent this month.
* `GET /v1/phones/{id}/sessions` shows what each session of a phone cost.
* The console's [Usage](/app/usage) page shows your usage by day.

## Failures cost nothing [#failures-cost-nothing]

A phone that fails before it is ever ready costs nothing, so a failed create doesn't need a refund. A retried create does cost money if it creates a second phone, so send the same `Idempotency-Key` when you retry one. Only after its phone has failed, or was deleted, do you create the next one with a new key.


---

# Sign in to apps and hand off to a human

Source: https://phonebox.dev/docs/guides/sign-in-and-handoff

> Drive a sign-in with actions, hand the phone to a person with a live view link when they must enter a code or pass a check, wait for them, and park the phone signed in.



Many apps have to be signed in to before your agent can do anything useful in them. Your agent can drive most of a sign-in itself. When a step needs the person who owns the account, such as a code sent to their own phone or a check that only a person should pass, your agent hands them the phone through a live view link, waits for them to finish, and carries on. The account then stays signed in, parked or not.

Sign in only to accounts that your user owns or may use, and only as the app's own terms allow.

## 1. Drive the sign-in [#1-drive-the-sign-in]

Open the app, wait for its sign-in screen, and fill in what your agent knows. This batch taps the email field, types the address and submits it, then waits for the next screen:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "open_app", "package": "com.example.app" },
    { "type": "wait_for", "target": { "text": "Sign in" }, "timeout_ms": 15000 },
    { "type": "tap", "target": { "text": "Sign in" } },
    { "type": "wait_for", "target": { "id": "email" }, "timeout_ms": 10000 },
    { "type": "tap", "target": { "id": "email" } },
    { "type": "type", "text": "alex@example.com", "submit": true },
    { "type": "wait_for", "target": { "text": "Enter the code" }, "timeout_ms": 20000 }
  ],
  "observe": "ui"
}
```

Look at the result's observation before the next step, since sign-in screens change from one attempt to the next: a consent screen, a cookie banner or an "is this you?" prompt can appear in between. Phonebox never stores the text your agent types. Activity records it only as `[redacted]`. Even so, when your agent shouldn't know a password at all, leave that field to the person too.

## 2. Hand the phone to a person [#2-hand-the-phone-to-a-person]

When the screen asks for something only your user can give, such as a code sent to their phone or email, create a live view link and send it to them:

```json title="POST /v1/phones/{id}/live"
{
  "expires_in": 900
}
```

```json title="Response: live"
{
  "url": "https://phonebox.dev/live/Pz7mK2vQ9xR4tW8nB3cL6hJ5gF1dS0aYeUoIiTqNrVb",
  "expires_at": "2026-09-29T16:20:00.512Z"
}
```

From the CLI, `phonebox live --expires 15m` does the same, and over MCP the tool is `live_view_url`. Tell your user what to do in words, such as "Enter the code we sent you, then tap Verify", along with the link.

The link lets anyone who opens it control the phone, and every account signed in on it, until it expires. Send it only to the person who needs it, over a private channel, and give it the shortest expiry that works. [Live view and human handoff](/docs/using-phones/live-view) describes what the person sees.

## 3. Wait for them to finish [#3-wait-for-them-to-finish]

Your agent waits for the screen that comes after the person's step, with a `wait_for` of up to 30 seconds at a time:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "wait_for", "target": { "text": "Inbox" }, "timeout_ms": 30000 }
  ],
  "observe": "ui"
}
```

`wait_timeout` means the person isn't done yet, so your agent sends the same request again, for as long as the link lasts. When the link expires and the person still hasn't finished, your agent stops waiting, parks the phone and tells your user. A `wait_for` only reads the screen, so repeating it is always safe. Each request counts as activity, so the phone doesn't park for being idle while your agent waits, and an open live view page keeps it awake too. The session's `max_duration` still applies, so start the phone again to renew it if a handoff runs long.

When the expected screen appears, your agent observes and carries on. If a different screen appears, such as an error or another check, it observes and decides again, and hands the phone back to the person when it needs them once more.

## 4. Park the phone [#4-park-the-phone]

Park the phone when the task is done. A parked phone keeps its apps and signed-in accounts, so the next session starts signed in, and the person doesn't have to repeat the sign-in.

```bash
phonebox park
```

Some apps sign out after a while of their own accord, or ask for a code again on a new session. When your agent finds the sign-in screen on a later start, it goes through the same steps.

## Checks that keep coming back [#checks-that-keep-coming-back]

If an app keeps asking for a check, don't try to get around it. Circumventing the security measures of other services breaks Phonebox's [terms](/docs/terms), and apps treat such attempts as abuse. Hand the phone to the person, and if the checks continue, slow your agent down or ask the app's owner for another way in, such as an API.


---

# Pricing and credits

Source: https://phonebox.dev/docs/billing/pricing

> $0.06 per phone-minute, billed per second with a one-minute minimum, paid from prepaid credits, with reservations and a monthly spending limit.



Phonebox has one price for every phone: **$0.06 per phone-minute**, which is $0.001 a second or $3.60 an hour. There is no subscription and no seat fee.

## What you pay for [#what-you-pay-for]

* A phone is billed while it is `creating`, `starting` or `ready`, per second, with a minimum of 60 seconds for each session.
* A parked phone costs nothing, and it keeps its apps, files and signed-in accounts.
* The clock starts when Phonebox accepts your create or start. It stops when you ask to park the phone, when Phonebox decides to park it, or when the phone becomes unavailable or fails. The time the device takes to stop after that isn't billed.
* A session that ends before the phone was ever ready costs nothing, unless you parked or deleted the phone yourself.

A session's charge is its seconds from start to end, rounded up, at least 60 and never more than it reserved, times $0.001. For example:

| Session                                     | Charge                        |
| ------------------------------------------- | ----------------------------- |
| Parked after 131 seconds                    | $0.131                        |
| Parked after 20 seconds                     | $0.06, the one-minute minimum |
| Idle-parked 5 minutes after a 5-minute task | $0.60                         |
| A new phone that failed before it was ready | $0.00                         |

The third example shows why parking matters: with the default `idle_timeout` of 300 seconds, a phone left alone after its work keeps billing until it parks itself. [Keep costs down](/docs/guides/keep-costs-down) collects the ways to avoid that.

## Credits [#credits]

Every new account receives $2 of starter credit automatically in its first project. No card is required. This buys about 33 total phone-minutes at the current rate. A new project in the same account does not receive another starter grant. Paid top-ups are on the [Billing](/app/billing) page.

* A purchase is from $10 to $1,000, and $10 is suggested.
* Credits don't expire.
* The starter grant is one-time. There is no subscription and no automatic top-up.
* Our payment provider runs the checkout and handles taxes and receipts. Credits appear once it confirms the payment, not when the checkout page closes.
* A refund removes the matching credits. If the remaining balance can't cover the reservations of running phones, the project is frozen and its phones are parked.

## Reservations [#reservations]

When a session starts, Phonebox reserves credit for its whole `max_duration`, at $0.001 a second: $0.90 for the default 15 minutes. The session's `reserved_usd` shows the amount, and the account's `reserved_usd` shows the total held by open sessions.

* With `max_duration` omitted, create and start automatically shorten the default to fit unreserved credit and the monthly spending limit. The returned session states the actual duration. If you request an explicit duration, a create or start fails with `402 insufficient_credits` when your balance minus what is already reserved can't cover the reservation. `details.max_affordable_seconds` is the longest `max_duration` you can afford at that moment.
* When the session ends, you're charged for the seconds it ran, and the rest of the reservation is released.
* Renewing a running phone with a start reserves only the extra time.
* Because every session is reserved in advance, your balance never goes below zero, and a running phone never stops for lack of credit.

A lower `max_duration` reserves less, which lets more phones start from the same balance.

## Monthly spending limit [#monthly-spending-limit]

Every project has a monthly spending limit, $100 by default, which you change in the console's Settings. A create or start fails with `402 spend_limit_reached` when this month's charges, plus every open reservation, plus the new reservation, would pass the limit.

* A limit of $0 stops every new session. Phones already running keep their reservations and run until they park.
* Months are calendar months in UTC. `spent_this_month_usd` in `GET /v1/account` shows this month's charges.
* The limit and your credits are separate. Buying credits doesn't raise the limit, and raising the limit doesn't add credits.

## See what you spent [#see-what-you-spent]

* `GET /v1/phones/{id}/sessions` lists each session of a phone with `billed_seconds` and `cost_usd`. See [Sessions](/docs/api-reference/sessions).
* `GET /v1/account` shows your balance, what is reserved, and what you spent this month. See [Account](/docs/api-reference/account).
* The console's [Usage](/app/usage) page shows your usage by day, and its [Billing](/app/billing) page lists every purchase and charge under Credit activity.

## Questions [#questions]

**Does Phonebox give my agent a phone number?** No. Phonebox gives your agent control of an Android device: its screen, apps, touch input and keyboard. It doesn't include a phone number, SMS or calling.

**What counts as running time?** The time from starting a phone until it parks, measured in seconds, with a one-minute minimum per session. Parked phones cost nothing.

**When does a phone park?** When you park it, when its session reaches its maximum length, which is 15 minutes by default and up to 3 hours, or when its idle timeout has passed since the last request that addressed it, about 5 minutes by default.

**Do apps and sign-ins survive parking?** Yes. A parked phone keeps its apps, files and signed-in accounts.

**Can I try it for free?** Every new account gets $2 starter credit, with no card required. Then top up from $10 when you need more. Credits do not expire.

**Where do phones run?** In cloud data centers.

**What happens if I run out of credits?** Phones already running keep the credit they reserved when they started, so they run until they park. An omitted maximum shortens to fit available credit. A new session needs at least $0.06 available for its one-minute minimum.


---

# Limits

Source: https://phonebox.dev/docs/billing/limits

> Every limit on phones, requests, batches, files, links and spending, what happens when you reach one, and how to ask for more.



## Project limits [#project-limits]

Each project has these limits:

| Limit                                                               | Default                                                     | When you reach it                     |
| ------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------- |
| Phones in the project, not counting deleted or failed ones          | 10                                                          | `409 phone_limit_reached`             |
| Phones running at once                                              | 2, which you can lower in Settings                          | `409 running_limit_reached`           |
| Requests                                                            | 600 a minute for each API key                               | `429 rate_limited` with `Retry-After` |
| Lifecycle requests: creates, starts and renewals, parks and deletes | 60 a minute for each project                                | `429 rate_limited` with `Retry-After` |
| Actions in one batch                                                | 20, with at most 60 seconds of waiting in all               | `400 validation_failed`               |
| File uploads and downloads                                          | 4 MB, which is 4,000,000 bytes                              | `413 payload_too_large`               |
| An APK [upload](/docs/api-reference/uploads)                        | 524,288,000 bytes (500 MiB), sent within 2 minutes          | The upload ends as `too_large`        |
| Uploads a project holds, `processing` or `ready`                    | 524,288,000 bytes (500 MiB)                                 | `409 storage_limit_reached`           |
| New uploads                                                         | 50 a day for each project, in UTC days                      | `429 rate_limited` with `Retry-After` |
| Live link lifetime                                                  | 60 seconds to 24 hours, 1 hour by default                   | `400 validation_failed`               |
| Monthly spending                                                    | $100, which you change in Settings. $0 pauses new spending. | `402 spend_limit_reached`             |

* A failed phone holds no device, so it doesn't count toward the phone limit. Neither does a deleted one.
* A phone counts as running while it is `creating`, `starting` or `ready`.
* The running limit counts all of your projects together, so a second project doesn't add to it. To run more phones at once, email [team@phonebox.dev](mailto:team@phonebox.dev).
* Phonebox counts the request limit on each of its servers, so treat 600 as approximate. The lifecycle limit is exact, and a replayed create with the same `Idempotency-Key` doesn't count toward it.
* `GET /v1/account` shows the project's phone and running limits, and how many phones it has and runs.

Across all projects, Phonebox can run only so many phones at once. When none are free, a create or a start fails with `503 capacity_unavailable`. When it is retryable, send the same request again after its `Retry-After`: a refused create made no phone, and a start whose phone parked again starts the same phone again. When a few tries in a row are refused, stop and try again later. When it isn't retryable, a new phone has failed, so its `status` is `failed`: create another one with a new key.

## Other limits [#other-limits]

| What                                     | Limit                                                              |
| ---------------------------------------- | ------------------------------------------------------------------ |
| Projects per account                     | 10                                                                 |
| Active API keys per project              | 50                                                                 |
| Phones a limited key may use             | 1 to 20                                                            |
| `idle_timeout`                           | 60 to 3600 seconds, 300 by default                                 |
| `max_duration`                           | 60 to 10800 seconds, 900 by default                                |
| Phone name                               | 80 characters                                                      |
| Metadata                                 | 20 pairs, with keys of up to 40 characters and values of up to 256 |
| JSON request body                        | 100 KB, or 200 KB for an action batch                              |
| A long poll's `timeout`                  | 55 seconds, 50 by default                                          |
| A list page                              | 100 items, 50 by default                                           |
| Reboots                                  | 1 every 10 minutes for each phone                                  |
| Text in one `type` action                | 5,000 characters                                                   |
| One `wait` action                        | 10 seconds                                                         |
| One `wait_for` action                    | 30 seconds, 10 by default                                          |
| A batch's running time                   | No new action starts after 90 seconds.                             |
| Clipboard text                           | 10,000 characters                                                  |
| Elements in one observation              | 250                                                                |
| A screenshot's `max_width`               | 160 to 2000 pixels                                                 |
| A screenshot, or an observation with one | Under 4 MB                                                         |
| Snapshots, for acting by `ref`           | 15 minutes                                                         |
| An `Idempotency-Key`                     | Remembered for 24 hours                                            |
| Activity records                         | Kept for 30 days                                                   |

## Ask for more [#ask-for-more]

To keep more phones in a project, or to run more at once, email [team@phonebox.dev](mailto:team@phonebox.dev). Include your project ID, which the console's Settings page shows, how many phones you need, and what your agents do with them. The spending limit is yours to change in Settings at any time.


---

# Phone won't start

Source: https://phonebox.dev/docs/troubleshooting/phone-wont-start

> Why a create or start is refused, what a failed phone means, and how long a phone has to become ready.



Start with the error's `code`. A refused create or start reserves nothing and costs nothing, and a phone that fails before it is ever ready costs nothing either.

## capacity\_unavailable [#capacity_unavailable]

`503 capacity_unavailable` means no phones are free right now. It comes in two forms:

* **The request is refused.** No phone was created or started, and nothing was reserved. Send the same request again after `Retry-After`, which is 30 seconds unless it says otherwise. Repeat a create with the same `Idempotency-Key`. When a few tries in a row are refused, stop and tell your user, and try again later.
* **A phone couldn't get a device.** The error names the phone in `details.phone`, or you find the phone with its `failure.code` set to `capacity_unavailable`. A new phone is then `failed`, and the error isn't retryable, so create another one later, with a new idempotency key if you use one. An existing phone parks again with its data, so start it again later.

## insufficient\_credits [#insufficient_credits]

`402 insufficient_credits` means your balance, minus what running phones have reserved, can't cover the new session's reservation. The reservation is the whole `max_duration` at $0.001 a second, so the default hour needs $3.60.

* `details.max_affordable_seconds` is the longest `max_duration` you can afford right now. When it is 60 or more, `next` suggests it.
* Start with a shorter `max_duration`, add credits in [Billing](/app/billing), or park your own running phones that you no longer need to release what they reserved.

## running\_limit\_reached [#running_limit_reached]

`409 running_limit_reached` means the project already runs as many phones as it may, 2 by default. The limit counts all of your projects together. `details.limit` is the limit.

* Park only a phone you created for this task and no longer need. A phone counts as running while it is `creating`, `starting` or `ready`, so list each of those statuses to find yours, such as `GET /v1/phones?status=ready`.
* Never park a phone that another agent or a person may be using: its work stops mid-task, and starting that phone again opens a new billed session. When you have no phone of your own to park, stop and tell your user.
* Check the limit in Settings, where it may have been lowered, and email [team@phonebox.dev](mailto:team@phonebox.dev) if you need a higher limit than Settings offers.

## service\_paused [#service_paused]

`503 service_paused` means Phonebox is paused for maintenance. Creates and starts are refused, and running phones are parked, with the end reason `service_paused`. Your phones and everything on them are kept. Try again later.

## Other refusals [#other-refusals]

| Error                                   | What to do                                                                                                                                                                |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `402 spend_limit_reached`               | The session would take this month's spending past the monthly limit. Raise it in Settings, or start with a shorter `max_duration`.                                        |
| `402 project_frozen`                    | Contact support. A refund that leaves too little credit, or a review of the project's use, freezes a project.                                                             |
| `409 phone_limit_reached`               | Delete only a phone you created and no longer need. In a project that several agents share, tell your user instead, or ask for a higher limit. Failed phones don't count. |
| `409 phone_parking`                     | The phone is still parking. Wait with `GET /v1/phones/{id}?wait=parked`, then start it.                                                                                   |
| `409 phone_failed`, `409 phone_deleted` | The phone can't be started again. Create a new one, with a new idempotency key if you use one.                                                                            |
| `429 rate_limited`                      | The project made 60 creates, starts, parks and deletes this minute. Wait for `Retry-After`.                                                                               |
| `403 insufficient_scope`                | Creating needs `phones:create`, which keys limited to certain phones never have.                                                                                          |

## When a phone fails [#when-a-phone-fails]

A phone whose setup fails, or whose device is lost, becomes `failed`, and its `failure` field says why, with a `code`, a `message` and the time. Common codes are `provider_timeout`, when it didn't become ready in time, `capacity_unavailable`, and `validation_failed`, when no phones are available in the `country` you asked for.

* A session that fails before the phone was ever ready costs nothing, and the reservation is released.
* A failed phone can't be started again. Create a new one.
* A failed phone holds no device and doesn't count toward your phone limit, so you don't need to delete it.

## How long a phone has to become ready [#how-long-a-phone-has-to-become-ready]

A phone is `ready` only after its screen has answered a read and a screenshot has succeeded. A new phone has 10 minutes from its create to get there, and often takes about a minute. When the 10 minutes run out, it fails with `provider_timeout`, at no charge.

A start of a parked phone has 5 minutes. When they run out, the phone parks again with its apps and data, and its `failure` records why. Start it again later.

Waiting that long takes more than one request. Create and start each wait about 50 seconds, and then answer `202 Accepted` with the phone still `creating` or `starting`. Keep polling `GET /v1/phones/{id}?wait=ready&timeout=50` while the phone is `creating` or `starting`, and stop at any other status: `ready` means the phone is yours to use, and the other statuses are explained above. The CLI and the SDK do this for you, for up to 10 minutes for a create and 5 for a start.

## A phone that is unavailable [#a-phone-that-is-unavailable]

`unavailable` means the phone went into maintenance, or its state couldn't be confirmed. Its session was closed, so it isn't billed, and its data is kept. Start it again later. A start that meets maintenance leaves the phone `unavailable` at no charge, and a start request that is still waiting then answers `503 phone_unavailable` with the phone in `details.phone`.


---

# Actions fail

Source: https://phonebox.dev/docs/troubleshooting/actions-fail

> What to do when a target isn't found, matches several elements or has gone stale, when there is nowhere to type, and when a wait runs out.



A batch stops at the first action that fails, and that action's result carries the error. The response then includes the element list, so you can see where the batch stopped, unless reading the screen after the batch failed or ran out of time. Then `observation` may contain only a screenshot, without refs or a `snapshot_id`. If that read also fails, `observation` is missing. Inspect the image or request a screenshot before continuing. Read the code, look at the screen, and change the action before you send it again. The actions before it already ran, so send only the changed action and the ones after it, never the whole batch. [Action errors](/docs/api-reference/actions#action-errors) lists every code.

## validation\_failed [#validation_failed]

An HTTP `400 validation_failed` means no action ran. Read `error.details.issues` and correct the named fields before sending another request. Do not retry the same invalid request in a loop. Each schema issue includes its `path`, `code` and `message`, with expected types or bounds when available. A target with an invalid shape also includes the accepted formats and branch `alternatives`. `details.validation_stage` distinguishes schema validation, JSON syntax, content type and header checks.

Send an object containing `actions`, not a bare array. Coordinates are integers from 0 to 10000, and a coordinate target is `{"x":5000,"y":5000}`, not `{"point":{"x":5000,"y":5000}}`. Check action names against the [actions reference](/docs/api-reference/actions). The SDK and CLI validate batches locally before sending a write; a local validation error has no request ID.

## target\_not\_found [#target_not_found]

No element matches the target. `details.target` repeats it, and `details.candidates` lists up to 10 elements that share a word with it, or else clickable elements with a label.

* **Look at the screen.** The app may be on another screen than you expected, a dialog may cover it, or the keyboard may hide the element. Observe with a screenshot when the element list doesn't explain it.
* **Scroll.** Only elements on the screen are listed. Scroll toward the element and try again.
* **Match the label.** A text target tries an exact match first, then the same ignoring case, then text that contains the target. Use a shorter part of the label, or a candidate's exact text.
* **Target something else.** Try the element's resource ID, `{"id": "sign_in"}`, or its description, `{"desc": "Search"}`, which icons often have instead of text. When the element has no label at all, tap it by coordinates.
* **Check `nth`.** When `nth` is larger than the number of matches, `details.matches` says how many there were.

## ambiguous\_target [#ambiguous_target]

Several elements match the target equally well, such as three buttons that read "Add to cart". `details.candidates` lists them in reading order.

* For a text target, add `nth` to pick one, counting from 1 in reading order: `{"text": "Add to cart", "nth": 2}`. Only text targets take `nth`, so narrow an ID or description target instead, for example with the full resource ID.
* Or target a candidate by its ref: `{"ref": 5, "snapshot_id": "snp_4f2k7m3q6z3a"}`, with the `snapshot_id` from `details.snapshot_id`.
* When `details` names no `snapshot_id`, the screen moved before the batch read it again, so observe before you act by ref.

## stale\_snapshot [#stale_snapshot]

The element behind a ref changed or went away since the observation that gave you the ref. A ref target holds only while the element is still on the screen with the same type, resource ID, text and description, overlapping its old position by at least half. A snapshot also expires after 15 minutes.

Observe again and use the new refs. Always take a ref and its `snapshot_id` from the same observation, and prefer text or ID targets on screens that change on their own, such as lists that load more items.

## no\_focused\_field [#no_focused_field]

A `type` action found no text field with focus, so there was nowhere to type. Tap the field first, in the same batch:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "id": "email" } },
    { "type": "type", "text": "alex@example.com" }
  ],
  "observe": "ui"
}
```

An observation's `keyboard.focused_editable` says whether a text field has focus. Some screens take a moment to focus the field after a tap, so a short `wait` between the tap and the typing can help.

## Waits run out [#waits-run-out]

`wait_timeout` means a `wait_for` didn't see its target appear, or disappear with `gone`, before `timeout_ms`. It is retryable, and `details` repeats the `timeout_ms` and the `target`.

* Observe to see what the screen shows instead. A slow network, a dialog or an error message are common causes. On the first launch, Chrome and other apps may show setup, sign-in or permission screens instead of opening the requested page. Handle the visible screen, using its actual labels and language, before waiting for page content.
* If the screen is still loading, wait again, with a longer `timeout_ms`. One `wait_for` can wait up to 30 seconds, and the waits in one batch up to 60 seconds in all, so wait longer by sending the `wait_for` again in a new request. Wait a few times at most: when the screen still hasn't changed, decide from what it shows, or park the phone and tell your user.
* Wait for what you expect to see, not for a fixed time. A `wait` of a few seconds is right only when nothing on the screen tells you the change is done.

## The action succeeded but nothing changed [#the-action-succeeded-but-nothing-changed]

* **The screen hadn't caught up.** Many actions start an animation or a load. Follow them with a `wait_for` on the next thing you expect before you observe.
* **Another agent uses the phone.** Phonebox doesn't lock a phone, so two agents on one phone act on screens they didn't read. Give each agent its own phone.
* **The app went to the background.** In the first seconds after a phone becomes ready, the phone can still be finishing its own start-up. If the app isn't in front when you observe, open it again.

## The whole request fails [#the-whole-request-fails]

A request that fails with one of Phonebox's errors, in its [error envelope](/docs/api-reference/errors) with an `error.code`, ran no actions at all, except when the error is `500 internal_error`. The usual causes are a phone that isn't `ready`, such as `409 phone_not_running` for a parked phone, and a phone that was briefly out of reach, `503 phone_unavailable`. Both say what to do in `next`, and a batch refused with a retryable error other than `internal_error` is safe to send again. After `500 internal_error`, observe first, because the batch may have run. [When nothing ran](/docs/api-reference/actions#when-nothing-ran) lists these errors.

A failure without that envelope is different: a `502` or `504` that a proxy or the hosting platform sends, a timeout on your side, or a dropped connection. The batch may have run, in part or in full, so observe before you send it again, as [Unknown outcomes](/docs/troubleshooting/unknown-outcomes#when-no-answer-comes-from-phonebox) describes.

When an action's outcome is unknown, its result has `action_outcome_unknown`, and the rules are different: read [Unknown outcomes](/docs/troubleshooting/unknown-outcomes).


---

# The screen reads empty

Source: https://phonebox.dev/docs/troubleshooting/empty-screen

> Why an observation can list few or no elements, how to work from screenshots and coordinates instead, and what truncated means.



An observation lists the elements that Android's accessibility layer reports: views that you can act on or that carry text or a description. Most apps report their buttons, fields and labels this way. When an observation lists few elements, or none, while the screen clearly shows something, one of these is usually the reason.

## The app draws its own screen [#the-app-draws-its-own-screen]

Games, canvas drawings, maps, many video players and some apps built with cross-platform frameworks draw their content themselves. Android then sees one large view with nothing inside it, so the observation lists little or nothing, and text, ID and description targets can't find anything.

Work from a screenshot and coordinates instead:

1. Observe with a screenshot, such as `GET /v1/phones/{id}/observe?screenshot=jpeg&max_width=720`, or `phonebox look --screenshot screen.jpg`.
2. Find what you want on the image, and convert the point to device pixels by dividing its coordinates by `screenshot.scale`. On a 1080-pixel-wide screen at `max_width=720`, the scale is 0.666667, so a point at (360, 800) on the image is (540, 1200) on the phone.
3. Tap, press or swipe at that point:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "tap", "target": { "x": 540, "y": 1200 } },
    { "type": "wait", "ms": 1500 }
  ],
  "observe": "screenshot"
}
```

`"observe": "screenshot"` returns a new screenshot with the results, so you can check the effect of the tap without another request. When the screen couldn't be read, or not in time, the response has no `observation`, so observe before you go on. Since such an app offers nothing to wait for, a short `wait` gives it time to draw.

The [screenshot endpoint](/docs/api-reference/observe#take-a-screenshot) returns the image alone, with the screen's size and the scale in its headers, when you don't need the JSON.

## The app is still loading [#the-app-is-still-loading]

Right after an app opens, it may show a splash screen or a spinner with nothing to act on. Wait for what you expect, instead of observing again and again:

```json title="POST /v1/phones/{id}/actions"
{
  "actions": [
    { "type": "open_app", "package": "com.example.shop" },
    { "type": "wait_for", "target": { "text": "Search products" }, "timeout_ms": 20000 }
  ],
  "observe": "ui"
}
```

The same applies to a slow network inside an app. If the `wait_for` runs out with `wait_timeout`, the returned observation shows what is there instead. When the UI cannot be read, Phonebox tries a screenshot-only observation. If that also fails or cannot finish in time, `observation` is missing and you request a screenshot yourself.

## The observation is truncated [#the-observation-is-truncated]

An observation lists at most 250 elements, in reading order from top to bottom. On a longer screen, `truncated` is `true`, and the text form ends with a line saying that more elements are on screen but not listed.

Text, ID and description targets search the same 250 elements, so an element beyond them can't be found by its label. Scroll until it moves up into the listed part, or scroll inside the list that holds it with a `scroll` action whose `target` is that list.

## Something covers the screen [#something-covers-the-screen]

* **The keyboard.** When `keyboard.visible` is `true`, the keyboard may hide elements at the bottom of the screen. Press `back` once to close it, or scroll.
* **A dialog or a permission prompt.** The observation shows the dialog's elements rather than the screen behind it. Answer the dialog first.
* **The wrong app.** The first line of the text form, `app …`, names the app in front. It reads `app unknown` when Phonebox couldn't tell which app it is. Open the app you meant with `open_app`.

## A web page [#a-web-page]

Browsers usually report a page's links, buttons and fields as elements. A page drawn on a canvas, or a web game, reports nothing, so use screenshots and coordinates as above.


---

# Unknown outcomes

Source: https://phonebox.dev/docs/troubleshooting/unknown-outcomes

> What action_outcome_unknown means, why Phonebox never retries a write for you, and how to observe and decide what to do next.



`action_outcome_unknown` means Phonebox sent a write to the phone, such as a tap, typing or an install, and the phone didn't confirm it. The request may have timed out, or the phone's connection broke off after the write could already have reached it. The action may have happened, partly happened, or not happened at all. Its `retryable` is always `false`.

## Where it appears [#where-it-appears]

* **In an action's result.** The batch stops at that action, and the actions after it don't run. The response includes the element list of the screen, unless reading the screen after the batch failed or ran out of time.
* **As the error of a whole request** that writes to the phone: installing or uninstalling an app, uploading or deleting a file, setting the clipboard, setting or resetting the location, setting the locale or the timezone, and rebooting. These answer `502 action_outcome_unknown`.

A partial action is a related case. A `type` with `submit` and a `double_tap` each take two steps, and when the second step fails after the first reached the phone, the result has `details.partial` set to `true` and `retryable` set to `false`, whatever its code. The first step happened, and the second may or may not have.

## Why nothing is retried for you [#why-nothing-is-retried-for-you]

Phonebox sends every write to the phone once and never repeats it, and the SDK and the CLI don't either. A repeated tap on "Pay" can pay twice, a repeated "Send" can send a message twice, and a repeated reboot restarts a phone that may already be restarting. Only you can tell from the screen whether the first attempt worked.

Reads are different, because repeating them changes nothing: Phonebox retries its own reads of the phone for up to about 15 seconds. And when Phonebox's own error says that nothing ran, such as a retryable `503 phone_unavailable` in its [error envelope](/docs/api-reference/errors), the write never reached the phone, so sending it again is safe.

## Observe, then decide [#observe-then-decide]

1. **Observe.** Read the screen, with a screenshot when the element list might not show the change: `GET /v1/phones/{id}/observe?screenshot=jpeg&max_width=720`. If the phone was rebooting, wait for it with `GET /v1/phones/{id}?wait=ready` first.
2. **Compare.** Work out what the action should have changed. Look for the next screen, the text in the field, the item in the cart, or the message in the conversation.
3. **Decide.**
   * If it happened, carry on with the next step, as if the action had succeeded.
   * If it clearly didn't happen, send it again, once.
   * If you can't tell, don't guess. Check in a way that is safe to repeat, such as a `wait_for` on the text that would confirm it, or ask your user, handing the phone over with a [live view link](/docs/using-phones/live-view) when a person should look.

For a partial action, observe in the same way, and never send the second step blindly. For a `type` with `submit`, send a `key` action for `enter` only when the screen still shows your text in the field and hasn't moved on. For a `double_tap`, check whether the second tap is still needed before you tap again.

## Checks for requests that write [#checks-for-requests-that-write]

| Request                                                  | How to check whether it happened                                                                                                                                               |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Install an app                                           | [List the installs](/docs/api-reference/apps#list-app-installs), or the installed apps.                                                                                        |
| Uninstall an app                                         | [List the apps](/docs/api-reference/apps#list-apps).                                                                                                                           |
| Upload or delete a file                                  | [List the directory](/docs/api-reference/files#list-files).                                                                                                                    |
| Set the clipboard                                        | [Read it back](/docs/api-reference/device#read-the-clipboard).                                                                                                                 |
| Set or reset the location, or set the locale or timezone | These settings can't be read back, but sending the same value again leaves the phone as you meant it. A locale change restarts the interface each time, so observe afterwards. |
| Reboot                                                   | Wait with `GET /v1/phones/{id}?wait=ready`, as the error's `next` says. Never send a second reboot to find out whether the first one worked.                                   |

## When no answer comes from Phonebox [#when-no-answer-comes-from-phonebox]

A timeout or a dropped connection on your side hides the outcome in the same way, even when Phonebox finished the request. So does a `502` or `504` whose body isn't Phonebox's error envelope, which a proxy, a load balancer or the hosting platform sends, for example when a request runs past its time limit. None of these says whether anything ran. Treat them by what the request does:

* **Create.** Send it again with the same `Idempotency-Key` and body. You get the phone the first request created, if it did, and never a second one. If that phone has failed or was deleted, create the next one with a new key. Without a key, list your phones before you create another.
* **Park and heartbeat.** Send them again. They are safe to repeat.
* **Start.** Read the phone first, with `GET /v1/phones/{id}`. A start on a running phone renews its session and reserves credit again, so start it again only while it is still `parked` or `unavailable`. If it is `starting` or `ready`, it is running: wait with `GET /v1/phones/{id}?wait=ready` instead.
* **An action batch.** Observe first, as above. Give batch requests a client timeout of about 300 seconds, so that a long batch isn't cut off on your side.
* **Other writes.** Check whether they happened, with the [checks above](/docs/troubleshooting/unknown-outcomes#checks-for-requests-that-write), before you send them again.
* **Reads.** Send them again.


---

# Terms of Service

Source: https://phonebox.dev/docs/terms

> Accounts, acceptable use, payments and responsibilities.



These documents take effect when Phonebox launches. They are reviewed by the owner before publication.

Phonebox is operated by Steelworks Software LLC, a limited liability company registered in Delaware, United States. These terms cover the Phonebox website, console, API, CLI, SDK and MCP server. If you use Phonebox for an organization, you must have authority to act for it.

## Your account and keys [#your-account-and-keys]

Provide accurate account information, protect your credentials, and revoke any API key you believe is compromised. You are responsible for everything done through your projects, including the actions of the agents, applications and people that use your keys.

## Acceptable use [#acceptable-use]

Use Phonebox only for lawful purposes, and only with apps, accounts and content you are authorized to use. You may not use Phonebox for:

* unlawful activity;
* fraud, spam or deceptive automation;
* creating accounts at scale against a service's terms;
* circumventing the security measures or rate limits of third-party services, for example through credential stuffing or CAPTCHA farms;
* distributing or running malware;
* harassing or abusing anyone;
* content that exploits minors;
* reselling phones as a competing service.

We may suspend phones used in violation of this section, and we may freeze the project they belong to while we look into it.

## Your content and your phones [#your-content-and-your-phones]

You keep your rights in the apps, files and data you put on your phones. You authorize us, and the service providers described in our [Privacy Policy](/privacy), to process that content in order to run your phones and operate Phonebox. You are responsible for having the rights and permissions needed to use the apps and accounts your agents operate, including following those apps' own terms.

A parked phone keeps its apps, files and signed-in accounts. If we ever need to limit how long parked phones are kept, we will give you notice first. Deleting a phone is permanent, so keep your own copies of anything you need.

## Usage and payment [#usage-and-payment]

Phonebox is prepaid. You buy credits in advance, starting at $10, and they don't expire. New accounts receive $2 in one-time starter credit. There is no subscription and no automatic top-up. Usage is billed at the rate on our [pricing page](/pricing), currently $0.06 per phone-minute. It is measured per second while a phone is starting or running, with a one-minute minimum per session. Parked phones cost nothing, and a session that fails before its phone is ever ready is not charged.

Every project has a monthly spending limit that you can change. A new session doesn't start if it would take the project past that limit.

Our payment provider acts as merchant of record and handles checkout, applicable taxes, receipts and refunds under its own checkout terms. Credits can only be used for Phonebox and can't be transferred to another customer. To request a refund or dispute a charge, email us with your project ID and order reference. Nothing in these terms limits refund rights you have under applicable law. A refund removes the matching credits, and if the remaining balance no longer covers the time reserved by running phones, we freeze the project and park its phones.

## Phones and availability [#phones-and-availability]

Phonebox phones are virtual Android devices running in cloud data centers. Some apps behave differently on them than on a handset, and some may not run at all. Your agent decides what to do on a phone, and you are responsible for the actions it takes there.

Capacity is limited, so a request to start a phone can be refused when none is available. We don't offer an uptime commitment unless we agree to one in writing.

## Changes and suspension [#changes-and-suspension]

We may change Phonebox or these terms. Material changes will be published on this page with an updated date, and they won't change charges for usage that has already happened. We may also suspend access to deal with abuse, compromised credentials, payment reversals or a service incident.

## Support and account closure [#support-and-account-closure]

Email [team@phonebox.dev](mailto:team@phonebox.dev) for support, billing questions, account closure or data requests. Include your project ID, and never send passwords, card details or API keys. We may need to confirm your authority before we change an account or share its records. Some financial and security records are kept after an account is closed, as described in our [Privacy Policy](/privacy).


---

# Privacy Policy

Source: https://phonebox.dev/docs/privacy

> What Phonebox collects, why, and how long we keep it.



These documents take effect when Phonebox launches. They are reviewed by the owner before publication.

Phonebox is operated by Steelworks Software LLC, a limited liability company registered in Delaware, United States. This policy explains what we collect when you use the Phonebox website, console and API, why we collect it, and how long we keep it.

## What we collect [#what-we-collect]

* Account details: the email address you sign in with, your name and profile picture when provided, and the projects and API keys you create. We store API keys only in hashed form.
* Billing records: your credit purchases, balance, usage charges and spending limit. Our payment provider collects your card details, and we never receive full card numbers.
* API activity: for each request, its request ID and time, the project, phone and key involved, the kind of call and its parameters, its result and how long it took. The parameters include what your agent tapped (an element's text, description or ID, or a point on the screen), the apps it opened, file paths, a phone's name and requested country, and settings such as its location, language and time zone. In the activity records shown in the console, text your agent types or puts on the clipboard is removed, and a web address it opens keeps only its scheme and host. These activity records never include file contents, screenshots or metadata values.
* Private request diagnostics: to investigate failures, we retain exact authenticated API and MCP request inputs and console and live phone-operation inputs, including typed text, clipboard text, target selectors, URLs, query parameters and malformed request bodies. Binary request inputs are also captured within the size limit. We keep up to 256,000 bytes of each body and mark incomplete captures. We also retain phone-service failure responses, up to 64,000 characters, with their status and trace identifier. For failed action batches, we also retain the results, the accessibility screen tree and compact elements used by the target resolver, and the returned observation without screenshot pixels, up to 256,000 bytes with incomplete captures marked. These records are available only to authorized operators, are not sent to product analytics, and are not exposed through customer APIs or activity views. We do not duplicate authentication headers, cookies or live-link path credentials.
* Screen reads: when your agent reads a phone's screen, we keep the list of elements for 15 minutes so the agent can refer to them.
* Messages you send us: what you enter in the contact form (your name, email address, company and message), and the email address you give us for the changelog by email.
* Product analytics: the pages you visit, clicks, errors, performance measurements and product events, plus session recordings with every form input masked. They are sent through our own `/ingest` proxy on the Phonebox domain, and web addresses are recorded without their query strings. Anonymous visits use a random browser profile. When you sign in, that browsing history is linked to your account. Analytics never include what your agent types, file names or contents, phone screenshots, the text on a phone's screen or metadata values.

## Sign in with Google [#sign-in-with-google]

If you choose to sign in with Google, our authentication provider, Clerk, receives your Google account identifier, verified email address, name and profile picture. Clerk stores this information with your account to create your account, authenticate you and display your profile. We request only basic identity scopes, not access to Gmail, Drive, Calendar or Contacts. We do not sell Google profile data, use it for advertising or use it to train AI models. The account retention and deletion choices below also apply to this information.

## The contents of your phones [#the-contents-of-your-phones]

Your phones hold the apps, files and signed-in accounts your agents put on them. We process that content only to run your phones and operate Phonebox.

## Why we use it [#why-we-use-it]

We use this information to run your phones and your account, bill usage correctly, keep Phonebox secure, prevent abuse, answer your messages, send the changelog you signed up for, and understand how Phonebox is used so we can improve it. We don't sell personal information, and we don't use it for advertising.

## Service providers [#service-providers]

We rely on service providers for cloud hosting and storage, the Android device infrastructure, payments, sign-in, product analytics and email. They process data only as needed to provide their service to us. Our database, which holds your account, billing and API activity records, runs in the European Union. You can't currently choose another region for it. A phone's contents, meaning its apps, files and signed-in accounts, are held where the phone runs. The country you can ask for when you create a phone is where it runs, and that choice doesn't change where our database keeps your records.

## How long we keep it [#how-long-we-keep-it]

* Screen reads: 15 minutes.
* API activity records: 30 days.
* Private request diagnostics, failed-action screen evidence and phone-service failure responses: 90 days.
* Phones and their contents: a parked phone keeps its apps, files and signed-in accounts. If we ever need to limit how long parked phones are kept, we will give you notice first.
* Account, billing and support records: while your account is open, and afterwards for as long as we need them to resolve disputes, keep Phonebox secure and meet legal obligations.
* Contact messages and changelog sign-ups: until you ask us to remove them.

Our providers' backup policies also apply, so deleted data can take some time to disappear from every backup.

## Your choices and rights [#your-choices-and-rights]

To access, correct or delete your information, close your account, or ask how we process data, email [team@phonebox.dev](mailto:team@phonebox.dev). We check that a request comes from the account owner before we share or change any records. Depending on where you live, you may have additional rights under privacy law. When we handle a deletion request, we will tell you about any records we have to keep.

Cookies are described in our [Cookie Policy](/cookies). Material changes to this policy will be published on this page with an updated date.


---

# Cookie Policy

Source: https://phonebox.dev/docs/cookies

> The cookies and browser storage Phonebox uses, and why.



These documents take effect when Phonebox launches. They are reviewed by the owner before publication.

This policy explains the cookies and similar browser storage that the Phonebox website and console use. Phonebox is operated by Steelworks Software LLC, a limited liability company registered in Delaware, United States.

## Essential cookies [#essential-cookies]

When you sign in to the console, our authentication provider sets cookies that keep you signed in and protect your session. The console can't work without them, so they can't be switched off.

## Analytics [#analytics]

We use product analytics to understand how the website and console are used. The analytics script stores a random identifier in a cookie and in your browser's local storage, so it can count visits and tell them apart. The data goes to our analytics provider through our own `/ingest` proxy on the Phonebox domain. Anonymous visits use a random browser profile. When you sign in, that browsing history is linked to your account.

## Preferences [#preferences]

When you close the announcement box on the website, your browser's local storage remembers it for a week so the box stays closed.

## What we don't use [#what-we-dont-use]

We don't use advertising cookies or cross-site tracking.

## Managing cookies [#managing-cookies]

You can block or delete cookies in your browser settings. If you block essential cookies, you won't be able to sign in to the console. Blocking analytics cookies doesn't change how Phonebox works.

For questions about cookies, email [team@phonebox.dev](mailto:team@phonebox.dev). Material changes to this policy will be published on this page with an updated date.
