iRemotech Device API
Programmatic remote access to your managed fleet of iPhones. List your devices, take screenshots, send actions (tap, type, swipe…), open a live video stream, drive a device live with a control channel, and upload files — all over a simple HTTPS + WebSocket API.
- Base URL:
https://api.iremotech.com/v1(the live stream and live control usewss://api.iremotech.com). - Format: JSON requests and responses; screenshots are JPEG; the live stream is a WebSocket.
- Versioning: everything is under
/v1. Changes within v1 are additive and backward-compatible. - Reference: the machine-readable contract is in
openapi.yaml.
1. Quickstart
- In the dashboard, open API Access and click Create key. Copy the key — it is shown only once.
- List your devices:
curl https://api.iremotech.com/v1/devices \
-H "Authorization: Bearer $IREMOTECH_API_KEY"
[
{ "public_id": "dev_1a2b3c4d5e6f7a8b", "name": "iPhone A" }
]
- Tap the screen of that device:
curl -X POST https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b/actions \
-H "Authorization: Bearer $IREMOTECH_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "tap", "x": 200, "y": 640 }'
Coordinates (x/y) are in the pixel space of the snapshot you get from
GET /devices/{id}/snapshot — read the pixel position straight off that image
and send it. The API scales it to the device for you; don't pre-scale.
That's the whole model: get a public_id, then act on it.
2. Authentication
Send your key as a Bearer token on every request:
Authorization: Bearer irt_live_<key_id>_<secret>
Keys are created and revoked from the dashboard. You can hold several at once (rotate by minting a new one, migrating, then revoking the old). Treat a key like a password: it grants access to your devices.
Scopes
Each key carries a set of scopes. A key can only do what its scopes allow; calling
an endpoint without the needed scope returns 403 forbidden.
| Scope | Grants |
|---|---|
read |
List devices, take screenshots, read usage |
control |
Send actions to a device, open the live control channel |
stream |
Open the live video stream |
upload |
Upload files to a device |
manage |
Rename devices and edit their notes and labels (§3b) — shown as Manage in the dashboard |
By default a new key has every scope; remove the ones a given integration doesn't need.
The scopes of an existing key cannot be changed: to get a scope a key lacks (for
example manage, which keys created before it existed do not have), create a new key.
3. Devices
List devices — GET /v1/devices (scope: read)
curl https://api.iremotech.com/v1/devices -H "Authorization: Bearer $KEY"
Returns an array of devices. Use public_id in every other call — it is an opaque
identifier and does not expose the underlying hardware.
[
{
"public_id": "dev_1a2b3c4d5e6f7a8b",
"name": "EJ-369",
"custom_name": "Front desk 03",
"notes": "SIM replaced in September",
"labels": [
{ "name": "VIP", "color": "#E53935" },
{ "name": "Batch 3", "color": "#9E9E9E" }
],
"dashboard_id": 5016,
"dashboard_url": "https://iremotech.com/dashboard/device/5016"
}
]
nameis the device's original name;custom_nameis the name you gave it (nullif none).notesare your free-text notes (nullif none).labelsare the device's labels in display order ([]if none).dashboard_urlopens the device in the dashboard.
To change custom_name, notes or labels, see §3b.
Device detail — GET /v1/devices/{public_id} (scope: read)
curl https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $KEY"
{
"public_id": "dev_1a2b3c4d5e6f7a8b",
"name": "EJ-369",
"custom_name": "Front desk 03",
"notes": "SIM replaced in September",
"labels": [{ "name": "VIP", "color": "#E53935" }],
"dashboard_id": 5016,
"dashboard_url": "https://iremotech.com/dashboard/device/5016",
"screen": { "width": 375, "height": 667 },
"snapshot": { "width": 406, "height": 720 }
}
snapshot is the size of the screenshots and stream frames for this device —
the pixel space every action coordinate is in. screen is the phone's
logical canvas in iPhone points, informational only (for example to size a
viewer); never convert coordinates to it — the API scales for you.
Screenshot — GET /v1/devices/{public_id}/snapshot (scope: read)
Returns the current screen as a JPEG.
curl https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b/snapshot \
-H "Authorization: Bearer $KEY" --output screen.jpg
3b. Editing a device — name, notes, labels
PATCH /v1/devices/{public_id} (scope: manage)
Rename a device, write its notes and organise your fleet with labels from your own
tools. The change is saved in your iRemotech dashboard, and a GET right after it
returns the new values.
Your existing API keys cannot call this endpoint. It needs the Manage scope (
manage), and the scopes of an existing key cannot be changed. Create a new key in the dashboard (API Access → Create key; new keys include every scope, Manage included), move your integration to it, then revoke the old key. With an older key this endpoint answers403 forbidden.
Send a JSON object with one or more of these three fields. A field you leave out is not touched.
| Field | Type | What it does |
|---|---|---|
custom_name |
string or null |
The name you give the device: 1–64 characters after leading and trailing whitespace is removed. null removes it, and the device is shown by its original name again. An empty string is rejected — send null instead. |
notes |
string or null |
Free-text notes, up to 2,000 characters. null or "" clears them. |
labels |
array | The device's complete list of labels, in the order you want them shown. It replaces the current labels; [] removes them all (null is rejected). Up to 20 labels, each { "name": "...", "color": "#RRGGBB" } — color is optional. |
Lengths are counted in Unicode code points: a letter, accented or not, counts as one; some emoji are made of several code points (flags, skin tones, combined emoji) and count as more than one.
The response is the device with the values now saved — the same shape as an item of
GET /v1/devices (§3). An empty custom_name or notes is returned as null.
Examples
In every example, replace dev_1a2b3c4d5e6f7a8b with the device's public_id;
$API_KEY is a key with the Manage scope.
Rename a device:
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "custom_name": "Front desk 03" }'
{
"public_id": "dev_1a2b3c4d5e6f7a8b",
"name": "EJ-369",
"custom_name": "Front desk 03",
"notes": "SIM replaced in September",
"labels": [{ "name": "VIP", "color": "#E53935" }],
"dashboard_id": 5016,
"dashboard_url": "https://iremotech.com/dashboard/device/5016"
}
Only custom_name changed; notes and labels are returned as they already were.
Remove the custom name (the device is shown by its original name again):
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "custom_name": null }'
The response has "custom_name": null.
Set the notes:
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "notes": "SIM replaced in September" }'
The response has "notes": "SIM replaced in September".
Clear the notes ("notes": "" does the same):
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "notes": null }'
The response has "notes": null.
Replace the labels — the device ends up with exactly these two labels, in this order:
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "labels": [ { "name": "VIP", "color": "#E53935" }, { "name": "Batch 3" } ] }'
The response has the labels as they are now saved, with their colours:
"labels": [
{ "name": "VIP", "color": "#E53935" },
{ "name": "Batch 3", "color": "#9E9E9E" }
]
Here Batch 3 did not exist yet, so it was created in the default grey. If VIP
already existed in another colour, the response shows that colour — see
"How labels work" below.
Remove all labels:
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "labels": [] }'
The response has "labels": [].
Several fields in one request:
curl -X PATCH https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "custom_name": "Front desk 03", "notes": "SIM replaced in September", "labels": [ { "name": "VIP" } ] }'
{
"public_id": "dev_1a2b3c4d5e6f7a8b",
"name": "EJ-369",
"custom_name": "Front desk 03",
"notes": "SIM replaced in September",
"labels": [{ "name": "VIP", "color": "#E53935" }],
"dashboard_id": 5016,
"dashboard_url": "https://iremotech.com/dashboard/device/5016"
}
The fields of one request are saved together or not at all.
To add or remove a single label, read the device's current labels (GET), change
the list, and send the whole list back.
How labels work
- Labels belong to your account and are shared by all your devices. A label is
identified by its
name, matched exactly: names are case-sensitive, soVIPandvipare two different labels. - A name your account does not have yet is created — with the
coloryou send, or grey (#9E9E9E) if you send none. - A name that already exists is used as it is, and a
coloryou send for it is ignored: a label has one colour on every device, so changing it from one device would change it everywhere. Change a label's colour in the dashboard. - A name must be 1–50 characters after leading and trailing whitespace is
removed, and can appear only once in
labels. - Removing a label from a device does not delete the label from your account. Your account can have up to 200 different labels; delete the ones you no longer use in the dashboard.
Limits
| What | Limit |
|---|---|
custom_name |
1–64 characters |
notes |
up to 2,000 characters |
| Labels per device | 20 |
Label name |
1–50 characters, once per request |
Label color |
#RRGGBB (six hexadecimal digits), optional |
| Labels per account | 200 different labels |
| Request body | 64 KB (65,536 bytes) |
| Edits of one device | 10 per minute, with a burst of 5 |
| Edits across your account | 5 per second, with a burst of 20 |
In practice: one device accepts 5 edits in quick succession, then one every 6
seconds; across your account, 20 edits at once and then 5 per second — enough to
relabel a fleet of 200 phones in under a minute. Send one request per device with
its final values; do not loop on one device. A request that goes over a limit gets
429 rate_limited with a Retry-After header. Every request to one of your
devices counts, including one rejected for an invalid body.
Editing does not count toward your daily budgets (§10). Each request still counts toward the general request rate of your key and account (§9).
Errors
A request that fails changes nothing — except upstream_unavailable, where the
outcome is not known. The request is idempotent: sending the same request again
leads to the same saved state, so repeating it is safe.
| HTTP | code |
Meaning | What to do |
|---|---|---|---|
| 401 | unauthorized |
Missing or invalid API key | Check the key |
| 403 | forbidden |
The key does not have the Manage scope, or the key or account is not active (suspended, revoked, expired, IP not allowed) | Without the Manage scope: create a new key (see the note above). Otherwise check detail: the key may be revoked or expired, or your IP not in its allowlist; if your account is suspended, contact us |
| 404 | device_not_found |
No such device in your account | Check the public_id |
| 422 | invalid_action |
The body failed validation: empty body or {}, not a JSON object, a request body larger than 64 KB, unknown field, a name that is empty or too long, notes that are too long, text containing the null character or invalid Unicode, bad colour, the same label name twice, more than 20 labels, labels: null |
Fix the request |
| 422 | label_limit_reached |
The change would create labels beyond your account's 200 | Reuse existing labels, or delete unused ones in the dashboard |
| 429 | rate_limited |
Too many edits | Wait Retry-After seconds, then retry |
| 503 | rename_unavailable |
You sent custom_name and the device's server did not accept the change. Nothing was changed, including the other fields you sent. |
Retry later, or send the other fields without custom_name |
| 503 | upstream_unavailable |
The device settings could not be saved right now | Repeat the same request |
Renaming is the only change that also has to be accepted by the device's server; notes and labels do not depend on it.
(invalid_action is this API's code for any request body that fails validation.)
When the API rejects the body itself, the response has an errors array with one
item per problem: loc says where (["body", "notes"], ["body", "labels", 0, "color"],
…) and msg says what. A change rejected further along (by the dashboard) has no
errors array; its detail says what was wrong. Example — notes longer than 2,000
characters:
{
"type": "https://errors.kernel-api.iremotech.com/invalid_action",
"title": "invalid_action",
"status": 422,
"code": "invalid_action",
"detail": "request validation failed",
"errors": [
{
"type": "string_too_long",
"loc": ["body", "notes"],
"msg": "String should have at most 2000 characters"
}
]
}
Example — a key without the Manage scope:
{
"type": "https://errors.kernel-api.iremotech.com/forbidden",
"title": "forbidden",
"status": 403,
"code": "forbidden",
"detail": "scope 'manage' required"
}
Example — the rename was not accepted:
{
"type": "https://errors.kernel-api.iremotech.com/rename_unavailable",
"title": "rename_unavailable",
"status": 503,
"code": "rename_unavailable",
"detail": "the device's server did not accept the new name, so nothing was changed; retry later, or send the other fields without custom_name"
}
4. Actions — POST /v1/devices/{public_id}/actions (scope: control)
Send one action per call. The body is a single object discriminated by type.
Coordinates are in snapshot pixels — the snapshot size from device detail (§3), i.e. the pixel position read straight off a screenshot. Never scale them to screen.
type |
Fields | Effect |
|---|---|---|
tap |
x, y |
Tap a point |
double_tap |
x, y, gap_ms (0–500, default 100) |
Double-tap a point (both taps + the gap timed on the device) |
swipe |
x1, y1, x2, y2, duration_ms (default 250) |
Swipe between two points |
long_press |
x, y, hold_ms (default 600) |
Press and hold |
drag |
x1, y1, x2, y2, duration_ms (default 600) |
Drag between two points |
scroll |
x, y, dy (wheel notches, positive scrolls down, clamped to ±20 per message) |
Scroll at a point — the pointer is moved to (x, y) first |
text |
text |
Type text |
key |
key |
Send a single key |
press |
name or (modifiers + key) |
A preset (e.g. home) or a modifier chord |
open_url |
url |
Open a URL |
open_app |
target [, username] |
Open an app screen or Telegram bot by name |
clipboard_set |
text |
Set the device clipboard |
clipboard_get |
— | Returns { "clipboard": "..." } |
airplane |
on (boolean) |
Toggle airplane mode |
device_ip |
— | Returns the device's current IP |
recalibrate |
— | Recentre the pointer (does not reboot or wipe) |
Most actions return { "result": {} }. clipboard_get and device_ip return data
under result.
One example per action
All send POST https://api.iremotech.com/v1/devices/$ID/actions with
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json"; only the
-d body changes:
# tap a point
-d '{ "type": "tap", "x": 200, "y": 640 }'
# swipe up (from y=600 to y=200)
-d '{ "type": "swipe", "x1": 200, "y1": 600, "x2": 200, "y2": 200, "duration_ms": 250 }'
# press and hold
-d '{ "type": "long_press", "x": 200, "y": 640, "hold_ms": 600 }'
# drag between two points
-d '{ "type": "drag", "x1": 100, "y1": 400, "x2": 300, "y2": 400, "duration_ms": 600 }'
# scroll: dy in wheel notches (positive = down); a few notches per message
-d '{ "type": "scroll", "x": 200, "y": 400, "dy": -3 }'
# type text into the focused field
-d '{ "type": "text", "text": "hello world" }'
# send a single key
-d '{ "type": "key", "key": "enter" }'
# a named key preset (e.g. go to the home screen)
-d '{ "type": "press", "name": "home" }'
# open a URL on the device
-d '{ "type": "open_url", "url": "https://example.com" }'
# open an app screen or a Telegram bot by name
-d '{ "type": "open_app", "target": "instagram_account_status" }'
-d '{ "type": "open_app", "target": "telegram", "username": "yourbot" }'
# set the device clipboard
-d '{ "type": "clipboard_set", "text": "some text" }'
# read the device clipboard -> { "result": { "clipboard": "some text" } }
-d '{ "type": "clipboard_get" }'
# toggle airplane mode on/off
-d '{ "type": "airplane", "on": true }'
# read the device's current IP -> { "result": { "ip": "..." } }
-d '{ "type": "device_ip" }'
# recentre the pointer if taps start landing off-target
-d '{ "type": "recalibrate" }'
Full example:
curl -X POST https://api.iremotech.com/v1/devices/dev_1a2b3c4d5e6f7a8b/actions \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "clipboard_get" }'
# -> { "result": { "clipboard": "some text" } }
An unknown or malformed action returns 400 invalid_action. A well-formed action
the device refuses returns 422 command_rejected.
Open apps and screens — open_app
Open an app screen or a Telegram bot by a short name — no need to know any
URL. Send { "type": "open_app", "target": "<name>" }. For Telegram, also
pass username (the bot handle).
target |
Opens |
|---|---|
instagram |
Instagram (app home) |
instagram_account_status |
Instagram → Account Status |
instagram_settings |
Instagram → Settings |
instagram_reel |
Instagram → new Reel camera |
instagram_profile |
Instagram → your profile |
instagram_camera |
Instagram → Camera |
instagram_dms |
Instagram → Direct messages |
instagram_activity |
Instagram → Activity |
instagram_explore |
Instagram → Explore |
ios_notes |
iOS Notes |
ios_photos |
iOS Photos |
ios_settings |
iOS Settings |
ios_about |
iOS Settings → General → About |
ios_phone |
iOS Phone |
ios_messages |
iOS Messages |
telegram (+ username) |
A Telegram bot by handle |
An unknown target (or a bad/misplaced username) returns
400 invalid_action.
5. Live stream — wss://api.iremotech.com/v1/devices/{public_id}/stream?token=<key> (scope: stream)
A WebSocket that pushes the device screen as a series of binary JPEG frames (one JPEG per WebSocket message). Open it, then decode each binary message as an image.
Authenticate by passing your key as the token query parameter (not the
Authorization header) — WebSocket handshakes from browsers can't set headers,
so the key goes in the URL. Use wss:// (TLS) so the URL isn't sent in the clear.
Optional query param: fps to cap the frame rate. A rejected handshake (bad key,
missing stream scope, unknown device, suspended account, max_active_devices
reached, too many connects) shows up as a failed connection — HTTP 403 — with no
detail; call GET /v1/devices/{public_id} with a key that also has the read
scope to see why.
Python (websockets):
import asyncio, urllib.parse, websockets
async def watch(device_id, key):
token = urllib.parse.quote(key)
url = f"wss://api.iremotech.com/v1/devices/{device_id}/stream?token={token}"
async with websockets.connect(url) as ws:
i = 0
async for message in ws: # each message is one JPEG frame (bytes)
with open(f"frame_{i:04d}.jpg", "wb") as f:
f.write(message)
i += 1
asyncio.run(watch("dev_1a2b3c4d5e6f7a8b", "irt_live_..."))
Browser / Node.js (ws):
import WebSocket from 'ws'
const url = `wss://api.iremotech.com/v1/devices/${id}/stream?token=${encodeURIComponent(key)}`
const ws = new WebSocket(url)
ws.on('message', frame => {
// `frame` is a Buffer holding one JPEG image
})
The stream self-throttles and drops stale frames under backpressure, so you always get the freshest screen rather than a growing backlog. Time spent streaming counts toward your active-minutes budget (see Limits).
Frames arrive only when the screen changes. A phone that is sitting still produces no new frames — that is normal, not a dead connection. So that the socket is never silent, the stream re-sends the last frame after a few seconds without a change (a normal binary JPEG, identical to the previous one). Keep the connection open; do not reconnect because frames stopped arriving — a reconnect costs the handshake plus the wait for the next frame, and a control sent while reconnecting only becomes visible once the new stream delivers.
5b. Live control — wss://api.iremotech.com/v1/devices/{public_id}/control?token=<key> (scope: control)
A WebSocket for live, hands-on control: send the pointer as it moves and the
phone's cursor follows in real time, exactly like the dashboard. Use it when a
person is driving the phone from your UI (a CRM screen, a support console);
keep using POST …/actions for automation.
Authenticate with the token query parameter, like the stream. Coordinates are
snapshot pixels — the same space every action uses, and the size of the
frames the live stream delivers. The first message from the server tells you
both sizes:
{"type":"ready","logical":{"w":375,"h":667},"snapshot":{"w":406,"h":720},"max_messages_per_second":120}
Messages you send (one JSON object per message):
| Message | Meaning |
|---|---|
{"type":"mouse","x":203,"y":640,"buttons":0} |
Pointer at (x, y). buttons 1 = pressed, 0 = released. A tap is buttons:1 then buttons:0; a swipe or drag is a press, moves with buttons:1, then a release. |
{"type":"scroll","x":203,"y":400,"dy":-3} |
Scroll at (x, y) by dy notches — same sign convention as the scroll action (§4), clamped to ±20 per message; a mouse wheel is about one notch per click, so send dy = ±1 per wheel event. |
{"type":"key","key":"Return"} |
One key, same vocabulary as the key action. |
{"type":"text","text":"hello"} |
Type text, same rules as the text action. The server answers {"type":"typed","skipped":[…]} with any characters the device could not type. |
{"type":"ping"} |
Optional; answered with {"type":"pong"}. |
Messages you receive: ready (once), pong, typed, and
{"type":"error","code":"…","detail":"…"} for a message that was not applied
(invalid_frame, rate_limited, device_error, hold_timeout) — the channel
stays open.
Browser example — forward pointer events from the element that shows the
live stream (img here, sized to the snapshot):
const url = `wss://api.iremotech.com/v1/devices/${id}/control?token=${encodeURIComponent(key)}`
const ws = new WebSocket(url)
let snapshot = null
ws.onmessage = (e) => {
const m = JSON.parse(e.data)
if (m.type === 'ready') snapshot = m.snapshot
}
const img = document.querySelector('#screen') // the <img> fed by the stream
img.style.touchAction = 'none' // or CSS `touch-action: none`: let the
// element keep the pointer events instead
// of scrolling/zooming the page
const pos = (ev) => {
const r = img.getBoundingClientRect()
return {
x: Math.round((ev.clientX - r.left) * snapshot.w / r.width),
y: Math.round((ev.clientY - r.top) * snapshot.h / r.height),
}
}
const send = (ev, buttons) => {
if (!snapshot || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'mouse', ...pos(ev), buttons }))
}
let down = false
img.addEventListener('pointerdown', (ev) => { down = true; img.setPointerCapture(ev.pointerId); send(ev, 1) })
img.addEventListener('pointermove', (ev) => send(ev, down ? 1 : 0))
img.addEventListener('pointerup', (ev) => { down = false; send(ev, 0) })
img.addEventListener('pointercancel', (ev) => { down = false; send(ev, 0) })
ws.onclose = (e) => {
down = false
if (e.code === 4409) showBanner('Another session took control of this phone') // do NOT auto-reconnect
else if (e.code === 4401 || e.code === 4403) showError(e.reason) // fix the key / scopes first
else setTimeout(reconnect, backoff()) // 1011, 4290, 1006 (network or a refused handshake): retry with backoff
}
Keep the live stream (§5) open next to the channel: it is how the operator sees
the result, and ready.snapshot is exactly the size of the frames it delivers,
so the same element serves both.
What counts. Each gesture (a press-to-release, a scroll, a key, a text) is
one action in your daily actions budget — the same as the equivalent REST
action. Moving the pointer is free. While the channel is open the device counts
as in use (active minutes, max_active_devices), like a stream; a stream and
a control channel on the same device count as one device in use.
Rules.
- One control channel per device: opening a second one closes the first with
code
4409("replaced"). Reloading your page just works. - Up to 120 messages per second per connection. Above that, pointer moves are
dropped (the gesture still lands where you released) and anything else is
answered with
error rate_limited. A sustained flood closes the channel (4290). Repeated rejections in a burst share oneerrorreply, so do not expect one answer per message you send. - A message is at most 8 KiB — a larger one closes the channel with code
4400— and atextmessage at most 4096 characters, like thetextaction; a longer one is answered witherror invalid_frame. - If the key or the account is suspended mid-session, the channel closes
4290(or4401if the key was revoked) at the next check — reconnecting will not help until the account is active again. - If your client sends no further pointer, scroll, key or text message for
10 s while pressed (a
pingdoes not count), the server releases the button and sendserror hold_timeout. If your client disconnects while pressed, the button is released too. - The key travels in the URL (there is no header on a browser WebSocket). Use a
dedicated key with only the
streamandcontrolscopes and an IP allowlist for pages that talk to the API directly, or proxy the socket through your backend.
Getting the lowest latency. The phone's pointer travels to each point you
send and needs a moment to settle before a press registers. If you press right
after a jump across the screen, the press is applied once the pointer has
arrived and settled, and the release no earlier than a short minimum hold — so
the tap still registers, at the cost of that wait. Taps are fastest when the
pointer is already at the target: send pointer moves as the hand moves (as a
mouse does) rather than one jump followed immediately by a press. The first
press after connecting is the slowest case (the server does not yet know where
the pointer is). Sending a hover move as soon as ready arrives lets the pointer
settle before the operator's first tap, as long as that tap does not follow the
hover within the same instant.
If the connection is refused. A handshake the server rejects (bad or
revoked key, missing control scope, unknown device, suspended account, daily
budget or max_active_devices reached, too many connects) shows up in a
browser as a failed connection — HTTP 403 — with no close code and no detail.
To find out why, call GET /v1/devices/{public_id} and
GET /v1/devices/{public_id}/snapshot with a key that also has the read
scope (your main key; a stream+control-only key gets 403 forbidden there,
with detail "scope 'read' required"): they return the reason as a normal API
error (401/403/404/429).
Connects to one device are limited to a few per minute, so a client that
reconnects in a tight loop refuses itself.
Reconnecting. Once the channel is open, the server closes it with a code that tells you what to do:
| Code | Meaning | What to do |
|---|---|---|
4409 |
replaced — another session opened a control channel on this device | Do not reconnect automatically: two tabs reconnecting on 4409 expel each other forever. Tell the operator and let them decide. |
1011 |
device unreachable, or the server gave up writing to a client that stopped reading | Reconnect with an increasing delay (start at a second). |
4290 |
daily budget or max_active_devices reached, rate limited, flooding, or the key/account was suspended |
Reconnect with an increasing delay; if it keeps happening, check GET /v1/usage. |
4401 / 4403 |
key revoked or expired, account deactivated, or control scope removed |
Do not retry until the key, scopes or account are fixed. |
4400 |
a message over 8 KiB | Fix the client; reconnect. |
"Device not found" (4404) only ever happens at connect time, so it arrives as
a refused connection (above), never as a close code on an open channel.
A page reload or a lost network simply reconnects: the newest channel wins (§ Rules), so you never have to wait for the old one to time out.
Integration checklist
- Forward
pointerdown/pointermove/pointerup/pointercancelfrom the element that shows the stream, scaled toready.snapshot;touch-action: noneon that element. - One press =
buttons: 1, one release =buttons: 0; never leave a press without a release (the server releases it after 10 s, but the operator sees a stuck finger meanwhile). - Handle
oncloseas in the table above; never auto-reconnect on4409. - Keep the stream open alongside; open one control channel per phone per operator, when they start driving it, and close it when they stop (an open channel counts as the device being in use).
- Use a dedicated key with only
stream+controland an IP allowlist for pages that talk to the API directly, or proxy the socket through your backend. - Keep using
POST …/actionsfor anything scripted; the channel is for a human hand.
6. Upload a file (scope: upload)
There are two ways to upload. Use the ticket flow — it is the recommended path and the only way to send files larger than 100 MB (up to 300 MB).
Recommended — the upload ticket
POST /v1/devices/{public_id}/media/ticket
Uploading in two steps lets you send the file directly to the upload endpoint, bypassing the 100 MB request-size limit of the older direct-body route.
Step 1 — get a ticket:
curl -X POST "https://api.iremotech.com/v1/devices/$ID/media/ticket" \
-H "Authorization: Bearer $KEY"
{
"upload_url": "https://upload.iremotech.com/v1/devices/<...>/upload",
"ticket": "<short-lived token>",
"max_bytes": 314572800,
"expires_at": "2026-09-01T10:12:00Z"
}
Step 2 — send the raw file bytes to upload_url, authenticated with the
ticket. Append the file name as ?filename=, set
Content-Type: application/octet-stream, and send a Content-Length (chunked
transfer encoding is rejected):
curl -X POST "$UPLOAD_URL?filename=video.mp4" \
-H "Authorization: Bearer $TICKET" \
-H "Content-Type: application/octet-stream" \
--data-binary @video.mp4
Today a ticket is reusable for multiple files until expires_at; the upload
must start before it expires. The upload endpoint rate-limits to
60 requests/min and 4 concurrent uploads per key — honor Retry-After
on a 429 and keep at most 4 uploads in flight.
Upcoming change — one ticket per file. When usage-based billing launches (see announced changes), API upload tickets become single-use: mint one ticket per file, and a second upload with the same ticket is rejected with
409 ticket_already_used.max_byteswill also be set per ticket and may be lower than 300 MB — always read it from the response instead of assuming the maximum. To be ready now, mint a ticket immediately before each upload; that pattern works both today and after the change.
Python:
import requests
# 1. get a ticket
t = requests.post(
f"https://api.iremotech.com/v1/devices/{device_id}/media/ticket",
headers={"Authorization": f"Bearer {key}"},
).json()
# 2. upload directly to the returned URL (no 100 MB limit)
with open("video.mp4", "rb") as f:
r = requests.post(
t["upload_url"],
params={"filename": "video.mp4"},
headers={
"Authorization": f"Bearer {t['ticket']}",
"Content-Type": "application/octet-stream",
},
data=f, # streamed, not loaded into memory
)
r.raise_for_status()
Deprecated — direct body
POST /v1/devices/{public_id}/media?filename=<name>
Sends the raw bytes through the API in a single request. Capped at 100 MB and kept only for backward compatibility — prefer the ticket flow above.
curl -X POST "https://api.iremotech.com/v1/devices/$ID/media?filename=photo.jpg" \
-H "Authorization: Bearer $KEY" \
--data-binary @photo.jpg
7. Usage — GET /v1/usage (scope: read)
curl https://api.iremotech.com/v1/usage -H "Authorization: Bearer $KEY"
{
"active_minutes": { "used": 12, "budget": 4800, "remaining": 4788 },
"actions": { "used": 340, "budget": 200000, "remaining": 199660 },
"snapshots": { "used": 15, "budget": 20000, "remaining": 19985 },
"uploads": { "used": 2, "budget": 1000, "remaining": 998 },
"max_active_devices": 10,
"resets_at": "2026-07-14T00:00:00+00:00"
}
Budgets are for the current UTC day and reset at resets_at.
8. Errors
Every error is an RFC 7807 problem document with Content-Type: application/problem+json:
{
"type": "https://errors.kernel-api.iremotech.com/forbidden",
"title": "forbidden",
"status": 403,
"code": "forbidden",
"detail": "scope 'control' required"
}
Integrate against code (stable), not detail (human-readable, may change).
| HTTP | code |
Meaning |
|---|---|---|
| 400/422 | invalid_action |
The request body failed validation |
| 401 | unauthorized |
Missing or invalid API key |
| 403 | forbidden |
The key lacks the scope the endpoint needs, or the key or account is not active (suspended, revoked, expired, or the request's IP is not in the key's allowlist) |
| 403 | device_blocked |
Access to this device is temporarily blocked; the device is reachable but does not accept commands or screenshots right now |
| 404 | device_not_found |
No such device for your account |
| 409 | ticket_already_used |
(upcoming) Upload ticket was already used — mint a new one per file |
| 413 | payload_too_large |
Upload exceeded the size limit |
| 422 | command_rejected |
Device reachable but rejected the command |
| 422 | label_limit_reached |
Editing a device would exceed your account's 200 labels (§3b) |
| 429 | rate_limited / quota_exceeded / max_active_devices_reached |
Slow down, or a limit was hit |
| 502 | upload_failed |
Upload could not be completed downstream |
| 503 | device_offline |
Device currently unreachable |
| 503 | rename_unavailable |
The device's server did not accept a new custom_name; nothing was changed (§3b) |
| 503 | upstream_unavailable |
Device settings could not be saved right now; safe to retry (§3b) |
| 504 | kernel_timeout |
Device did not respond in time |
| 500 | internal |
Unexpected server error |
A 403 whose body is error code: 1010
Every error the API returns is a JSON object with a code. If you get a 403 whose body is
the plain text error code: 1010 instead, the request was refused by the network layer in
front of the API, before it reached us. It is not about your API key or its scopes: it is the
User-Agent your HTTP client sent. This happens with the default User-Agent of Python's
built-in urllib (Python-urllib/3.x). Common clients such as curl, requests, httpx,
axios, fetch, Go's net/http and OkHttp are not affected.
The fix is to send your own User-Agent header:
import urllib.request
req = urllib.request.Request(
"https://api.iremotech.com/v1/devices",
headers={
"Authorization": f"Bearer {API_KEY}",
"User-Agent": "my-integration/1.0",
},
)
9. Rate limits
Requests pass through several token-bucket layers (per key, per account, per device,
per endpoint). When any layer is exceeded you get 429 rate_limited with:
Retry-After: seconds to wait before retrying.X-RateLimit-Remaining/X-RateLimit-Limit-Layer: which layer tripped and what's left.
Back off on 429 and honor Retry-After. The live stream and live control
channel are limited per device on connects (a few per minute); the control
channel also caps messages per connection (see §5b).
Editing a device (§3b) has two limits of its own, on top of the limits every request counts toward:
| Limit | Rate | Burst |
|---|---|---|
| Edits of one device | 10 per minute | 5 |
| Edits across your account | 5 per second | 20 |
10. Limits (daily budgets)
Beyond rate limits, each account has daily budgets that reset at UTC midnight. The budgets are per device — the pool for your whole account is the per-device rate × your number of devices, so a larger fleet gets proportionally more headroom.
| Budget | What it measures |
|---|---|
active_minutes |
Minutes a device is in use (streaming or receiving actions) |
actions |
Number of actions |
snapshots |
Number of screenshots |
uploads |
Number of file uploads |
max_active_devices |
How many devices you may have in use at the same time |
Check GET /v1/usage to see your current usage and remaining budget. Exceeding a
daily budget returns 429 quota_exceeded (or max_active_devices_reached for
concurrency); it clears at the next UTC midnight (resets_at). Limits are
configurable per account — contact us if you need them raised.
Editing a device (§3b) does not count toward any daily budget.
11. Changes
Changes within /v1 are additive and backward-compatible. When a behaviour
change is unavoidable it is published on two pages:
- Announced changes — changes that are announced but not yet in effect, with what to do to be ready. If the API is integrated into an automated workflow, check this page at least once a week.
- Changelog — changes that are already live, newest first.