# ALI Remote API

> Programmatic access to your phone farm: list your devices, read their screens, drive them and send them files, then run the agency around them: schedule the work as automations, read what your team did and on which handsets, invite people, and organise your rack.

**Base URL:** `https://beta.aliremote.com/api/v1`  
**Version:** 1.0

## Authentication

Every request carries an API key:

```http
Authorization: Bearer ali_live_<key_id>_<secret>
```

Create one in the dashboard under **API access**. Keys belong to an agency, and each one is limited to a set of scopes and a set of phones. The secret is shown once and is not recoverable; rotate rather than recover.

WebSocket endpoints also accept `?token=<key>`, because browsers cannot set headers on a handshake. Prefer the header: query strings end up in logs.

### Scopes

- `devices.read`
- `devices.snapshot`
- `devices.write`
- `devices.control`
- `media.write`
- `devices.stream`
- `analytics.read`
- `members.read`
- `members.write`
- `agency.read`
- `groups.write`
- `automations.read`
- `automations.write`

An endpoint answers `403 scope_required` if the key lacks the scope it needs.

## Conventions

**Coordinates.** For every positional action, `x`/`y` are in the pixel space of the snapshot from `GET /devices/{id}/snapshot`. Read a position off that image and send it as-is; the API scales it to the device. Do not pre-scale.

**Device ids.** A device is addressed by its opaque `public_id` (`ph_…`). Paths also accept the sticker on the back of the handset (`inventory_id`) if your team labels racks that way.

**Errors.** Failures are RFC 7807 problem documents with a stable `code`. Integrate against `code`, never against `detail`, which is prose and may be reworded. **Every** response carries an `X-Request-Id` header, successes included; quote it in support tickets. Problem documents repeat it in the body as `request_id`, which is the only reason it looks error-only: a successful call has no body to put it in, so read the header. The call you most want to ask about is usually the one that returned 200.

**What a key may do, beyond its scopes.** A key acts on behalf of whoever created it, and can never do something that person could not do in the dashboard by hand: not read a team they cannot see, not hand out a role above their own standing, not reach another agency. Scopes narrow that further, they do not widen it. So a call can be refused `403 forbidden` even when the scope is right, and the message names the account and the permission it is missing: give that person a role carrying it, or mint the key under somebody who already has one. This only affects the endpoints that read or change the agency (analytics, people, invitations, groups); the device endpoints depend on which phones the key reaches, which `GET /usage` reports as `reachable_devices`. That count is the budget multiplier and reads higher than `GET /devices` lists, because it still counts a handset no relay reports any more. If the account is deleted or moves agency, those agency endpoints answer `403 key_orphaned` and the key needs replacing, while its device endpoints keep working.

**Rate limits.** Limits are token buckets, so each one has a sustained rate *and* a burst: 20/s per key (burst 120), 8/s per device for actions (burst 16), 2/s per device for snapshots (burst 4). A full bucket absorbs the burst before the sustained rate applies, so 4 snapshots fired at once all succeed and the next is refused. Refusals carry `Retry-After` and `X-RateLimit-*`, and those headers report whichever layer is closest to refusing you, not always the per-key one. A phone is physical hardware moving at its own pace; exceeding these does not make it faster.

**Daily credits.** Separate from the rate limits. Every call spends **credits**, priced by what it does: a read costs 1, an action 2, a screenshot 10, an upload 50, and a minute of streaming 20. One unit is one call, except where a call does more than one thing: `press` with `times: 20` pays for twenty actions, and a stream pays per minute it stays open. Three layers pay for that, and they are not the same thing. **Included:** 6,000 credits per reachable phone plus a flat 2,500, free, and reset at UTC midnight. **Purchased:** credits bought in the dashboard, held by the agency rather than by one key and never reset. Only the part of a day above the included allowance draws on them, and a call neither layer can cover is refused `402 credits_exhausted`: top up, or wait for the reset. **The ceiling:** 35,000 credits per reachable phone plus the same flat part, refused as `429 quota_exceeded` whether or not there is a balance to pay with, so a loop that goes wrong costs one day rather than a balance. **The pool is per key, not per phone.** The phone count sizes it and nothing else, so a key reaching 40 handsets can spend its whole day on one of them; nothing is reserved for a phone and nothing is lost by leaving one idle. A key reaching fewer than 3 phones is sized as though it reached 3, because small accounts would otherwise be throttled out of ordinary use, and the flat part is what a key reaching no phones has to spend, so the agency endpoints keep answering when a key's scope collapses. A call that fails without doing any work is refunded to whichever layer paid for it, and `GET /usage` is free. These are the standard numbers and your account may have been given different ones, so read what your key actually has from `GET /usage` rather than hardcoding the figures above: it reports the allowance, the balance, and what today has taken from each. It reports the per-phone breakdown too, so it asks for `devices.read`: give a management-only key that scope if you want it to read its own budget.

## Devices

### GET /devices

List your devices

**Scope:** `devices.read`

Every phone this key can reach. Paginated: pass `next_cursor` back as `?cursor=` until it comes back null.

A phone no relay reports any more is left out, so this page is the list the dashboard shows rather than a count of everything the agency is billed for: `GET /agency` reports that one as `phones_held`, and `reachable_devices` on `GET /usage` still sizes your budget by it. `GET /devices/{id}` still **reads** one, so an id you already hold resolves, but every action on it answers `device_unprovisioned` until a relay reports the handset again.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `cursor` | query | no | string | The `next_cursor` from the previous page. |
| `limit` | query | no | integer (min 1, max 200, default 50) |  |

**Returns:** [DevicePage](#devicepage)

| Status | Meaning |
| --- | --- |
| 200 | A page of devices. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `forbidden`: Not permitted; `scope_required`: The key lacks a required scope |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/devices" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /devices/{id}

One device

**Scope:** `devices.read`

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

**Returns:** [Device](#device)

| Status | Meaning |
| --- | --- |
| 200 | The device. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

### PATCH /devices/{id}

Rename a device, or set its inventory sticker

**Scope:** `devices.write`

The two labels a customer owns: what the phone is called, and the sticker on the back of the handset.

The sticker is unique across the fleet, which is what lets it be used in place of a `public_id` anywhere a device is named. `null` clears it, and a clash is refused rather than quietly moved off the phone that had it.

Needs `devices.write` **and** `phones.edit` on the account that created the key, which is the same pair the dashboard asks for before it shows the field. A key that drives handsets all day cannot rename them.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Request body:** `application/json`

**Returns:** [Device](#device)

| Status | Meaning |
| --- | --- |
| 200 | The device as it now reads. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X PATCH "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

## Seeing the screen

### GET /devices/{id}/snapshot

Capture a screenshot

**Scope:** `devices.snapshot`

The current screen as a JPEG. Coordinates you read off this image are the ones to send to `/actions`; the API scales them to the device.

Rate limited harder than actions: each capture costs the box a full frame and a transcode. For continuous frames, stream instead of polling.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

| Status | Meaning |
| --- | --- |
| 200 | A JPEG screenshot. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 409 | `device_unprovisioned`: Device is not connected to a box |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |
| 503 | `device_offline`: Device unreachable |
| 504 | `kernel_timeout`: Device did not respond in time |

```bash
curl "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/snapshot" -H "Authorization: Bearer $ALI_API_KEY" --output screen.jpg
```

### GET /devices/{id}/stream

Live screen (WebSocket)

**Scope:** `devices.stream`

Connect with `wss://`. The server sends a JSON `ready` frame, then the screen as **binary JPEG frames**, one per message.

```
→ {"type":"ready","device":"ph_…","screen":{"width":405,"height":720},"max_fps":2}
→ <binary JPEG>  …
→ {"type":"closing","reason":"max_duration","reconnect":true}
← {"type":"ping"}   → {"type":"pong","t":…}
← {"type":"ack"}    optional; lets the device pace to what you actually render
```

**Frame rate is capped at 5/s**, default 2. This is not the rate the ALI console runs at: a person driving a pointer needs every frame, an integration checking a screen between actions does not, and the frames are relayed through us rather than served from the device directly.

**Reconnect.** Connections are closed at a duration ceiling and on any upstream interruption. A `closing` frame with `reconnect: true` precedes an expected close; reconnect with backoff.

**Limits.** 10 concurrent streams per key, and every minute open costs 20 credits from the daily pool, charged a minute at a time and reported under `stream_minutes` in `GET /usage`. A device that is not casting is refused: send a `recast` action first.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `fps` | query | no | integer (min 1, max 5, default 2) | Frames per second. Clamped to 5, never rejected. |
| `token` | query | no | string | Your API key, for clients that cannot set headers on a WebSocket handshake (browsers). Prefer the Authorization header: query strings end up in logs. |

| Status | Meaning |
| --- | --- |
| 101 | Switching protocols. Frames follow. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
# WebSocket. Frames arrive as binary JPEG messages, one per frame.
wscat -c "wss://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/stream?fps=2" \
  -H "Authorization: Bearer $ALI_API_KEY"
```

## Controlling a phone

### POST /devices/{id}/ip-rotation

Rotate the device's public IP

**Scope:** `devices.control`

A fresh public address, scored. Mobile data: an airplane-mode cycle. Proxy Wi-Fi: the router's link, which rotates every device on it, unscored. ALI Wi-Fi: `409 rotation_unavailable`.

Answers `202`; poll `GET` on this path until `status` leaves `running`. One rotation per device at a time.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Returns:** [IpRotation](#iprotation)

| Status | Meaning |
| --- | --- |
| 202 | The rotation has started. Poll `GET` for the outcome. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted |
| 404 | `device_not_found`: No such device |
| 409 | `device_unprovisioned`: Device is not connected to a box; `rotation_unavailable`: This device cannot rotate its IP; `rotation_in_progress`: An IP rotation is already running; `idempotency_in_flight`: The first request with this Idempotency-Key is still running |
| 422 | `idempotency_key_reused`: This Idempotency-Key was used for a different request |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/ip-rotation" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /devices/{id}/ip-rotation

The device's IP rotations

**Scope:** `devices.read`

This device's rotations, newest first.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

| Status | Meaning |
| --- | --- |
| 200 | Newest first. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/ip-rotation" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /devices/{id}/actions

Send an action to a device

**Scope:** `devices.control`

Perform one action. The body is a single object discriminated by `type`.

Every response carries `device_ack`. **200** with `device_ack: true` means the device confirmed it accepted the command. **202** with `device_ack: false` means the command was handed over and the outcome could not be confirmed, because the hardware serving that phone is mid-update; verify with a snapshot.

**What `device_ack: true` is worth.** It is the device saying it accepted the command. It is *not* a claim that the screen changed, and nothing can observe that for you.

**For a gesture it is worth noticeably less, and this document used to say the opposite.** A tap either lands or is refused. A swipe, a drag, a `swipe_direction`, a `scroll` and the flick inside `app_close` are each a press, a walk and a release, and every one of those can be accepted in full while the screen does not move at all: the phone has one pointer, it is shared with whatever else is driving that handset, and how far a walk actually travels is decided by hardware we can ask but not watch. So every gesture answers `effect_observed: false` beside its acknowledgement, and that field is the honest one. **Read the screen back with `GET /devices/{id}/snapshot` when a gesture matters**, rather than treating a 200 as movement.

For anything whose work continues after the call returns the acknowledgement is weaker still, and `recast` is the clear example: it answers `device_ack: true` in under a second while the phone may take another minute to come back, or never come back.

One thing an ack cannot tell you is *where* a tap landed. It is uncommon, but some handsets occasionally place one away from where it was aimed, and an unattended run that carries on from the wrong screen is the expensive version of that. If you see it, send `calibrate` and repeat the tap.

On a fleet where every box reports outcomes a 200 always carries `device_ack: true`. The field earns its place by being `false` on a 202, and by giving you one thing to branch on rather than two status codes to remember.

An action against a phone that is not casting is refused with `503 device_offline` rather than accepted. A phone that has stopped mirroring will still take a command and report success for work that never happened, so this endpoint checks first. Send `recast`, then retry.

**Some verbs need a helper that runs on the phone, and some do not. This is the single most useful thing to know about failures here.**

Taps, gestures, buttons, snapshots, `calibrate` and `recast` are driven through the handset's pointer and work on any phone that is casting. These verbs instead go through a small helper installed on the phone itself: `text` (beyond short plain ASCII, which goes over the keyboard), `clipboard_get`, `clipboard_set`, `open_url`, `wifi`, `cellular`, `airplane`, `torch`, `brightness` and `device_ip`. They are slower (several seconds is normal) and they can fail on a phone where everything else works.

When the helper does not answer in time you get `409 device_not_provisioned_for_action`. **The name is misleading and we are stuck with it for now** (it is a published code that clients branch on): it reports a *timeout*, not a provisioning state. **Read it as "the helper did not answer", not as "this key lacks a permission" and not as "this phone needs enabling for this one verb".** There is no per-verb provisioning, no scope that unlocks it, and nothing on the key-creation screen that affects it: a key with `devices.control` can already send every verb on this list.

Tell the two cases apart by trying **once** more, a minute later:

- **It succeeds.** The helper was busy, often with something else driving the same phone. Nothing is wrong.
- **It refuses again.** Stop, and send us the `X-Request-Id` and the phone's `inventory_id`. Retrying will not clear it and nothing on your key affects it.

**Do not build a retry loop around this code.** A phone whose helper is down answers the same way every time, and an unattended loop against a live account is exactly where this becomes expensive. Two attempts is the whole strategy.

⚠️ **`open_url` has a first-run cost per phone and per destination, and this is worth understanding before you build on it.** iOS asks the person holding the phone to approve each destination the first time that phone is asked to open it (*Allow "ALI Remote" to open "Instagram"?*). Until that prompt is answered the request simply waits, and at thirty seconds the box gives up and answers `device_not_provisioned_for_action`.

Measured on a live handset: first call to a new destination **30 s, refused**; once approved, the same call is **about 2 s** and stays that way. The approval is remembered **per phone and per destination**, so a phone that opens Instagram in two seconds will still spend thirty being refused the first time you ask it for TikTok.

Boxes on current builds answer that prompt themselves, so the first `open_url` to a new destination just runs a few seconds longer than every later one. On a box that has not picked that up yet the first call is still refused at thirty seconds; if the same phone and link keep refusing, tell us both and we will approve it at the rack. In the meantime `press home` plus a tap read off a snapshot reaches any app on the home screen in about a second, with no first-run cost.

A practical consequence worth planning for: because these verbs share one dependency, **one of them failing predicts the rest**. If you need to know whether a phone's helper is answering before you drive an account with it, send a `clipboard_get` first: it reads and changes nothing on the phone. It speaks for the helper, not for any one verb: `open_url` carries its per-destination approval on top, so a passing preflight promises the tier `open_url` runs on, not `open_url` itself.

`send_binding_key` is **not part of the control surface** and is listed here only because the catalogue is generated from the schema that validates it. It is a one-time step in preparing a handset, performed by ALI staff while somebody is watching that phone: it answers `403 forbidden` on any customer credential, whatever scopes that credential holds, because the check is against the account behind it rather than against the key. There is nothing to reach for here in any case, since everything it prepares a phone for already has a verb of its own (`text`, the clipboard pair, the radio switches).

**Swipes are one gesture, drawn the way a hand draws it.** Every gesture here is the one the dashboard's arrows send: the finger is placed, pressed, rested, walked in sixteen steps and released at the end point, a little over half a second in all. `swipe` and `drag` take the finger from (`x1`,`y1`) to (`x2`,`y2`), and differ only in whether the content is allowed to keep moving afterwards. `swipe_direction` names where the **finger** goes: `up` drags from the bottom of the screen towards the top, which scrolls the content down. It runs from 80 % to 20 % of the axis by default, centred on the other one; `distance` shortens or lengthens that, as a fraction of the axis or as `{ "px": N }` in snapshot pixels, always centred on the screen and never within 5 % of an edge; it needs the phone to have reported its screen size, and is refused rather than quietly drawn at the default page while it has not. `scroll` names where the **view** goes: it starts the finger at `x`,`y` and moves it against `dy`, so a positive `dy` scrolls down and a negative one scrolls up, by exactly that many snapshot pixels. Anchored close to an edge the whole line slides inwards rather than being cut short, so the finger always travels the full `dy`. A `dy` of **0 is refused** rather than treated as a no-op: a finger that presses and lifts without travelling is a tap on whatever is under `x`,`y`.

**`precise` decides whether that line is drawn twice the same way.** A hand does not scroll a phone from the same pixel at the same speed sixty times in a row, so a swipe we lay out ourselves is varied: the whole line sits 5 to 15 % off to one side of where it would have been (sideways for an up or down swipe, up or down for a sideways one), each end moves along the axis by 5 to 15 % of the travel, and the walk runs 5 to 15 % faster or slower. The shape does not change: same direction, same sixteen steps, both ends still at least 5 % clear of the edges iOS owns. Only where it is drawn, and how quickly.

The default follows one rule: **if you gave us the numbers we draw them, and if you only asked us for a page we draw it like a hand.** So `swipe`, `drag` and `scroll` are exact, because their coordinates are yours and an icon has to land where you put it, and so is a `swipe_direction` carrying a `distance`, because that is a measurement you gave us and one we refuse to guess at without a screen. A `swipe_direction` with no `distance` names nothing but a direction, so the whole line is ours and it is varied. `precise: true` turns the variation off, `precise: false` turns it on, on any of the four. Varying a swipe is measured against the phone's screen size, so `precise: false` is refused with `503 device_offline` on a phone that has not reported one yet, exactly as `distance` is; the default silently draws the box's own page instead, since that is a swipe drawn a little differently rather than one that did not happen. The dashboard varies every swipe it sends and offers no switch: nobody driving a phone by hand is trying to hit a coordinate.

**Deprecated: `duration_ms`, `span`, `steps`, `step_ms`, `stepping`, `step_sleep` and `brake`.** These pacing and sizing knobs are still accepted on `swipe`, `drag` and `swipe_direction` for one release so existing scripts keep working, and they are **ignored**: every gesture takes the walk described above, which is the one that measurably lands, varied only as `precise` decides. They will be refused in the release after this one. Use `distance` on `swipe_direction` where `span` was used.

`calibrate` puts the phone's pointer back in a known place. It is the one verb here that fixes a class of problem rather than doing something: a tap that lands off-target takes an unattended run somewhere it cannot recover from, and re-centring is the remedy. Send it when you see that happening, or once at the top of a sequence that will tap a lot. **Not before every tap.** It is a round trip of its own, about a second, and one action off the budget, so a tap-calibrate pair doubles both for a fault most handsets do not have. It changes nothing on screen, so a screenshot either side of it is identical, and it is one of only two verbs that work on a phone that is not casting.

`app_close` force-closes the app in front, the way a person does it: it opens the App Switcher and flicks that app's card up off the screen. Open the app you mean first, then call it; it closes whatever is in front and does not choose, so do not send it from a screen you have not read. It takes no fields at all, and is the one verb here that **refuses** a body carrying any: `{"type":"app_close","url":"instagram://"}` comes back as `invalid_action` rather than being read as "close whatever is in front" and answered with a 200 for the wrong app. It is pointer-driven and does **not** use the on-device helper, so it works on any casting phone even where that helper is not answering, which is the point of it.

You can do the same by hand with `press` `appswitcher` and then a `swipe` from the card up past the top of the screen, but the card you have to hit is not where most people assume: with more than one app open the front app's card is at the **right** of the switcher and the middle holds the app *before* it, so a flick up the centre line force-closes a bystander. `app_close` is that sequence done correctly. Both the switcher and `lock` ride a spare mouse button iOS binds to the system action rather than a keyboard combination, so they no longer depend on Full Keyboard Access being on, which it is not across the fleet.

It takes a few seconds and it checks its own work: it reads the screen again before answering, so a 200 means the app that was in front is no longer the one showing. **How firm that is depends on the app.** The check is a comparison of the whole frame, so an app that is animating anyway - a video, an autoplaying feed, a spinner - satisfies it whether or not its card went, and on those a 200 is worth about what a gesture's is. On a still screen it is the real thing. Read the screen back with `GET /devices/{id}/snapshot` when the close matters and the app was moving. It still carries `effect_observed: false` like every other gesture, because "no longer in front" is what can be seen and "terminated" is not. It is refused rather than reported as closed in two cases, both arriving as `command_rejected`: a phone whose App Switcher has never been bound, where nothing was touched, and a close that ran and left the same app on screen. Neither is a retry, whatever the generic `detail` on that answer says: with more than one app open the first flick may already have thrown a different app's card, and sending it again throws another. Read the screen back with `GET /devices/{id}/snapshot` instead.

`recast` re-establishes dropped screen mirroring and is the fix for a phone that is online but not casting. It is expensive: on a phone that is already casting the session is bounced, and the screen is gone while it reconnects. Budget **up to two minutes** and poll `casting` rather than assuming a fixed wait; 15 to 30 seconds is the good case and 75 seconds has been measured on a healthy phone. Never call it speculatively.

It answers as soon as the request has been accepted, so a 200 here says the reconnect *started*. Poll `GET /devices/{id}` until `casting` is true before driving the phone, and treat a phone still dark after two minutes as needing a look rather than another `recast`.

**Retrying safely.** Send an `Idempotency-Key` header, any string up to 255 characters, unique per action you intend to perform. A retry carrying the same key replays the first response instead of tapping the phone again, and comes back with `Idempotent-Replay: true`. Reusing a key for a different body is refused with `idempotency_key_reused` rather than answered from the cache, and a retry that arrives while the first is still running gets `idempotency_in_flight`. Keys are honoured for 24 hours and scoped to your API key.

`times` sends a press more than once inside one request. If the sequence stops early, because a press failed or because the request ran out of time, `repeated` reports how many actually landed and only those are charged. On a failure the same count is in the error detail, so a retry can pick up where it stopped.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Request body:** `application/json`

**Returns:** [ActionResult](#actionresult)

Each action is one object, chosen by `type`. `*` marks a required field.

| type | Fields |
| --- | --- |
| `tap` | `x`* integer (min 0, max 10000), `y`* integer (min 0, max 10000) |
| `long_press` | `x`* integer (min 0, max 10000), `y`* integer (min 0, max 10000), `hold_ms` integer (min 0, max 10000, default 600) |
| `swipe` | `x1`* integer (min 0, max 10000), `y1`* integer (min 0, max 10000), `x2`* integer (min 0, max 10000), `y2`* integer (min 0, max 10000), `precise` boolean, `duration_ms` number, `steps` number, `step_ms` number, `stepping` number, `step_sleep` number, `brake` boolean |
| `drag` | `x1`* integer (min 0, max 10000), `y1`* integer (min 0, max 10000), `x2`* integer (min 0, max 10000), `y2`* integer (min 0, max 10000), `precise` boolean, `duration_ms` number, `steps` number, `step_ms` number, `stepping` number, `step_sleep` number, `brake` boolean |
| `swipe_direction` | `direction`* `up` \| `down` \| `left` \| `right`, `distance` , `span` number, `precise` boolean, `duration_ms` number, `steps` number, `step_ms` number, `stepping` number, `step_sleep` number, `brake` boolean |
| `scroll` | `x`* integer (min 0, max 10000), `y`* integer (min 0, max 10000), `dy`* integer (min -10000, max 10000), `precise` boolean |
| `text` | `text`* string (min length 1, max length 4096) |
| `press` | `name`* `home` \| `appswitcher` \| `lock` \| `screenshot` \| `enter` \| `keyboard` \| `controlcenter` \| `notifications` \| `volumeup` \| `volumedown` \| `mute` \| `backspace` \| `tab` \| `up` \| `down` \| `left` \| `right`, `times` integer (min 1, max 20, default 1) |
| `open_url` | `url`* string (min length 1, max length 2048) |
| `clipboard_set` | `text`* string (max length 4096) |
| `clipboard_get` | none |
| `airplane` | `on`* boolean |
| `device_ip` | `public` boolean |
| `wifi` | `on`* boolean |
| `cellular` | `on`* boolean |
| `torch` | `on`* boolean |
| `brightness` | `level`* number (min 0, max 1) |
| `recast` | none |
| `calibrate` | none |
| `app_close` | none |
| `send_binding_key` | none |

| Status | Meaning |
| --- | --- |
| 200 | Performed, and the device confirmed it (`device_ack: true`). Most actions return only that; `clipboard_get` adds `{ clipboard }` and `device_ip` the address. |
| 202 | Handed to the box, outcome unconfirmed (`device_ack: false`). The box hosting this device is on a build that cannot report the result. Verify with a snapshot. |
| 400 | `invalid_action`: The action failed validation |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted |
| 404 | `device_not_found`: No such device |
| 409 | `device_unprovisioned`: Device is not connected to a box; `device_not_provisioned_for_action`: The phone's on-device helper did not answer in time; `device_in_use`: Device in use; `idempotency_in_flight`: The first request with this Idempotency-Key is still running |
| 422 | `command_rejected`: The device rejected the command; `idempotency_key_reused`: This Idempotency-Key was used for a different request |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |
| 503 | `device_offline`: Device unreachable |
| 504 | `kernel_timeout`: Device did not respond in time |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/actions" \
  -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"tap","x":229,"y":400}'
```

### GET /devices/{id}/control-session

See who is driving a device

**Scope:** `devices.read`

Whether the phone is free, held by a person, or held by an automation, and since when.

The read that makes the rest of this section usable. Without it, the only way to learn a phone was busy was to be refused mid-run, which is late: a scheduler wants to skip a busy handset and come back to it, not fail a step halfway through a sequence and unwind what it already did.

`yours` is true when this key is the holder, so a caller can tell somebody-else-has-it from I-have-it without comparing names.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

| Status | Meaning |
| --- | --- |
| 200 | Who holds it now. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/control-session" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /devices/{id}/control-session

Declare that an automation is driving a device

**Scope:** `devices.control`

Tells the dashboard a script has this phone, and what to call it. Anyone who opens the device sees the label and starts as a spectator.

Every control call already takes a lease lasting about 30 seconds, so an integration that never calls this is still protected from somebody typing over it. Declare a session for the two things that lease cannot do: give the automation a name a person can act on, and hold the phone through the gaps when the script is thinking rather than tapping.

It is not a lock. A person can take the phone deliberately, from a dialog naming this session, and the next control call then answers `device_in_use`, which is how the automation learns to stop instead of tapping into somebody's work. Release it when done; it also expires on its own.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

**Request body:** `application/json`, [ControlSessionRequest](#controlsessionrequest)

| Status | Meaning |
| --- | --- |
| 200 | The phone is yours until `expires_at`, or until a person takes it. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 409 | `device_in_use`: Device in use |
| 429 | `rate_limited`: Too many requests |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/control-session" -H "Authorization: Bearer $ALI_API_KEY"
```

### DELETE /devices/{id}/control-session

End an automation's session

**Scope:** `devices.control`

Lets people back on immediately rather than when the lease lapses. A no-op if somebody has already taken the phone.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

| Status | Meaning |
| --- | --- |
| 200 | Released. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests |

```bash
curl -X DELETE "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/control-session" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /devices/{id}/control-session/takeover

Take a device somebody else is driving

**Scope:** `devices.control`

Take a phone that somebody else is currently driving.

Control of a phone is a lease: one holder at a time, everybody else watches. Until this endpoint existed an automation that found a phone busy could only give up, and the commonest reason a phone is busy is a console somebody left open on a second monitor, which nothing will ever clear.

**What happens depends on who is holding it.**

- **Nobody, or this same key.** Taken straight away.
- **Another automation.** Taken straight away. This mirrors the rule the dashboard already applies in the other direction, where a person may take a phone off a script without asking.
- **A person.** They are asked. Their console shows the request and they have about three seconds to refuse; silence is a yes, which is what stops an open tab holding a phone all afternoon. **This request blocks for those three seconds**, so allow for it in your client timeout.
- **The account this key belongs to.** Taken straight away, and nobody is asked. A credential is not somebody else, and asking would leave an automation stuck behind its own owner's open tab with no way through. This is the one case where `asked` is false and `taken_from.kind` is `person`.

A refusal answers `409 device_in_use`, and asking again immediately is refused for longer each time: five seconds, then fifteen, then a minute. The four refusals share that one code and are told apart by `detail`: a person who said no, a cooldown still running, somebody else already mid-ask, and an ask that did not settle. Treat any of them as a phone somebody needs and move on to another one.

**Who does the asking.** The account that created the API key, so the dialog names a colleague rather than a credential nobody recognises. If that account has left the agency the request answers `key_orphaned`: mint a replacement key under somebody who is still there.

**It is not a lock in the other direction either.** Whoever was taken off keeps watching and can take the phone straight back, at which point your next control call answers `device_in_use`. That is the signal to stop rather than to retry.

Call `GET /devices/{id}/control-session` first if you would rather skip a busy phone than interrupt one.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

**Request body:** `application/json`, [ControlTakeoverRequest](#controltakeoverrequest)

| Status | Meaning |
| --- | --- |
| 200 | The device is yours until `expires_at`. `taken_from` says who was on it, and `asked` whether a person had to be asked. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 404 | `device_not_found`: No such device |
| 409 | `device_in_use`: Device in use |
| 429 | `rate_limited`: Too many requests |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/control-session/takeover" -H "Authorization: Bearer $ALI_API_KEY"
```

## Sending files

### POST /devices/{id}/media

Start sending a file to a device

**Scope:** `media.write`

Uploading is three steps, and the bytes never pass through the API:

1. `POST /devices/{id}/media` with the file's `filename`, `mime` and `size_bytes` → **201** with an `upload_url`.
2. `PUT` the raw bytes to `upload_url` before `upload_expires_at` (`curl -X PUT --data-binary @photo.jpg "$upload_url"`). If you set a `Content-Type`, it must be the one you declared.
3. `POST /devices/{id}/media/{asset_id}/complete` → **202**. Storage is checked for what actually arrived, and the delivery to the phone is queued.

The upload URL points directly at storage, which is what makes the 100 MB limit real: a body routed through the API itself would be capped at 4.5 MB by the platform, regardless of what this document promised.

Delivery then runs on a queue: putting a file onto a physical handset takes minutes rather than milliseconds. Poll `GET /devices/{id}/media/{asset_id}` for the outcome, and read `error_code` beside `error` when it ends `failed` if you want something stable to branch on.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per upload you intend to start. A retry with the same key replays the first response and answers with `Idempotent-Replay: true`. Honoured for 15 minutes here rather than 24 hours, because that is how long the `upload_url` it replays stays valid; after that the same key starts a fresh upload. |

**Request body:** `application/json`, [UploadRequest](#uploadrequest)

**Returns:** [UploadTicket](#uploadticket)

| Status | Meaning |
| --- | --- |
| 201 | A slot is reserved. PUT the bytes to `upload_url` before it expires, then call `/complete`. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device |
| 409 | `device_unprovisioned`: Device is not connected to a box |
| 413 | `payload_too_large`: Payload too large |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
# 1. describe the file; a presigned URL comes back
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/media" \
  -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"photo.jpg","mime":"image/jpeg","size_bytes":248000}'

# 2. PUT the bytes straight to that URL
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg

# 3. confirm, which queues delivery to the phone
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/media/$ASSET_ID/complete" -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

### POST /devices/{id}/media/{asset_id}/complete

Finish an upload and queue the delivery

**Scope:** `media.write`

Call after PUTting the bytes to `upload_url`. The vault verifies what actually arrived: size and type against what was declared, then queues the delivery to the phone. Answers **202**; poll `GET /devices/{id}/media/{asset_id}` for the outcome.

Calling it again re-queues the delivery, so it doubles as the retry.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `asset_id` | path | yes | string |  |
| `album` | query | no | string | Album to file it under on the phone. |

**Returns:** [UploadAccepted](#uploadaccepted)

| Status | Meaning |
| --- | --- |
| 202 | Verified and queued. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device; `not_found`: Not found |
| 409 | `device_unprovisioned`: Device is not connected to a box; `device_in_use`: Device in use |
| 429 | `rate_limited`: Too many requests |
| 502 | `upload_failed`: Upload could not be completed |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/media/ASSET_ID/complete" -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

### DELETE /devices/{id}/media/{asset_id}

Take a file back off a device

**Scope:** `media.write`

Remove a file this API put on the phone.

Asynchronous, for the same reason the upload is: the work happens on the handset itself. Answers **202** with `removing`; poll `GET` on this path until `removed`.

A removal is confirmed by reading the phone's album back afterwards, never by trusting the result of the delete itself. A removal that cannot be confirmed is reported as still delivered rather than quietly marked clean.

Removing something already gone answers `404 not_found`, which makes a repeat safe. A removal asked for while the file is still on its way to the phone answers `command_rejected`: wait for the delivery to settle first.

**One-time consent.** The first removal on a handset can hang and fail until somebody answers Apple's "Delete Always" prompt on that phone once. Until then the file stays put, and the error says so rather than reporting a clean removal.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `asset_id` | path | yes | string |  |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

| Status | Meaning |
| --- | --- |
| 202 | Removal queued. Poll `GET` on this same path until `removed`. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `device_not_found`: No such device; `not_found`: Not found |
| 409 | `device_unprovisioned`: Device is not connected to a box |
| 422 | `command_rejected`: The device rejected the command |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X DELETE "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/media/ASSET_ID" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /devices/{id}/media/{asset_id}

Whether an upload landed

**Scope:** `devices.read`

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |
| `asset_id` | path | yes | string |  |

**Returns:** [Delivery](#delivery)

| Status | Meaning |
| --- | --- |
| 200 | The delivery. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/media/ASSET_ID" -H "Authorization: Bearer $ALI_API_KEY"
```

## Automations

### GET /automations

List automations

**Scope:** `automations.read`

An automation is a flow of steps that drives phones on a schedule, on an event, or when you start one.

Until now these scopes had nothing behind them over HTTP: the feature was reachable from the dashboard canvas and from an assistant, and a customer reading `automations.read` in the scope list was being shown a promise this API did not keep. These endpoints are the same store both of those call, so a flow built here is the flow the canvas draws and the scheduler runs.

**How a flow is built.**

1. `POST /automations` for an empty one. It starts turned off and unpublished, so creating it can never start anything.
2. `GET /automation-blocks` for the steps that exist, with their settings and the ports their edges leave by. Do not guess these.
3. `PATCH /automations/{id}` with the whole `graph`. A draft is saved even when it is not yet valid, and the answer lists every problem at once rather than the first, so a flow can be built over several calls.
4. `POST /automations/{id}/publish` to make the draft the version that runs. **This is the step that refuses a broken flow**, which is what keeps a half-built automation off a schedule.
5. `POST /automations/{id}/triggers` to say when it should run, then `PATCH` with `enabled: true`.

**What a run reaches.** A run acts with the standing of the account that created the API key, so it drives the phones that person drives and no others. Everything it does spends the same credits the same actions would over `/devices`.

**Watching one.** Starting a run answers **202** and a `run_id`; it does not finish while you wait, because it drives physical phones. `GET /automation-runs/{id}` reports every step in order, which phone each acted on, and why any was skipped.

| Status | Meaning |
| --- | --- |
| 200 | Every automation in the agency, with whether it is published, whether it is on, and how its last run went. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automations" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /automations

Create an automation

**Scope:** `automations.write`

Creates an empty automation with a name. It starts turned off and unpublished, so nothing runs until you save steps with `PATCH` and then publish it. `name` is capped at 120 characters and `description` at 2000.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Request body:** `application/json`

| Status | Meaning |
| --- | --- |
| 201 | Created, turned off and unpublished. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/automations" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /automations/{id}

Get one automation

**Scope:** `automations.read`

Its published steps, its unpublished draft if there is one, its triggers and its version history. Read this before changing a flow, so you edit what is actually there.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |

| Status | Meaning |
| --- | --- |
| 200 | The automation. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

### PATCH /automations/{id}

Rename, switch on, or save a new draft

**Scope:** `automations.write`

`graph` replaces the draft rather than merging into it, so send the whole flow; read `GET /automation-blocks` first.

A draft is saved even when it is not yet valid. The answer carries `draft.problems`, every problem at once rather than the first, and `publishable`. Nothing saved here runs until you publish it.

`enabled: true` is refused until something has been published, because a switch that reports on and does nothing is worse than a refusal.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |

**Request body:** `application/json`

| Status | Meaning |
| --- | --- |
| 200 | Saved. `draft.problems` is empty when it is ready to publish. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X PATCH "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

### DELETE /automations/{id}

Delete an automation

**Scope:** `automations.write`

Deletes it and everything it recorded: versions, triggers and run history. This cannot be undone. To stop it running, `PATCH` with `enabled: false` instead.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |

| Status | Meaning |
| --- | --- |
| 200 | Gone. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X DELETE "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /automations/{id}/publish

Publish the current draft

**Scope:** `automations.write`

Makes the draft the version that runs. This is the step that checks the whole flow and refuses if anything is wrong, so a broken automation can never end up on a schedule. Runs already in progress keep the version they started with.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

| Status | Meaning |
| --- | --- |
| 200 | Published. This version is what triggers now run. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26/publish" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /automations/{id}/runs

List this automation's runs

**Scope:** `automations.read`

Newest first, with how each one ended.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |

| Status | Meaning |
| --- | --- |
| 200 | Recent runs. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26/runs" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /automations/{id}/runs

Start a run now

**Scope:** `automations.write`

Answers **202** with a `run_id` as soon as the run is created. It does not finish while you wait, because it drives physical phones: poll `GET /automation-runs/{id}`.

The run acts with the standing of the account behind the key, so it reaches the phones that person reaches and no others.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

| Status | Meaning |
| --- | --- |
| 202 | Started. Poll the run for how it goes. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26/runs" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /automations/{id}/triggers

Add a trigger

**Scope:** `automations.write`

`manual` for by hand only, `schedule` with a five-field `cron` such as `0 2 * * *` read in the agency's timezone unless you name one, or `event` with an `event_type` for when a phone changes state.

An automation with no trigger can still be started with `POST /automations/{id}/runs`.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Request body:** `application/json`

| Status | Meaning |
| --- | --- |
| 201 | Added. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26/triggers" -H "Authorization: Bearer $ALI_API_KEY"
```

### DELETE /automations/{id}/triggers/{trigger_id}

Remove a trigger

**Scope:** `automations.write`

Stops the automation being started that way. Its other triggers are left alone.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The automation's id, from `GET /automations`. |
| `trigger_id` | path | yes | string (uuid) | The trigger's id, from `GET /automations/{id}`. |

| Status | Meaning |
| --- | --- |
| 200 | Removed. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X DELETE "https://beta.aliremote.com/api/v1/automations/ph_0b491ebdeb254769ab26/triggers/{trigger_id}" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /automation-blocks

List the steps a flow can be built from

**Scope:** `automations.read`

Every kind of step, with what it does, what settings it takes and which ports its edges may leave by. Read this before writing a `graph`: it is the only way to know what exists, and it is filtered to what this key's account is allowed to use, so a step listed here is one you may actually publish.

| Status | Meaning |
| --- | --- |
| 200 | The catalogue, and one paragraph on how a flow is wired. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automation-blocks" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /automation-runs

List recent runs

**Scope:** `automations.read`

Across every automation, newest first. The one call that answers whether last night worked.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `automation_id` | query | no | string (uuid) | Only this automation's runs. |
| `limit` | query | no | integer (min 1, max 200) | Defaults to 50. |

| Status | Meaning |
| --- | --- |
| 200 | Recent runs. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automation-runs" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /automation-runs/{id}

Get one run, step by step

**Scope:** `automations.read`

Every step in order, which phone each acted on, and why any was skipped or failed. This is what to read when somebody asks why an automation did not do what they expected.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The run's id. |

| Status | Meaning |
| --- | --- |
| 200 | The run and its steps. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/automation-runs/ph_0b491ebdeb254769ab26" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /automation-runs/{id}/cancel

Stop a run

**Scope:** `automations.write`

Stops a run that is still going. It stops at its next step rather than mid-action, so a phone is never left half way through something. A run that has already finished answers `not_found` rather than pretending to stop.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The run's id. |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

| Status | Meaning |
| --- | --- |
| 200 | It will stop at its next step. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/automation-runs/ph_0b491ebdeb254769ab26/cancel" -H "Authorization: Bearer $ALI_API_KEY"
```

## Organising your rack

### GET /groups

List your groups

**Scope:** `devices.read`

Groups are your own filing of your own phones: a label, and the phones in it.

There is no create, rename or delete endpoint, and that is the product rather than a gap. A group is created by naming it on a phone (`new_group_name`) and swept away when the last phone leaves it, which is what stops an agency accumulating empty groups somebody made once. The dashboard works the same way.

Counts are of every phone in the group, including ones this key cannot reach.

| Status | Meaning |
| --- | --- |
| 200 | Every group in the agency. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/groups" -H "Authorization: Bearer $ALI_API_KEY"
```

### PUT /devices/{id}/groups

Set the groups a device is in

**Scope:** `groups.write`

The complete set, never a delta: whatever you send is what the phone ends up in, so a client working from a stale read cannot silently drop a group it never knew about. `{"group_ids": []}` takes it out of everything.

`new_group_name` is the only way to create a group. A group left with no phones stops existing, which is how they are cleaned up. There is no delete.

Answers with the agency's whole group list, because one write can both create a group and empty another away, so every count may have moved.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string | The device's `public_id`, or its sticker (`inventory_id`). |

**Request body:** `application/json`, [SetDeviceGroups](#setdevicegroups)

| Status | Meaning |
| --- | --- |
| 200 | The agency's groups, after the change. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `device_not_found`: No such device |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
# The complete set. An empty array files the phone under nothing.
curl -X PUT "https://beta.aliremote.com/api/v1/devices/ph_0b491ebdeb254769ab26/groups" \
  -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group_ids":[],"new_group_name":"Night shift"}'
```

## Analytics

### GET /analytics/summary

Agency totals and the daily trend

**Scope:** `analytics.read`

What the agency did over the period: worked minutes, how many people and phones were active, gestures sent, and a point per day.

Figures come from the same rollups the dashboard reads, so the two always agree. Days are drawn on the agency's timezone rather than UTC (`period.timezone` says which), and the retention window bounds how far back `days` can reach.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `days` | query | no | integer (min 1, max 365, default 7) | How many days back to report, ending today. Refused rather than clamped if out of range: a range is the meaning of every number in the response, so silently answering for 365 days when you asked for more would be wrong in a way nothing in the payload reveals. |

**Returns:** [AnalyticsSummary](#analyticssummary)

| Status | Meaning |
| --- | --- |
| 200 | Totals and trend. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/analytics/summary?days=30" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /analytics/members

What each person did

**Scope:** `analytics.read`

Per-person desk minutes, worked minutes, phones touched, sittings and gestures, with the last time each was active.

`presence_minutes` is desk time, with overlaps merged so six phones driven at once is one minute; `active_minutes` is the part of it that carried a gesture. An agency paying by the hour and an agency measuring effort want different ones, and neither can be derived from the other.

`untrusted_minutes` is the part of `active_minutes` that arrived too late to corroborate against a second signal. It is included in the total, and reported separately so a payroll integration knows which part is soft.

`?include=devices` adds a `devices` array to each member: which phones their minutes were spent on, from a daily rollup kept for years. Off by default because it multiplies the response by the rack rather than by the team. That half is limited to the phones this key reaches, since it names handsets, and its `active_minutes` are phone-minutes: they legitimately sum to more than the member's own, because driving two handsets in one minute is one minute worked and two phones used. For the times rather than the totals, read `GET /analytics/sessions`.

Figures come from the same rollups the dashboard reads, so the two always agree. Days are drawn on the agency's timezone rather than UTC (`period.timezone` says which), and the retention window bounds how far back `days` can reach.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `days` | query | no | integer (min 1, max 365, default 7) | How many days back to report, ending today. Refused rather than clamped if out of range: a range is the meaning of every number in the response, so silently answering for 365 days when you asked for more would be wrong in a way nothing in the payload reveals. |
| `include` | query | no | `devices` | Comma-separated optional sections. `devices` is the only one today. An unknown name is refused rather than ignored, so a typo reads as an error instead of as a missing feature. |

**Returns:** [MemberUsageReport](#memberusagereport)

| Status | Meaning |
| --- | --- |
| 200 | One row per member. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/analytics/members?days=30" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /analytics/devices

What each phone carried, and which carried nothing

**Scope:** `analytics.read`

Per-phone activity, plus `untouched`, the phones you hold that nobody used in the period. That is the line item that costs money.

**Limited to the phones this key reaches**, unlike the other analytics endpoints. Those report agency totals and name no phone; this one is an inventory listing with numbers attached, so a key scoped to three handsets sees three.

Figures come from the same rollups the dashboard reads, so the two always agree. Days are drawn on the agency's timezone rather than UTC (`period.timezone` says which), and the retention window bounds how far back `days` can reach.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `days` | query | no | integer (min 1, max 365, default 7) | How many days back to report, ending today. Refused rather than clamped if out of range: a range is the meaning of every number in the response, so silently answering for 365 days when you asked for more would be wrong in a way nothing in the payload reveals. |

**Returns:** [DeviceUsageReport](#deviceusagereport)

| Status | Meaning |
| --- | --- |
| 200 | One row per reachable phone. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/analytics/devices?days=30" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /analytics/sessions

Who was on which phone, and when

**Scope:** `analytics.read`

One row per person per phone per stretch: `username` was on `device_name` from `started_at` to `ended_at`. The grain the other reports aggregate away, published raw so you can roll it up whichever way your business asks.

This is the report to reach for when something has gone wrong on one handset and the question is who was driving it. `/analytics/members` says somebody touched ten phones and `/analytics/devices` says a phone carried an hour; neither says which ten, or whose hour.

**Limited to the phones this key reaches**, like `GET /analytics/devices` and unlike the agency totals: it names handsets.

**Paginated, newest first.** A sitting is written every time somebody opens a phone, so a busy agency produces thousands a week. Pass `next_cursor` back as `?cursor=` until it comes back null.

**Two retention lines, and they are different.** Sittings themselves are kept for `retention_days`. The per-minute grain that `active_minutes` and `gestures` are computed from is kept for 8 days, so a sitting that started before `activity_from` reports both as `null` rather than as zero. For totals older than that, read `GET /analytics/members?include=devices`, which comes from a daily rollup kept for years.

**`active_minutes` can exceed `minutes` on a short sitting**, and that is not a bug: the minute is the grain, so a stretch from 09:29:50 to 09:30:10 touches two buckets and rounds to zero wall-clock minutes. A minute shared by two sittings is counted once, against the later one, so the sessions never sum to more than the day.

A sitting by ALI support carries `support: true` with a null `member_id` and `username`. That somebody from ALI was on your phone is yours to know; which of us it was is not.

Figures come from the same rollups the dashboard reads, so the two always agree. Days are drawn on the agency's timezone rather than UTC (`period.timezone` says which), and the retention window bounds how far back `days` can reach.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `days` | query | no | integer (min 1, max 365, default 7) | How many days back to report, ending today. Refused rather than clamped if out of range: a range is the meaning of every number in the response, so silently answering for 365 days when you asked for more would be wrong in a way nothing in the payload reveals. |
| `cursor` | query | no | string | The `next_cursor` from the previous page. |
| `limit` | query | no | integer (min 1, max 500, default 100) | Clamped rather than refused, unlike `days`: a page size is a hint and serving fewer rows costs you one more round trip, while a range changes what every figure in the body means. |

**Returns:** [SessionPage](#sessionpage)

| Status | Meaning |
| --- | --- |
| 200 | A page of sittings. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/analytics/sessions?days=30" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /analytics/activity

Worked minutes by hour of the week

**Scope:** `analytics.read`

`cells[day][hour]`, Monday first, always 7 rows of 24, so a quiet hour is a zero rather than a missing entry and you can index it without bounds checks. `peak` is the busiest cell, for scaling a colour ramp in one pass.

The week is the agency's local one. Figures come from the same rollups the dashboard reads, so the two always agree. Days are drawn on the agency's timezone rather than UTC (`period.timezone` says which), and the retention window bounds how far back `days` can reach.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `days` | query | no | integer (min 1, max 365, default 7) | How many days back to report, ending today. Refused rather than clamped if out of range: a range is the meaning of every number in the response, so silently answering for 365 days when you asked for more would be wrong in a way nothing in the payload reveals. |

**Returns:** [ActivityReport](#activityreport)

| Status | Meaning |
| --- | --- |
| 200 | A 7 × 24 grid of minutes. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/analytics/activity?days=30" -H "Authorization: Bearer $ALI_API_KEY"
```

## People and roles

### GET /members

List the people in your agency

**Scope:** `members.read`

Everybody with an account in this agency, and the roles each of them holds.

Unpaginated, unlike `GET /devices`: an agency's seat count is capped in the low tens by its plan, so there is no second page to fetch.

| Status | Meaning |
| --- | --- |
| 200 | Every member. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/members" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /members/{id}

One member

**Scope:** `members.read`

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The member's id, from `GET /members`. |

**Returns:** [Member](#member)

| Status | Meaning |
| --- | --- |
| 200 | The member. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/members/8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10" -H "Authorization: Bearer $ALI_API_KEY"
```

### PATCH /members/{id}

Set which roles a member holds

**Scope:** `members.write`

Set the complete list of roles somebody holds. Never a delta: whatever you send is what they end up with, so a client working from a stale read cannot silently drop a role it never knew about. An empty array leaves the account able to do nothing.

The only write in this API that changes what a person can do, and three things bound it:

- **A key never outranks its creator.** It acts as whoever minted it, so it can only hand out a role that person could hand out by hand: at or below their own standing, carrying nothing they do not already hold. Read `assignable` on `GET /roles`.
- **It cannot act on somebody at or above that standing.** A member who outranks the key's creator, a peer of theirs, and the creator themselves all read back as `404`: the same answer as an id that does not exist, because a refusal that explained itself would confirm which ids are senior accounts.
- **So an agency cannot be left without an owner through this endpoint**, since taking the last owner's role away would mean acting on a peer or on yourself. Changing an owner is a dashboard operation.

Nothing else about a member is writable here. Usernames, addresses and two-factor belong to the person, not to the agency, and are theirs to change.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The member's id, from `GET /members`. |

**Request body:** `application/json`, [SetMemberRoles](#setmemberroles)

**Returns:** [Member](#member)

| Status | Meaning |
| --- | --- |
| 200 | The member, with their new roles. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
# The complete set of roles. Ids come from GET /roles.
curl -X PATCH "https://beta.aliremote.com/api/v1/members/8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10" \
  -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"role_ids":["8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10"]}'
```

### GET /roles

List the roles in your agency

**Scope:** `members.read`

Every role, what it allows, and whether **this key** may hand it out.

Read `assignable` before inviting somebody: a key carries the standing of whoever created it, so it can only grant a role that person could grant. Checking here is the difference between choosing a role you can use and finding out from a 403 after you have already picked an address.

Read-only. Creating a role means choosing a set of permissions, which is a decision for a screen that explains what each one does.

| Status | Meaning |
| --- | --- |
| 200 | Every role in the agency. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/roles" -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /invitations

List invitations

**Scope:** `members.read`

Every invitation this agency has issued, including the settled ones: accepted, expired, withdrawn. "Did that invitation ever land" is the question this list answers, and one that hid everything but the live ones would answer it with silence.

| Status | Meaning |
| --- | --- |
| 200 | Every invitation. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/invitations" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /invitations

Invite somebody into your agency

**Scope:** `members.write`

Invite somebody into your agency. The account is created when they accept, never here. This writes an invitation and sends a link, so no password is chosen, and no seat is taken until somebody takes it.

The invitation names a role, from `GET /roles`. A key can only hand out a role its creator could, so check `assignable` before choosing one.

An address that already signs in anywhere is refused with `invalid_request`: a person belongs to exactly one agency, and moving somebody between agencies is a separate, deliberate operation. So is a second live invitation to an address that already has one: withdraw or resend the first instead.

The link expires. `POST /invitations/{id}/resend` issues a fresh one and invalidates the old.

**Retrying safely.** Send an `Idempotency-Key`. It matters less here than on an action, because a duplicate invitation is already refused by a uniqueness rule; what it buys is that a client retrying a timeout gets the original 201 and the invitation back, rather than an error about a pending invitation whose id it cannot see.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | no | string (max length 255) | Unique per action you intend to perform. A retry with the same key replays the first response instead of repeating the work, and answers with `Idempotent-Replay: true`. Honoured for 24 hours. |

**Request body:** `application/json`, [CreateInvitation](#createinvitation)

**Returns:** [Invitation](#invitation)

| Status | Meaning |
| --- | --- |
| 201 | Invited, and the mail is on its way. |
| 400 | `invalid_request`: The request could not be understood |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 409 | `idempotency_in_flight`: The first request with this Idempotency-Key is still running |
| 422 | `idempotency_key_reused`: This Idempotency-Key was used for a different request |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/invitations" \
  -H "Authorization: Bearer $ALI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"newva@example.com","role_id":"8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10"}'
```

### DELETE /invitations/{id}

Withdraw an invitation

**Scope:** `members.write`

The link stops working; the record stays, because it happened and this list is a history as much as a queue.

An invitation somebody already accepted cannot be withdrawn. They have an account now, and `PATCH /members/{id}` with an empty role set is the operation that means what withdrawing would have meant.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The invitation's id, from `GET /invitations`. |

| Status | Meaning |
| --- | --- |
| 200 | Withdrawn. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X DELETE "https://beta.aliremote.com/api/v1/invitations/8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10" -H "Authorization: Bearer $ALI_API_KEY"
```

### POST /invitations/{id}/resend

Send the invitation again

**Scope:** `members.write`

Mints a **new** token and restarts the clock, so the previous link stops working. That is deliberate: a link forwarded to the wrong address, or sitting in a mailbox somebody else now reads, is dead the moment you resend.

Not idempotent. Each call is meant to produce a fresh link, and replaying a stored response would hand back one that had already been superseded.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) | The invitation's id, from `GET /invitations`. |

**Returns:** [Invitation](#invitation)

| Status | Meaning |
| --- | --- |
| 200 | Sent again, with a new link and a new expiry. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope; `forbidden`: Not permitted; `key_orphaned`: The account this key acts for is gone |
| 404 | `not_found`: Not found |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl -X POST "https://beta.aliremote.com/api/v1/invitations/8f14e45f-ea3c-4b21-9e35-1d9a6c9b2f10/resend" -H "Authorization: Bearer $ALI_API_KEY"
```

## Your agency

### GET /agency

The agency this key belongs to

**Scope:** `agency.read`

What a provisioning script reads before it orders anything: `phones_held` against `phone_limit` answers "may I add another", and `timezone` is the clock every analytics figure is drawn on, so you can align your own day boundaries instead of guessing at UTC.

Read-only. Renaming an agency, moving its timezone or turning on its two-factor requirement all change what every member sees, and the last changes whether they can sign in at all. Those stay in the dashboard.

**Returns:** [Agency](#agency)

| Status | Meaning |
| --- | --- |
| 200 | The agency. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 429 | `rate_limited`: Too many requests; `quota_exceeded`: Daily quota exhausted |

```bash
curl "https://beta.aliremote.com/api/v1/agency" -H "Authorization: Bearer $ALI_API_KEY"
```

## Usage and events

### GET /events

Change feed (WebSocket)

**Scope:** `devices.read`

Connect with `wss://`. Pushes **transitions**, not state: use `GET /devices` for the current picture and this to learn what changed.

Event types: `device.online`, `device.offline`, `device.casting`, `device.not_casting`, `media.delivered`, `media.failed`.

```
→ {"type":"ready","devices":12,"events":[…],"poll_interval_ms":5000}
→ {"type":"device.offline","device":"ph_…","at":"…"}
→ {"type":"media.delivered","device":"ph_…","asset_id":"…","at":"…"}
← {"type":"ping"}   → {"type":"pong","t":…}
```

The `devices` count on `ready` is what this socket is watching, which is every phone the key reaches. That is wider than `GET /devices` lists, deliberately: a handset dropping off its relay leaves the list and you are still told it went offline.

Exists so you do not have to poll `GET /devices` on a timer, which costs you latency and both of us a full authenticated request per tick.

| Parameter | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `token` | query | no | string | As above; prefer the Authorization header. |

| Status | Meaning |
| --- | --- |
| 101 | Switching protocols. Events follow. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 429 | `rate_limited`: Too many requests |

```bash
wscat -c "wss://beta.aliremote.com/api/v1/events" \
  -H "Authorization: Bearer $ALI_API_KEY"
```

### GET /usage

Today's credits, allowance and balance

**Scope:** `devices.read`

What the current UTC day has spent and what is left: the free allowance, the purchased balance, what each kind of work costs, and where the credits went, by kind and by phone. This call is free.

**Returns:** [Usage](#usage)

| Status | Meaning |
| --- | --- |
| 200 | Usage snapshot. |
| 401 | `unauthorized`: Missing or invalid API key |
| 403 | `scope_required`: The key lacks a required scope |
| 429 | `rate_limited`: Too many requests |

```bash
curl "https://beta.aliremote.com/api/v1/usage" -H "Authorization: Bearer $ALI_API_KEY"
```

## Errors

Every failure is `application/problem+json`:

```json
{
  "type": "https://docs.aliremote.com/api/errors#device_offline",
  "title": "Device unreachable",
  "status": 503,
  "code": "device_offline",
  "detail": "The device is not reachable. It may not be casting.",
  "request_id": "4e6ee2b5-1991-46dc-98cd-5646112c4d47"
}
```

| Code | Status | Meaning |
| --- | --- | --- |
| `quota_exceeded` | 429 | Daily quota exhausted. The daily ceiling resets at UTC midnight and refuses with or without purchased credits |
| `max_active_devices_reached` | 429 | Too many devices in use at once. Close a stream before opening another |
| `rate_limited` | 429 | Too many requests. Back off and honour `Retry-After` |
| `device_offline` | 503 | Device unreachable. The phone may not be casting; send a `recast` action |
| `kernel_timeout` | 504 | Device did not respond in time. Retry: the phone was reachable but slow |
| `device_not_found` | 404 | No such device. The id is wrong, or this key cannot reach that phone |
| `command_rejected` | 422 | The device rejected the command. The phone refused it; `detail` says what to try next |
| `invalid_action` | 400 | The action failed validation. `detail` names the field that failed |
| `unauthorized` | 401 | Missing or invalid API key. Send `Authorization: Bearer ali_live_...` |
| `forbidden` | 403 | Not permitted. The key is valid but not permitted to do this |
| `scope_required` | 403 | The key lacks a required scope. Add the scope to the key, or use one that has it |
| `payload_too_large` | 413 | Payload too large. Split the upload or reduce the file |
| `upload_failed` | 502 | Upload could not be completed. Retry the upload |
| `internal` | 500 | Something went wrong on our side. Quote the `request_id` if you contact support |
| `agency_disabled` | 403 | This account is disabled. The key is valid and works again once the account does |
| `key_unscoped` | 403 | This key reaches no devices. Its creator may have left; re-scope the key in the dashboard |
| `invalid_request` | 400 | The request could not be understood. Check the body against the schema above |
| `idempotency_key_reused` | 422 | This Idempotency-Key was used for a different request |
| `idempotency_in_flight` | 409 | The first request with this Idempotency-Key is still running |
| `device_unprovisioned` | 409 | Device is not connected to a box. The phone is not wired up, or the hardware serving it is mid-update |
| `device_blocked` | 403 | This phone is blocked |
| `device_not_provisioned_for_action` | 409 | The phone's on-device helper did not answer in time. On `open_url` it usually sat on the iOS approval prompt for that destination; tell us the phone and the link if it keeps refusing |
| `not_found` | 404 | Not found. Check the path and the id |
| `device_in_use` | 409 | Device in use. Somebody is driving this phone from the dashboard, or another key is. Wait, or ask them to hand it over |
| `rotation_unavailable` | 409 | This device cannot rotate its IP |
| `rotation_in_progress` | 409 | An IP rotation is already running |
| `key_orphaned` | 403 | The account this key acts for is gone. The account that created this key is gone from the agency; mint a replacement under one that is still active |
| `credits_exhausted` | 402 | Out of credits. Buy credits in the dashboard, or wait for the allowance to reset at UTC midnight |

## Schemas

The objects the endpoints above return and accept. `*` marks a field that is always present.

### Device

| Field | Type | Description |
| --- | --- | --- |
| `public_id`* | string | Opaque id used in every device call. Paths also accept the sticker. |
| `name`* | string |  |
| `inventory_id` | string \| null | The sticker on the back, if it has one. |
| `status`* | `online` \| `offline` |  |
| `casting`* | boolean | Whether the phone is mirroring to its box. A phone can be online and not casting, in which case a snapshot has nothing to capture and actions land on a black screen. Send a `recast` action. |
| `screen` | { width: integer, height: integer } \| null | Pixel space of the snapshot, and of action coordinates. |
| `ios_version` | string \| null |  |
| `region` | null | Always null. Boxes no longer carry a region (removed 2026-08-20); the property is kept for one release so existing clients keep parsing, and will be dropped in the next. |
| `last_seen_at` | string \| null (date-time) | When the box hosting this phone last reported in. `status` and `casting` are both derived from it, so this is what lets you judge how fresh they are rather than trusting a boolean computed on our clock. |
| `box` | { version: string \| null } \| null | The box hosting this phone. The fleet is mixed and the API behaves differently across it, so the build is published to let you group by it: a **202** on an action means the box cannot report outcomes, and `/snapshot` answers `device_unprovisioned` on a build without snapshot support. |

### DevicePage

| Field | Type | Description |
| --- | --- | --- |
| `devices`* | Device[] |  |
| `next_cursor`* | string \| null |  |

### ControlSessionRequest

Declares an automation as the driver of a phone, so people who open it start as spectators and can see what is running.

| Field | Type | Description |
| --- | --- | --- |
| `label`* | string (max length 64) | What a person should be told is running. "Nightly poster", not "job-4417". |
| `ttl_seconds` | integer (min 10, max 3600) | How long to hold the phone without another call. Defaults to the implicit lease length. |

### ControlTakeoverRequest

Takes a phone off whoever is currently driving it. The label is required rather than optional here: it is what the person losing the phone is shown, and "an API key" is not something they can act on.

| Field | Type | Description |
| --- | --- | --- |
| `label`* | string (max length 64) | What a person should be told is taking the phone. "Nightly poster", not "job-4417". |
| `ttl_seconds` | integer (min 10, max 3600) | How long to hold the phone without another call. Defaults to the implicit lease length. |

### UploadRequest

| Field | Type | Description |
| --- | --- | --- |
| `filename`* | string (max length 200) | The name to store it under. No path separators. |
| `mime`* | string | The file's content type, e.g. `image/jpeg` or `video/mp4`. |
| `size_bytes`* | integer (max 104857600) | The exact size of the file. Verified against what actually arrives, with a small tolerance. |

### UploadTicket

| Field | Type | Description |
| --- | --- | --- |
| `asset_id`* | string |  |
| `upload_url`* | string | Presigned PUT. Send the raw bytes here; the file never passes through the API. |
| `upload_expires_at`* | string (date-time) |  |
| `status`* | `awaiting_upload` |  |

### UploadAccepted

| Field | Type | Description |
| --- | --- | --- |
| `asset_id`* | string |  |
| `status`* | `queued` |  |

### Delivery

| Field | Type | Description |
| --- | --- | --- |
| `asset_id`* | string |  |
| `filename` | string |  |
| `status`* | `awaiting_upload` \| `stored` \| `queued` \| `sending` \| `delivered` \| `failed` | `awaiting_upload` and `stored` describe the upload before `/complete` has queued it; the rest come from the delivery queue. |
| `attempts` | integer |  |
| `error` | string \| null |  |
| `delivered_at` | string \| null (date-time) |  |
| `updated_at` | string (date-time) |  |

### Budget

Credits spent today, the ceiling they are spent against, and the gap.

| Field | Type | Description |
| --- | --- | --- |
| `used` | integer |  |
| `budget` | integer |  |
| `remaining` | integer |  |

### ActionResult

What came back from an action. Extra fields appear for the verbs that return a value.

| Field | Type | Description |
| --- | --- | --- |
| `device_ack`* | boolean | Whether the device confirmed it accepted the command. Not a claim that the screen changed, which nothing can observe for you, but it separates a confirmed hand-off from an unconfirmed one without inferring it from the status code. |
| `effect_observed` | boolean | Present on `swipe`, `drag`, `swipe_direction` and `scroll`, and always false. A gesture can be accepted in full and still move nothing, and unlike a tap there is no way to tell from here. Read the screen back with `GET /devices/{id}/snapshot` when it matters. |
| `repeated` | integer | Present when `times` was used: how many presses were sent. |
| `clipboard` | string | `clipboard_get` only. |
| `ip` | string \| null | `device_ip` only. |
| `public` | boolean | `device_ip` with `public: true` only: present when the address is the one the internet sees. |

### IpRotation

One rotation of one device's public address.

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `kind`* | `sim` \| `proxy` | How it rotates. |
| `status`* | `running` \| `rotated` \| `unchanged` \| `failed` | `unchanged`: the same address twice. |
| `step`* | `read_before` \| `cut` \| `restore` \| `read_after` \| `fraud_check` \| `done` |  |
| `attempt`* | integer |  |
| `ip_before` | string \| null |  |
| `ip_after` | string \| null |  |
| `fraud_score` | integer \| null | 0 clean to 100. |
| `fraud_risk` | `low` \| `medium` \| `high` \| `critical` \| `null` | Bands of 25. |
| `failure` | `not_rotatable` \| `phone_busy` \| `phone_offline` \| `helper_unreachable` \| `box_too_old` \| `no_address_after` \| `proxy_not_configured` \| `proxy_call_failed` \| `internal` \| `null` |  |
| `failure_detail` | string \| null |  |
| `started_at`* | string (date-time) |  |
| `finished_at` | string \| null (date-time) |  |

### Usage

Today's credits, in one unit, priced by what a call does (requests 1, management 1, actions 2, snapshots 10, uploads 50, stream_minutes 20). The three layers are reported separately because the remedies differ: `included_credits` is free and resets at UTC midnight, `credit_balance` is bought and pays for the part of a day above the allowance, and `credits.budget` is a ceiling that refuses whether or not there is a balance. The standard figures are 6,000 included and 35,000 of ceiling per reachable phone, each plus a flat 2,500.

| Field | Type | Description |
| --- | --- | --- |
| `credits` | Budget | The only ceiling. Everything else here explains it. |
| `included_credits` | integer | Today's free allowance. Past it a call draws the part of its price above the line from `credit_balance`, or is refused `credits_exhausted`. |
| `paid_credits` | integer | How much of today's spend came out of the balance. |
| `credit_balance` | integer | Purchased credits in hand. Held by the agency, shared by its keys and connections, never reset. |
| `spent_on` | object | Where today's credits went: one `WorkSpend` per kind of work, under the names priced above. The credits add up to `credits.used`. |
| `price` | object | What one unit of each kind costs today. Read it, do not hardcode it. |
| `reachable_devices` | integer | How many phones this key reaches: the multiplier on the allowance and on the ceiling, floored at 3. Zero leaves only the flat part. A capacity figure, so it counts a handset no relay reports any more and will read higher than `GET /devices` lists. That is deliberate: a phone dropping off a rack must not shrink the budget you paid for. |
| `devices` | object | Today's spend per phone, keyed by `public_id`, which is what shows where a shared pool went. |
| `active_streams` | integer |  |
| `max_concurrent_streams` | integer |  |
| `resets_at` | string (date-time) |  |

### WorkSpend

How much of one kind of work was done, and what it cost. `count` is units of that kind: calls, taps, screenshots, files, minutes of relay.

| Field | Type | Description |
| --- | --- | --- |
| `count` | integer |  |
| `credits` | integer |  |

### Group

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `name`* | string (max length 64) |  |
| `device_count`* | integer | Every phone in the group, including ones this key cannot reach. The ids on this list are the ones `PUT /devices/{id}/groups` accepts, so hiding a group the rest of the rack uses would make that call refuse an id you can see in your dashboard. |

### SetDeviceGroups

| Field | Type | Description |
| --- | --- | --- |
| `group_ids`* | array (max items 50) | The complete set. An empty array files the phone under nothing. |
| `new_group_name` | string (max length 64) | Create a group and put the phone in it. Reuses an existing group of the same name rather than making a second. An agency may hold at most 50. |

### Period

The window a report covers, and the clock its days are drawn on.

| Field | Type | Description |
| --- | --- | --- |
| `from`* | string (date) |  |
| `to`* | string (date) |  |
| `days`* | integer |  |
| `timezone`* | string | IANA name, from the agency's own setting. A VA shift running 09:00-18:00 in the Americas straddles UTC midnight, so rolling up by UTC date would split one working day across two rows. |

### AnalyticsSummary

| Field | Type | Description |
| --- | --- | --- |
| `period`* | Period |  |
| `active_minutes`* | integer |  |
| `users_active` | integer |  |
| `phones_active` | integer | Phones someone touched in the period, counted only over the phones in `phones_held` so it never reads above it. One the agency has since handed back, or that no relay reports any more, drops out, which is why this can read lower than a day in `trend`. |
| `phones_held` | integer | Phones the agency holds, whether or not anyone touched them. Leaves out a handset no relay reports any more, so it can read lower than the `phones_held` on the agency object, which counts every phone against `phone_limit`. |
| `gestures` | integer |  |
| `viewed_seconds` | integer |  |
| `untrusted_minutes` | integer | The part of `active_minutes` that arrived too late to corroborate. Included in the total, not additional to it. |
| `last_activity_at` | string \| null (date-time) | When a usage report last arrived, across the whole retained window rather than this period. The "is collection working" signal. |
| `trend`* | array | One point per day, oldest first. |

### MemberUsage

| Field | Type | Description |
| --- | --- | --- |
| `member_id`* | string (uuid) |  |
| `username`* | string |  |
| `roles` | array | Role names. Somebody can hold more than one. |
| `active_minutes`* | integer |  |
| `presence_minutes` | integer | Desk time, with overlapping sittings merged so six phones at once is one minute. What a timesheet is built from. |
| `phones_touched` | integer | The most phones this person used on any one day of the period, not the distinct phones across it. Somebody working five handsets a day all week reads 5, and may well have touched eight different ones. For the distinct set, count the `devices` array from `?include=devices`. That array is limited to the phones this key reaches and this figure is not, so a key scoped to part of the rack sees a shorter array than this number. |
| `sessions` | integer | Separate stretches at a phone, on any phone in the agency. Bounded by the sitting retention rather than the analytics one, so a long enough period can show worked hours with no sittings left behind them. `GET /analytics/sessions` itemises the same sittings but is limited to the phones this key reaches, so a key scoped to part of the rack returns fewer rows than this counts. |
| `gestures` | integer |  |
| `viewed_seconds` | integer |  |
| `last_active_at` | string \| null (date-time) |  |
| `untrusted_minutes` | integer |  |
| `devices` | MemberDeviceUsage[] | Only with `?include=devices`. Present as `[]` rather than absent when asked for and there is nothing, so a client can index it either way. |

### MemberUsageReport

| Field | Type | Description |
| --- | --- | --- |
| `period`* | Period |  |
| `members`* | MemberUsage[] |  |

### DeviceUsageReport

| Field | Type | Description |
| --- | --- | --- |
| `period`* | Period |  |
| `devices`* | DeviceUsage[] |  |
| `untouched`* | DeviceUsage[] | Reachable phones nobody touched in the period. The line item that costs money. |

### ActivityReport

| Field | Type | Description |
| --- | --- | --- |
| `period`* | Period |  |
| `cells`* | array | Worked minutes by hour of the agency's local week, `cells[day][hour]`, Monday first. Always 7 rows of 24, so a quiet hour is a zero rather than a gap and you can index it without bounds checks. |
| `peak`* | integer | The busiest cell, for scaling a colour ramp in one pass. |

### MemberDeviceUsage

One person's totals on one phone over the period, from the daily rollup. `active_minutes` are phone-minutes.

| Field | Type | Description |
| --- | --- | --- |
| `public_id`* | string |  |
| `name` | string \| null |  |
| `inventory_id` | string \| null |  |
| `active_minutes`* | integer |  |
| `gestures` | integer |  |
| `taps` | integer |  |
| `swipes` | integer |  |
| `texts` | integer |  |
| `keys` | integer | Individual key presses. Typed text is counted, never stored. |
| `viewed_seconds` | integer |  |
| `last_active_at` | string \| null (date-time) |  |

### SessionPage

| Field | Type | Description |
| --- | --- | --- |
| `period`* | Period |  |
| `sessions`* | Session[] |  |
| `next_cursor`* | string \| null | Pass back as `?cursor=`. Null on the last page. |
| `activity_from`* | string (date-time) | Sittings starting before this report `active_minutes` and `gestures` as null: the per-minute grain behind them is kept for 8 days and then dropped a partition at a time. |
| `retention_days`* | integer | How far back sittings are kept at all. Beyond this the list is empty. |

### Session

One person, one phone, one stretch.

| Field | Type | Description |
| --- | --- | --- |
| `session_id`* | string (uuid) |  |
| `member_id` | string \| null (uuid) | Null for a support sitting, along with the username. |
| `username` | string \| null |  |
| `support` | boolean | Somebody from ALI rather than one of your own people. That we were on your phone is yours to know; which of us is not. |
| `device_public_id`* | string |  |
| `device_name` | string \| null |  |
| `inventory_id` | string \| null |  |
| `started_at`* | string (date-time) |  |
| `ended_at`* | string (date-time) |  |
| `minutes`* | integer | Wall clock, rounded to the minute. |
| `active_minutes` | integer \| null | Minutes inside the sitting that carried a gesture. Can exceed `minutes` on a short one, because the minute is the grain. Null past `activity_from`. |
| `gestures` | integer \| null | Completed press-to-release gestures, not the pointer stream they decompose into. Null past `activity_from`. |

### DeviceUsage

| Field | Type | Description |
| --- | --- | --- |
| `public_id`* | string |  |
| `name` | string \| null |  |
| `inventory_id` | string \| null |  |
| `active_minutes`* | integer |  |
| `users_touched` | integer |  |
| `gestures` | integer |  |
| `viewed_seconds` | integer |  |
| `last_active_at` | string \| null (date-time) |  |

### Member

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `username`* | string |  |
| `email`* | string (email) |  |
| `roles`* | array | Every role held, highest standing first. What they may do is the union. |
| `email_verified` | boolean |  |
| `two_factor_enabled` | boolean |  |
| `phone_grants` | integer | How many phones are granted to this person individually. |
| `created_at` | string (date-time) |  |

### SetMemberRoles

| Field | Type | Description |
| --- | --- | --- |
| `role_ids`* | array (max items 50) | The complete set, never a delta. An empty array removes every role, which leaves the account able to do nothing until somebody gives it one. |

### Role

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `name`* | string |  |
| `description` | string \| null |  |
| `rank`* | integer | Standing. Higher acts on lower; the three seeded into every agency are 100 (owner), 50 (admin) and 10 (member). |
| `system_key` | `owner` \| `admin` \| `member` \| `null` | Set on the seeded roles, null on anything the agency made itself. |
| `permissions`* | array |  |
| `member_count` | integer |  |
| `assignable`* | boolean | Whether **this key** may hand this role out. A key carries the standing of whoever created it, so this is per-key rather than a property of the role. |

### Invitation

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `email`* | string (email) |  |
| `role`* | { id: string (uuid), name: string } |  |
| `status`* | `sending` \| `sent` \| `opened` \| `clicked` \| `accepted` \| `expired` \| `revoked` \| `failed` | Read `opened` with care: an open is an image load, so clients that prefetch report one nobody saw. A `clicked` or an `accepted` is a fact. |
| `invited_by` | string \| null |  |
| `created_at` | string (date-time) |  |
| `expires_at` | string (date-time) |  |
| `accepted_at` | string \| null (date-time) |  |

### CreateInvitation

| Field | Type | Description |
| --- | --- | --- |
| `email`* | string (max length 254, email) | An address that already has an account anywhere is refused: a person belongs to exactly one agency. |
| `role_id`* | string (uuid) | From `GET /roles`. Only the ones marked `assignable` will be accepted. |

### Agency

| Field | Type | Description |
| --- | --- | --- |
| `id`* | string (uuid) |  |
| `name`* | string |  |
| `status`* | string | `active`, `not-active`, and the other values the billing lifecycle sets. |
| `disabled` | boolean | Always false here: a disabled agency's key is refused with `agency_disabled` before it reaches this endpoint. |
| `timezone`* | string | IANA name. The day boundary every analytics figure is drawn on. |
| `phone_limit` | integer \| null |  |
| `phones_held` | integer | Phones assigned to the agency, counted against `phone_limit`. Includes a handset no relay reports any more, which the analytics summary's `phones_held` leaves out. |
| `member_count` | integer |  |
| `requires_two_factor` | boolean | Whether every member must have two-factor confirmed to sign in. |
| `created_at` | string (date-time) |  |

### Problem

RFC 7807 problem document. Integrate against `code`, which is stable; `detail` is prose and may be reworded.

| Field | Type | Description |
| --- | --- | --- |
| `type` | string |  |
| `title` | string |  |
| `status`* | integer |  |
| `code`* | `quota_exceeded` \| `max_active_devices_reached` \| `rate_limited` \| `device_offline` \| `kernel_timeout` \| `device_not_found` \| `command_rejected` \| `invalid_action` \| `unauthorized` \| `forbidden` \| `scope_required` \| `payload_too_large` \| `upload_failed` \| `internal` \| `agency_disabled` \| `key_unscoped` \| `invalid_request` \| `idempotency_key_reused` \| `idempotency_in_flight` \| `device_unprovisioned` \| `device_blocked` \| `device_not_provisioned_for_action` \| `not_found` \| `device_in_use` \| `rotation_unavailable` \| `rotation_in_progress` \| `key_orphaned` \| `credits_exhausted` |  |
| `detail`* | string |  |
| `request_id` | string | Quote this when contacting support. |

## Machine-readable

- OpenAPI 3.1: `https://beta.aliremote.com/api/v1/openapi.json`
- This page as markdown: `/docs/api.md`
- Everything for a language model, one file: `/llms-full.txt`
