AkaPhoneDocs
REST API

Live stream API

The device WebSocket - authentication with a key, cookie or single-use stream token, binary JPEG frames, JSON events, input messages and limits.

Each device has a WebSocket that carries its screen as JPEG frames, events about the device and its tasks, and input from you. The dashboard's live view is built on it. Background in Live streaming.

Connect#

WS/v1/devices/:id/stream
Text
wss://akaphone.abhgnx-417.workers.dev/v1/devices/dev_s6zr7mr4f2f1/stream

A plain HTTP request (no WebSocket upgrade) gets 426 with code invalid_request. You can connect to a device in any status; frames flow only while it is running.

Authentication#

Use one of:

Method Use it from
Authorization: Bearer ak_live_… header on the upgrade request Servers, scripts, Node.js and Python clients.
?key=<stream token> query parameter (?token= also works) Browsers. Get the token from your server with POST /v1/devices/:id/stream-token. It is valid for 5 minutes, for this device only, and for a single connection.
The dashboard's session cookie The AkaPhone dashboard only.

Failures are returned before the upgrade, as normal JSON errors: 401 unauthorized (no or bad credentials, or a used or expired token), 403 forbidden (suspended account), 404 not_found (not your device).

Server to client#

Frames#

Binary messages are complete JPEG images of the screen, with no prefix or header. Draw each one as it arrives.

  • Rate: 2 fps on Free, 4 on Dev, 5 on Team.
  • Size: downscaled by the host, 540 px wide by default; scale input coordinates back to device pixels.
  • On connect you get the most recent frame straight away if there is one. An unchanged screen is resent every 10 seconds.

Events#

Text messages are JSON objects with a t field:

t Fields When
status device (the device object) On connect, and whenever the device's status changes.
task.started task_id ARTEMIS started a task.
task.step task_id, n, label, ms Each step of a running task.
task.done task (the task object, without steps) A task finished: succeeded, failed, cancelled or timed out.
app.installed device_id, install_id, package, error An APK install finished (also available from get an install). error is null on success; package may be null if it could not be determined.
credits.low balance Your balance is 5 credits or less.
device.stopped reason: user, idle, credits or lost The device is stopping or has stopped. No more frames will come; close the socket.
error error: { code, message } Your last input was rejected: rate_limited or invalid_request.
JSON
{"t":"status","device":{"id":"dev_s6zr7mr4f2f1","status":"running","…":"…"}}
{"t":"task.started","task_id":"tsk_01m5vqsywgehe4jhva3g0r"}
{"t":"task.step","task_id":"tsk_01m5vqsywgehe4jhva3g0r","n":1,"label":"Open the Settings app","ms":2140}
{"t":"task.done","task":{"id":"tsk_01m5vqsywgehe4jhva3g0r","status":"succeeded","result":"Android version: 14","…":"…"}}
{"t":"app.installed","device_id":"dev_s6zr7mr4f2f1","install_id":"ins_01m5vqsywg99189k5xj7f5","package":"com.example.shop","error":null}
{"t":"credits.low","balance":4}
{"t":"device.stopped","reason":"credits"}

Ignore event types you do not recognise; new ones may be added.

Client to server#

Sending input#

Send input as a text message with t: "input" and the same fields as POST /v1/devices/:id/input:

JSON
{"t":"input","type":"tap","x":360,"y":780}
{"t":"input","type":"swipe","x1":360,"y1":1200,"x2":360,"y2":400,"ms":250}
{"t":"input","type":"text","text":"hello@example.com"}
{"t":"input","type":"key","key":"BACK"}
  • Coordinates are device pixels (720×1560 for pixel_8), not frame pixels.
  • text is at most 500 characters per message. Keys: HOME, BACK, RECENTS, ENTER, DEL, TAB.
  • At most 20 messages per second per connection; extra messages are dropped and you get an error event.
  • Input is ignored while the device is not running. Any other message type is ignored.

Keeping the device alive#

An open connection counts as activity: a device with a viewer is never stopped as idle. It still stops when you stop it or your credits run out. Close sockets you are not using.

Examples#

// 1. Your server: POST /v1/devices/:id/stream-token with your API key, return { token } to the page.
const { token } = await fetch(`/my-backend/stream-token?device=${deviceId}`).then((r) => r.json());

// 2. The page: connect with the token, draw frames on a canvas.
const ws = new WebSocket(`wss://akaphone.abhgnx-417.workers.dev/v1/devices/${deviceId}/stream?key=${encodeURIComponent(token)}`);
ws.binaryType = "arraybuffer";
const canvas = document.querySelector("canvas")!;
const ctx = canvas.getContext("2d")!;

ws.onmessage = async (msg) => {
  if (typeof msg.data !== "string") {
    const img = await createImageBitmap(new Blob([msg.data], { type: "image/jpeg" }));
    canvas.width = img.width;
    canvas.height = img.height;
    ctx.drawImage(img, 0, 0);
    img.close();
    return;
  }
  const ev = JSON.parse(msg.data);
  if (ev.t === "task.step") console.log(`step ${ev.n}: ${ev.label}`);
  if (ev.t === "device.stopped") ws.close();
};

// Tap where the user clicked, scaled from frame pixels to device pixels (720 wide for pixel_8).
canvas.addEventListener("click", (e) => {
  const r = canvas.getBoundingClientRect();
  const scale = 720 / r.width;
  ws.send(JSON.stringify({ t: "input", type: "tap", x: Math.round((e.clientX - r.left) * scale), y: Math.round((e.clientY - r.top) * scale) }));
});

Reconnecting#

Connections can drop (network changes, deploys). Reconnect with exponential backoff, and get a new stream token for each attempt if you use tokens. On reconnect you receive a fresh status event and the latest frame, so there is no state to replay. Stop reconnecting after device.stopped.