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 explains the model behind them, and the conventions and common errors apply to every one of them.
The phone object
{
"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 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
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 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. |
{
"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}'Both successful answers carry the phone, with its path in the Location header:
201 Createdmeans the phone is ready.202 Acceptedmeans the phone was created but isn't ready yet. Eitherwaitwasfalse, the wait of about 50 seconds ran out, or the wait itself failed. PollGET /v1/phones/{id}?wait=readywhile the phone iscreatingorstarting. Any other status ends the loop:readymeans you can use the phone, and any other means you read itsstatusandfailure, as Get a phone describes. If your key was revoked in the meantime, the poll answers401 key_revoked.
{
"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
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. |
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.
{
"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 /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. |
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:
failedordeleted: the phone can't be used again, so create a new one, with a new idempotency key if you use one.parkedorunavailable, when you waited forready: the phone isn't running, so start it. Whenfailureshows that a start failed, start it again later rather than at once.parking, when you waited forready: wait with?wait=parked, then start it.readyorstarting, when you waited forparked: someone started the phone again, so park it again if you still mean to. Anunavailablephone isn't billed, so there is nothing to wait for.
{
"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
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. |
{
"metadata": { "customer": "acme", "plan": "trial" }
}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"}}'{
"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 /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.
curl -X DELETE https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta \
-H "Authorization: Bearer $PHONEBOX_API_KEY"{
"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
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.
{
"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}'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.
{
"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
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. |
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.
{
"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
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 to renew it. A phone that is still creating or starting is returned as it is.
curl -X POST https://phonebox.dev/v1/phones/ph_7kx2m6q4v3ta/heartbeat \
-H "Authorization: Bearer $PHONEBOX_API_KEY"{
"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. |