Devices API
Create, list, inspect and stop cloud Android devices; take screenshots, send taps and text, install apps, snapshot, and mint stream tokens.
A device is a cloud Android phone. Read Devices for the lifecycle and billing rules. All examples assume your key is in AKAPHONE_API_KEY.
The device object#
{
"id": "dev_s6zr7mr4f2f1",
"name": "checkout-test",
"status": "running",
"profile": "pixel_8",
"android": "14",
"region": "eu-central",
"node_id": "nod_01m5vqsywg84jv64rmmqf9",
"snapshot_id": null,
"created_at": "2026-10-26T09:14:02.000Z",
"ready_at": "2026-10-26T09:14:09.000Z",
"terminated_at": null,
"last_activity_at": "2026-10-26T09:16:40.000Z",
"billed_minutes": 3,
"error": null
}| Field | Type | Description |
|---|---|---|
id |
string | dev_ and 12 characters. |
name |
string or null | Your label. |
status |
string | queued, creating, running, stopping, terminated, error or lost. |
profile |
string | Screen profile, default pixel_8. |
android |
string | Android version, "14". |
region |
string | Where it runs, eu-central. |
node_id |
string or null | The host it was placed on; null while queued. |
snapshot_id |
string or null | The snapshot it was restored from. |
created_at |
string | When you created it. |
ready_at |
string or null | When it became running. Billing starts here. |
terminated_at |
string or null | When it ended. |
last_activity_at |
string | Last input, task, install or viewer activity. Used for the 30-minute idle stop. |
billed_minutes |
integer | Credits charged for this device so far. |
error |
string or null | Why it failed, e.g. no_capacity. |
List profiles#
/v1/profilesThe device profiles you can pass as profile. No authentication needed; cacheable for 5 minutes.
curl https://akaphone.abhgnx-417.workers.dev/v1/profilesconst profiles = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/profiles").then((r) => r.json());profiles = requests.get("https://akaphone.abhgnx-417.workers.dev/v1/profiles").json()Response 200 OK
[
{ "id": "pixel_8", "name": "Pixel 8", "width": 720, "height": 1560, "dpi": 320, "android": "14" },
{ "id": "pixel_tablet", "name": "Pixel Tablet", "width": 1280, "height": 800, "dpi": 240, "android": "14" },
{ "id": "small_phone", "name": "Small phone", "width": 540, "height": 960, "dpi": 240, "android": "14" }
]width and height are the device pixels that input coordinates use. pixel_8 is the default.
Create a device#
/v1/devicesQueues a new device and returns it. With wait, the request is held until the device is out of queued and creating, for at most 90 seconds.
| Body field | Type | Default | Description |
|---|---|---|---|
name |
string | null |
Up to 64 characters. |
profile |
string | "pixel_8" |
pixel_8, pixel_tablet or small_phone; see list profiles. |
android |
string | "14" |
One or two digits. |
snapshot_id |
string | null |
Boot from this ready snapshot. Same as restore. |
region |
string | first region | Must be an available region; today only eu-central. |
wait |
boolean | false |
Hold the request until the device is running (max 90 s). |
The query parameters ?wait=true&timeout=<seconds> do the same as wait; timeout is capped at 90.
curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "checkout-test", "wait": true}'const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "checkout-test", wait: true }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const device = await res.json();import os, requests
r = requests.post(
"https://akaphone.abhgnx-417.workers.dev/v1/devices",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
json={"name": "checkout-test", "wait": True},
)
r.raise_for_status()
device = r.json()Response 201 Created with the device. Without wait its status is usually queued. With wait it is normally running; if boot failed it is error. If the 90 seconds ran out while the device was still queued or creating, the response carries the header X-Wait-Timed-Out: 1: poll get a device until it is running.
HTTP/1.1 201 Created
Content-Type: application/json
X-Wait-Timed-Out: 1
{"id": "dev_s6zr7mr4f2f1", "status": "creating", "…": "…"}| Error | When |
|---|---|
402 insufficient_credits |
Balance below 1 credit. |
409 concurrency_limit |
You already have your plan's maximum of queued, creating and running devices. |
404 not_found |
snapshot_id does not exist. |
409 conflict |
The snapshot is lost, pending or error. |
400 invalid_request |
A field is invalid, the region is unknown, or the profile is not one of pixel_8, pixel_tablet, small_phone. |
429 rate_limited |
More than 10 device creations in a minute. |
List devices#
/v1/devicesReturns a JSON array of devices, newest first. By default only active devices (queued, creating, running, stopping).
| Query | Description |
|---|---|
status=all |
Include finished devices (the 200 most recent). all=true is accepted too. |
curl "https://akaphone.abhgnx-417.workers.dev/v1/devices?status=all" \
-H "Authorization: Bearer $AKAPHONE_API_KEY"const devices = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices?status=all", {
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
}).then((r) => r.json());devices = requests.get(
"https://akaphone.abhgnx-417.workers.dev/v1/devices",
params={"status": "all"},
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
).json()Response 200 OK
[
{ "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "running", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:16:40.000Z", "billed_minutes": 3, "error": null },
{ "id": "dev_ynxe87v87zx0", "name": null, "status": "terminated", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-25T17:02:11.000Z", "ready_at": "2026-10-25T17:02:19.000Z", "terminated_at": "2026-10-25T17:21:40.000Z", "last_activity_at": "2026-10-25T17:21:40.000Z", "billed_minutes": 20, "error": null }
]Get a device#
/v1/devices/:idcurl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1 \
-H "Authorization: Bearer $AKAPHONE_API_KEY"const device = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", {
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
}).then((r) => r.json());device = requests.get(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
).json()Response 200 OK with the device. 404 not_found if it does not exist or is not yours.
Stop a device#
/v1/devices/:idStops the device and its billing. Idempotent: stopping a device that has already ended returns it unchanged.
curl -X DELETE https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1 \
-H "Authorization: Bearer $AKAPHONE_API_KEY"await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
});requests.delete(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
)Response 202 Accepted with the device: stopping if it was creating or running (it becomes terminated shortly after), or terminated if it was still queued.
{ "id": "dev_s6zr7mr4f2f1", "name": "checkout-test", "status": "stopping", "profile": "pixel_8", "android": "14", "region": "eu-central", "node_id": "nod_01m5vqsywg84jv64rmmqf9", "snapshot_id": null, "created_at": "2026-10-26T09:14:02.000Z", "ready_at": "2026-10-26T09:14:09.000Z", "terminated_at": null, "last_activity_at": "2026-10-26T09:31:15.000Z", "billed_minutes": 18, "error": null }Take a screenshot#
/v1/devices/:id/screenshotReturns the current screen as image/jpeg. If someone is watching the live stream you get the latest frame; otherwise the host captures one.
curl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-o screen.jpgimport { writeFile } from "node:fs/promises";
const res = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot", {
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
});
await writeFile("screen.jpg", Buffer.from(await res.arrayBuffer()));r = requests.get(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/screenshot",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
)
open("screen.jpg", "wb").write(r.content)Response 200 OK, Content-Type: image/jpeg. Frames are downscaled by the host (540 px wide by default).
| Error | When |
|---|---|
409 conflict |
The device is not running. |
504 internal |
No frame arrived within 8 seconds. Retry. |
Send input#
/v1/devices/:id/inputSends one tap, swipe, text or key event. Coordinates are in device pixels (720×1560 for pixel_8).
type |
Fields |
|---|---|
tap |
x, y: integers 0–10000 |
swipe |
x1, y1, x2, y2: integers 0–10000; ms: 1–10000, default 300 |
text |
text: 1–500 characters |
key |
key: HOME, BACK, RECENTS, ENTER, DEL or TAB |
curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "tap", "x": 360, "y": 780}'const input = (body: object) =>
fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await input({ type: "tap", x: 360, y: 780 });
await input({ type: "text", text: "hello@example.com" });
await input({ type: "swipe", x1: 360, y1: 1200, x2: 360, y2: 400, ms: 250 });
await input({ type: "key", key: "ENTER" });def send_input(body: dict):
return requests.post(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/input",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
json=body,
)
send_input({"type": "tap", "x": 360, "y": 780})
send_input({"type": "text", "text": "hello@example.com"})
send_input({"type": "key", "key": "ENTER"})Response 202 Accepted
{ "ok": true }The event is sent to the device; there is no confirmation that it had an effect. Take a screenshot to check. For many events in a row, the stream socket is faster than one HTTP request each.
| Error | When |
|---|---|
400 invalid_request |
Unknown type, missing field, or value out of range. |
409 conflict |
The device is not running, or its host is not connected. |
Install an app#
/v1/devices/:id/appsInstalls a completed upload on the device.
| Body field | Type | Description |
|---|---|---|
upload_id |
string | An upload in status uploaded. |
curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"upload_id": "upl_01m5vqsywgw57haqkjnexz"}'const { install_id } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ upload_id: "upl_01m5vqsywgw57haqkjnexz" }),
}).then((r) => r.json());install_id = requests.post(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
json={"upload_id": "upl_01m5vqsywgw57haqkjnexz"},
).json()["install_id"]Response 202 Accepted
{ "install_id": "ins_01m5vqsywg99189k5xj7f5" }The host downloads and installs the APK in the background (adb install -r -g). Poll get an install for the outcome, or watch for the app.installed event on the live stream. See Install an APK.
| Error | When |
|---|---|
404 not_found |
Unknown upload. |
409 conflict |
The upload is not complete, the device is not running, or its host is not connected. |
Get an install#
/v1/devices/:id/apps/:install_idThe status of an install started with install an app. Poll it every few seconds until status is no longer pending.
curl https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5 \
-H "Authorization: Bearer $AKAPHONE_API_KEY"const install = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5", {
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
}).then((r) => r.json());install = requests.get(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/apps/ins_01m5vqsywg99189k5xj7f5",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
).json()Response 200 OK
{
"install_id": "ins_01m5vqsywg99189k5xj7f5",
"status": "installed",
"package": "com.example.shop",
"created_at": "2026-10-26T09:20:00.000Z",
"finished_at": "2026-10-26T09:20:06.000Z"
}| Field | Description |
|---|---|
status |
pending, installed or failed. |
package |
The installed package name, when it could be determined. Omitted otherwise. |
error |
Why it failed, e.g. INSTALL_FAILED_NO_MATCHING_ABIS or timeout. Omitted on success. |
finished_at |
When it ended. Omitted while pending. |
An install still pending after 15 minutes becomes failed with error: "timeout". Install records are kept for 30 days. 404 not_found for an unknown device or install id.
Snapshot a device#
/v1/devices/:id/snapshotSaves the device's disk as a snapshot.
| Body field | Type | Description |
|---|---|---|
name |
string | Optional, up to 64 characters. |
curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "signed-in-v2.3"}'const snapshot = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "signed-in-v2.3" }),
}).then((r) => r.json());snapshot = requests.post(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/snapshot",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
json={"name": "signed-in-v2.3"},
).json()Response 202 Accepted
{
"id": "snp_01m5vqsywgez2ntdv5fq42",
"device_id": "dev_s6zr7mr4f2f1",
"node_id": "nod_01m5vqsywg84jv64rmmqf9",
"name": "signed-in-v2.3",
"size_bytes": null,
"status": "pending",
"created_at": "2026-10-26T09:20:44.000Z"
}| Error | When |
|---|---|
409 conflict |
The device is not running; your snapshot quota is reached; the host is low on disk or not connected. |
429 rate_limited |
More than 5 snapshots in an hour. |
Create a stream token#
/v1/devices/:id/stream-tokenReturns a single-use token, valid for 5 minutes, that lets a browser open this device's live stream without your API key. Call it from your server and hand the token to the page.
curl -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token \
-H "Authorization: Bearer $AKAPHONE_API_KEY"const { token } = await fetch("https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` },
}).then((r) => r.json());token = requests.post(
"https://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream-token",
headers={"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"},
).json()["token"]Response 200 OK
{ "token": "pfs_Jq3w0kV9mXr2YbT6nLc8Hd1sPfAe4uZo" }The token is bound to this device. It works for one connection; reconnecting needs a new token.
Related#
- Run a task on a device:
POST /v1/devices/:id/tasks - Live stream:
GET /v1/devices/:id/stream(WebSocket) - Restore a snapshot:
POST /v1/snapshots/:id/restore