Phonebox / Docs
Using phones

Lifecycle and timers

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

View as Markdown

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 describes every status. This page covers the requests that move a phone between them.

Create a phone

POST /v1/phones
{
  "name": "checkout-test",
  "metadata": { "customer": "acme" },
  "idle_timeout": 300,
  "max_duration": 1800
}
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.

FieldMeaning
nameUp to 80 characters. The default is the phone's ID.
countryWhere 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.
metadataUp 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_timeoutSeconds after the phone's last activity, its last_active_at, at which it parks itself, from 60 to 3600. The default is 300.
max_durationThe 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.
waitWhether 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:

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

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 are never replayed.

Wait for a status

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

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.

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 and the CLI poll for you.

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

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. Actions and targets covers this in detail.

Start or renew a phone

POST /v1/phones/{id}/start
{
  "idle_timeout": 900,
  "max_duration": 7200
}
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.

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:

ErrorWhy
409 phone_parkingThe phone is still parking. Wait with wait=parked, then start it. This error is retryable.
409 running_limit_reachedThe project already runs its maximum number of phones.
402 insufficient_creditsYour balance can't cover the reservation. details.max_affordable_seconds says how long a session you can afford.
402 spend_limit_reachedThe reservation would pass the monthly spending limit.
503 capacity_unavailableNo phones are free right now. Try again after Retry-After.
409 phone_failed or 409 phone_deletedThe 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

POST /v1/phones/{id}/park
{
  "wait": true
}
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.

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

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:

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 explains why.

Rename a phone or change its metadata

PATCH /v1/phones/{id}
{
  "name": "checkout-test-2",
  "metadata": { "customer": "acme", "suite": "nightly" }
}
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:

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

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

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

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

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

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:

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.

On this page