iRemotech Docs

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.


1. Quickstart

  1. In the dashboard, open API Access and click Create key. Copy the key — it is shown only once.
  2. List your devices:
curl https://api.iremotech.com/v1/devices \
  -H "Authorization: Bearer $IREMOTECH_API_KEY"
[
  { "public_id": "dev_1a2b3c4d5e6f7a8b", "name": "iPhone A" }
]
  1. 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"
  }
]

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 answers 403 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

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.

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


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_bytes will 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:

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: