{"openapi":"3.1.0","info":{"title":"ALI Remote API","version":"1.0","description":"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.\n\n**Authentication.** `Authorization: Bearer ali_live_...`. Create a key in the dashboard under **API access**. Keys belong to your agency, and each one is limited to a set of scopes and a set of phones.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\n**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."},"servers":[{"url":"https://beta.aliremote.com/api/v1","description":"Production"}],"security":[{"apiKey":[]}],"tags":[{"name":"Devices"},{"name":"Control"},{"name":"Media"},{"name":"Automations"},{"name":"Groups"},{"name":"Analytics"},{"name":"People"},{"name":"Agency"},{"name":"Usage"}],"paths":{"/devices":{"get":{"tags":["Devices"],"summary":"List your devices","description":"Every phone this key can reach. Paginated: pass `next_cursor` back as `?cursor=` until it comes back null.\n\nA 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.","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"The `next_cursor` from the previous page."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"A page of devices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicePage"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`forbidden`: Not permitted\n\n`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}":{"get":{"tags":["Devices"],"summary":"One device","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"responses":{"200":{"description":"The device.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Device"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Devices"],"summary":"Rename a device, or set its inventory sticker","description":"The two labels a customer owns: what the phone is called, and the sticker on the back of the handset.\n\nThe 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.\n\nNeeds `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.","security":[{"apiKey":["devices.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"At least one of the two.","properties":{"name":{"type":"string","maxLength":64},"inventory_id":{"type":"string","maxLength":40,"nullable":true,"description":"The sticker on the back of the handset. Null clears it."}}}}}},"responses":{"200":{"description":"The device as it now reads.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Device"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/snapshot":{"get":{"tags":["Devices"],"summary":"Capture a screenshot","description":"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.\n\nRate limited harder than actions: each capture costs the box a full frame and a transcode. For continuous frames, stream instead of polling.","security":[{"apiKey":["devices.snapshot"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"responses":{"200":{"description":"A JPEG screenshot.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"`device_offline`: Device unreachable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"504":{"description":"`kernel_timeout`: Device did not respond in time","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/ip-rotation":{"post":{"tags":["Control"],"summary":"Rotate the device's public IP","description":"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`.\n\nAnswers `202`; poll `GET` on this path until `status` leaves `running`. One rotation per device at a time.","security":[{"apiKey":["devices.control"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"responses":{"202":{"description":"The rotation has started. Poll `GET` for the outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IpRotation"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box\n\n`rotation_unavailable`: This device cannot rotate its IP\n\n`rotation_in_progress`: An IP rotation is already running\n\n`idempotency_in_flight`: The first request with this Idempotency-Key is still running","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"`idempotency_key_reused`: This Idempotency-Key was used for a different request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"tags":["Control"],"summary":"The device's IP rotations","description":"This device's rotations, newest first.","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"responses":{"200":{"description":"Newest first.","content":{"application/json":{"schema":{"type":"object","required":["rotations"],"properties":{"rotations":{"type":"array","items":{"$ref":"#/components/schemas/IpRotation"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/actions":{"post":{"tags":["Control"],"summary":"Send an action to a device","description":"Perform one action. The body is a single object discriminated by `type`.\n\nEvery 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.\n\n**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.\n\n**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.\n\nFor 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.\n\nOne 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.\n\nOn 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.\n\nAn 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.\n\n**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.**\n\nTaps, 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.\n\nWhen 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.\n\nTell the two cases apart by trying **once** more, a minute later:\n\n- **It succeeds.** The helper was busy, often with something else driving the same phone. Nothing is wrong.\n- **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.\n\n**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.\n\n⚠️ **`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`.\n\nMeasured 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.\n\nBoxes 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.\n\nA 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.\n\n`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).\n\n**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`.\n\n**`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.\n\nThe 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.\n\n**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.\n\n`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.\n\n`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.\n\nYou 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.\n\nIt 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.\n\n`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.\n\nIt 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`.\n\n**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.\n\n`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.","security":[{"apiKey":["devices.control"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","oneOf":[{"type":"object","properties":{"type":{"type":"string","const":"tap"},"x":{"type":"integer","minimum":0,"maximum":10000},"y":{"type":"integer","minimum":0,"maximum":10000}},"required":["type","x","y"]},{"type":"object","properties":{"type":{"type":"string","const":"long_press"},"x":{"type":"integer","minimum":0,"maximum":10000},"y":{"type":"integer","minimum":0,"maximum":10000},"hold_ms":{"default":600,"type":"integer","minimum":0,"maximum":10000}},"required":["type","x","y"]},{"type":"object","properties":{"type":{"type":"string","const":"swipe"},"x1":{"type":"integer","minimum":0,"maximum":10000},"y1":{"type":"integer","minimum":0,"maximum":10000},"x2":{"type":"integer","minimum":0,"maximum":10000},"y2":{"type":"integer","minimum":0,"maximum":10000},"precise":{"description":"true draws the gesture exactly as given; false varies it the way a hand does. Defaults to true wherever you gave us the numbers (swipe, drag, scroll, and swipe_direction carrying a distance) and false where we lay the whole line out ourselves (swipe_direction with no distance).","type":"boolean"},"duration_ms":{"type":"number"},"steps":{"type":"number"},"step_ms":{"type":"number"},"stepping":{"type":"number"},"step_sleep":{"type":"number"},"brake":{"type":"boolean"}},"required":["type","x1","y1","x2","y2"]},{"type":"object","properties":{"type":{"type":"string","const":"drag"},"x1":{"type":"integer","minimum":0,"maximum":10000},"y1":{"type":"integer","minimum":0,"maximum":10000},"x2":{"type":"integer","minimum":0,"maximum":10000},"y2":{"type":"integer","minimum":0,"maximum":10000},"precise":{"description":"true draws the gesture exactly as given; false varies it the way a hand does. Defaults to true wherever you gave us the numbers (swipe, drag, scroll, and swipe_direction carrying a distance) and false where we lay the whole line out ourselves (swipe_direction with no distance).","type":"boolean"},"duration_ms":{"type":"number"},"steps":{"type":"number"},"step_ms":{"type":"number"},"stepping":{"type":"number"},"step_sleep":{"type":"number"},"brake":{"type":"boolean"}},"required":["type","x1","y1","x2","y2"]},{"type":"object","properties":{"type":{"type":"string","const":"swipe_direction"},"direction":{"type":"string","enum":["up","down","left","right"]},"distance":{"anyOf":[{"type":"number","exclusiveMinimum":0,"maximum":1},{"type":"object","properties":{"px":{"type":"integer","minimum":1,"maximum":10000}},"required":["px"]}]},"span":{"type":"number"},"precise":{"description":"true draws the gesture exactly as given; false varies it the way a hand does. Defaults to true wherever you gave us the numbers (swipe, drag, scroll, and swipe_direction carrying a distance) and false where we lay the whole line out ourselves (swipe_direction with no distance).","type":"boolean"},"duration_ms":{"type":"number"},"steps":{"type":"number"},"step_ms":{"type":"number"},"stepping":{"type":"number"},"step_sleep":{"type":"number"},"brake":{"type":"boolean"}},"required":["type","direction"]},{"type":"object","properties":{"type":{"type":"string","const":"scroll"},"x":{"type":"integer","minimum":0,"maximum":10000},"y":{"type":"integer","minimum":0,"maximum":10000},"dy":{"type":"integer","minimum":-10000,"maximum":10000},"precise":{"description":"true draws the gesture exactly as given; false varies it the way a hand does. Defaults to true wherever you gave us the numbers (swipe, drag, scroll, and swipe_direction carrying a distance) and false where we lay the whole line out ourselves (swipe_direction with no distance).","type":"boolean"}},"required":["type","x","y","dy"]},{"type":"object","properties":{"type":{"type":"string","const":"text"},"text":{"type":"string","minLength":1,"maxLength":4096}},"required":["type","text"]},{"type":"object","properties":{"type":{"type":"string","const":"press"},"name":{"type":"string","enum":["home","appswitcher","lock","screenshot","enter","keyboard","controlcenter","notifications","volumeup","volumedown","mute","backspace","tab","up","down","left","right"]},"times":{"default":1,"type":"integer","minimum":1,"maximum":20}},"required":["type","name"]},{"type":"object","properties":{"type":{"type":"string","const":"open_url"},"url":{"type":"string","minLength":1,"maxLength":2048}},"required":["type","url"]},{"type":"object","properties":{"type":{"type":"string","const":"clipboard_set"},"text":{"type":"string","maxLength":4096}},"required":["type","text"]},{"type":"object","properties":{"type":{"type":"string","const":"clipboard_get"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","const":"airplane"},"on":{"type":"boolean"}},"required":["type","on"]},{"type":"object","properties":{"type":{"type":"string","const":"device_ip"},"public":{"description":"true reads the address the internet sees; false or absent reads the phone's own local address.","type":"boolean"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","const":"wifi"},"on":{"type":"boolean"}},"required":["type","on"]},{"type":"object","properties":{"type":{"type":"string","const":"cellular"},"on":{"type":"boolean"}},"required":["type","on"]},{"type":"object","properties":{"type":{"type":"string","const":"torch"},"on":{"type":"boolean"}},"required":["type","on"]},{"type":"object","properties":{"type":{"type":"string","const":"brightness"},"level":{"type":"number","minimum":0,"maximum":1}},"required":["type","level"]},{"type":"object","properties":{"type":{"type":"string","const":"recast"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","const":"calibrate"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","const":"app_close"}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","const":"send_binding_key"}},"required":["type"]}]}}}},"responses":{"200":{"description":"Performed, and the device confirmed it (`device_ack: true`). Most actions return only that; `clipboard_get` adds `{ clipboard }` and `device_ip` the address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionResult"}}}},"202":{"description":"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.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionResult"}}}},"400":{"description":"`invalid_action`: The action failed validation","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box\n\n`device_not_provisioned_for_action`: The phone's on-device helper did not answer in time\n\n`device_in_use`: Device in use\n\n`idempotency_in_flight`: The first request with this Idempotency-Key is still running","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"`command_rejected`: The device rejected the command\n\n`idempotency_key_reused`: This Idempotency-Key was used for a different request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"`device_offline`: Device unreachable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"504":{"description":"`kernel_timeout`: Device did not respond in time","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/control-session":{"get":{"tags":["Control"],"summary":"See who is driving a device","description":"Whether the phone is free, held by a person, or held by an automation, and since when.\n\nThe 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.\n\n`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.","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"responses":{"200":{"description":"Who holds it now.","content":{"application/json":{"schema":{"type":"object","properties":{"device":{"type":"string"},"held_by":{"type":"string","enum":["free","person","automation"]},"name":{"type":"string","nullable":true,"description":"The person's username, or the automation's declared label. Null when the phone is free."},"since":{"type":"string","format":"date-time","nullable":true},"yours":{"type":"boolean","description":"Whether this key is the one holding it."}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Control"],"summary":"Declare that an automation is driving a device","description":"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.\n\nEvery 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.\n\nIt 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.","security":[{"apiKey":["devices.control"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlSessionRequest"}}}},"responses":{"200":{"description":"The phone is yours until `expires_at`, or until a person takes it.","content":{"application/json":{"schema":{"type":"object","properties":{"device":{"type":"string"},"label":{"type":"string"},"expires_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_in_use`: Device in use","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Control"],"summary":"End an automation's session","description":"Lets people back on immediately rather than when the lease lapses. A no-op if somebody has already taken the phone.","security":[{"apiKey":["devices.control"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"responses":{"200":{"description":"Released.","content":{"application/json":{"schema":{"type":"object","properties":{"device":{"type":"string"},"released":{"type":"boolean"}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/control-session/takeover":{"post":{"tags":["Control"],"summary":"Take a device somebody else is driving","description":"Take a phone that somebody else is currently driving.\n\nControl 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.\n\n**What happens depends on who is holding it.**\n\n- **Nobody, or this same key.** Taken straight away.\n- **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.\n- **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.\n- **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`.\n\nA 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.\n\n**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.\n\n**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.\n\nCall `GET /devices/{id}/control-session` first if you would rather skip a busy phone than interrupt one.","security":[{"apiKey":["devices.control"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlTakeoverRequest"}}}},"responses":{"200":{"description":"The device is yours until `expires_at`. `taken_from` says who was on it, and `asked` whether a person had to be asked.","content":{"application/json":{"schema":{"type":"object","properties":{"device":{"type":"string"},"label":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"taken_from":{"type":"object","nullable":true,"description":"Null when the device was free, so nothing was taken.","properties":{"kind":{"type":"string","enum":["person","automation"]},"name":{"type":"string"},"since":{"type":"string","format":"date-time"}}},"asked":{"type":"boolean","description":"True when a person was asked and their window passed. False when the device was free, held by an automation, held by this key already, or held by the very account this key belongs to, who is not asked to hand a phone to their own credential. So `asked: false` alongside a `taken_from.kind` of `person` is expected rather than a contradiction."}}}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_in_use`: Device in use","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/media":{"post":{"tags":["Media"],"summary":"Start sending a file to a device","description":"Uploading is three steps, and the bytes never pass through the API:\n\n1. `POST /devices/{id}/media` with the file's `filename`, `mime` and `size_bytes` → **201** with an `upload_url`.\n2. `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.\n3. `POST /devices/{id}/media/{asset_id}/complete` → **202**. Storage is checked for what actually arrived, and the delivery to the phone is queued.\n\nThe 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.\n\nDelivery 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.","security":[{"apiKey":["media.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}}},"responses":{"201":{"description":"A slot is reserved. PUT the bytes to `upload_url` before it expires, then call `/complete`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadTicket"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"413":{"description":"`payload_too_large`: Payload too large","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/media/{asset_id}/complete":{"post":{"tags":["Media"],"summary":"Finish an upload and queue the delivery","description":"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.\n\nCalling it again re-queues the delivery, so it doubles as the retry.","security":[{"apiKey":["media.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"album","in":"query","required":false,"schema":{"type":"string"},"description":"Album to file it under on the phone."}],"responses":{"202":{"description":"Verified and queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadAccepted"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device\n\n`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box\n\n`device_in_use`: Device in use","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"502":{"description":"`upload_failed`: Upload could not be completed","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/media/{asset_id}":{"delete":{"tags":["Media"],"summary":"Take a file back off a device","description":"Remove a file this API put on the phone.\n\nAsynchronous, 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`.\n\nA 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.\n\nRemoving 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.\n\n**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.","security":[{"apiKey":["media.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"responses":{"202":{"description":"Removal queued. Poll `GET` on this same path until `removed`.","content":{"application/json":{"schema":{"type":"object","properties":{"asset_id":{"type":"string"},"status":{"type":"string","enum":["removing"]}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device\n\n`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`device_unprovisioned`: Device is not connected to a box","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"`command_rejected`: The device rejected the command","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"tags":["Media"],"summary":"Whether an upload landed","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The delivery.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Delivery"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/stream":{"get":{"tags":["Devices"],"summary":"Live screen (WebSocket)","description":"Connect with `wss://`. The server sends a JSON `ready` frame, then the screen as **binary JPEG frames**, one per message.\n\n```\n→ {\"type\":\"ready\",\"device\":\"ph_…\",\"screen\":{\"width\":405,\"height\":720},\"max_fps\":2}\n→ <binary JPEG>  …\n→ {\"type\":\"closing\",\"reason\":\"max_duration\",\"reconnect\":true}\n← {\"type\":\"ping\"}   → {\"type\":\"pong\",\"t\":…}\n← {\"type\":\"ack\"}    optional; lets the device pace to what you actually render\n```\n\n**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.\n\n**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.\n\n**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.","security":[{"apiKey":["devices.stream"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."},{"name":"fps","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":5,"default":2},"description":"Frames per second. Clamped to 5, never rejected."},{"name":"token","in":"query","required":false,"schema":{"type":"string"},"description":"Your API key, for clients that cannot set headers on a WebSocket handshake (browsers). Prefer the Authorization header: query strings end up in logs."}],"responses":{"101":{"description":"Switching protocols. Frames follow."},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/events":{"get":{"tags":["Usage"],"summary":"Change feed (WebSocket)","description":"Connect with `wss://`. Pushes **transitions**, not state: use `GET /devices` for the current picture and this to learn what changed.\n\nEvent types: `device.online`, `device.offline`, `device.casting`, `device.not_casting`, `media.delivered`, `media.failed`.\n\n```\n→ {\"type\":\"ready\",\"devices\":12,\"events\":[…],\"poll_interval_ms\":5000}\n→ {\"type\":\"device.offline\",\"device\":\"ph_…\",\"at\":\"…\"}\n→ {\"type\":\"media.delivered\",\"device\":\"ph_…\",\"asset_id\":\"…\",\"at\":\"…\"}\n← {\"type\":\"ping\"}   → {\"type\":\"pong\",\"t\":…}\n```\n\nThe `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.\n\nExists 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.","security":[{"apiKey":["devices.read"]}],"parameters":[{"name":"token","in":"query","required":false,"schema":{"type":"string"},"description":"As above; prefer the Authorization header."}],"responses":{"101":{"description":"Switching protocols. Events follow."},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations":{"get":{"tags":["Automations"],"summary":"List automations","description":"An automation is a flow of steps that drives phones on a schedule, on an event, or when you start one.\n\nUntil 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.\n\n**How a flow is built.**\n\n1. `POST /automations` for an empty one. It starts turned off and unpublished, so creating it can never start anything.\n2. `GET /automation-blocks` for the steps that exist, with their settings and the ports their edges leave by. Do not guess these.\n3. `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.\n4. `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.\n5. `POST /automations/{id}/triggers` to say when it should run, then `PATCH` with `enabled: true`.\n\n**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`.\n\n**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.","security":[{"apiKey":["automations.read"]}],"responses":{"200":{"description":"Every automation in the agency, with whether it is published, whether it is on, and how its last run went.","content":{"application/json":{"schema":{"type":"object","required":["automations"],"properties":{"automations":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Automations"],"summary":"Create an automation","description":"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.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":120,"description":"Unique within the agency."},"description":{"type":"string","maxLength":2000}}}}}},"responses":{"201":{"description":"Created, turned off and unpublished.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations/{id}":{"get":{"tags":["Automations"],"summary":"Get one automation","description":"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.","security":[{"apiKey":["automations.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."}],"responses":{"200":{"description":"The automation.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Automations"],"summary":"Rename, switch on, or save a new draft","description":"`graph` replaces the draft rather than merging into it, so send the whole flow; read `GET /automation-blocks` first.\n\nA 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.\n\n`enabled: true` is refused until something has been published, because a switch that reports on and does nothing is worse than a refusal.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":120},"description":{"type":"string","maxLength":2000,"nullable":true},"enabled":{"type":"boolean"},"graph":{"type":"object","description":"The whole flow as `{nodes, edges}`."},"note":{"type":"string","maxLength":500,"description":"What changed, like a commit message."}}}}}},"responses":{"200":{"description":"Saved. `draft.problems` is empty when it is ready to publish.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Automations"],"summary":"Delete an automation","description":"Deletes it and everything it recorded: versions, triggers and run history. This cannot be undone. To stop it running, `PATCH` with `enabled: false` instead.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."}],"responses":{"200":{"description":"Gone.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations/{id}/publish":{"post":{"tags":["Automations"],"summary":"Publish the current draft","description":"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.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"responses":{"200":{"description":"Published. This version is what triggers now run.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations/{id}/runs":{"get":{"tags":["Automations"],"summary":"List this automation's runs","description":"Newest first, with how each one ended.","security":[{"apiKey":["automations.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."}],"responses":{"200":{"description":"Recent runs.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Automations"],"summary":"Start a run now","description":"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}`.\n\nThe run acts with the standing of the account behind the key, so it reaches the phones that person reaches and no others.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"responses":{"202":{"description":"Started. Poll the run for how it goes.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations/{id}/triggers":{"post":{"tags":["Automations"],"summary":"Add a trigger","description":"`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.\n\nAn automation with no trigger can still be started with `POST /automations/{id}/runs`.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","enum":["manual","schedule","event"]},"cron":{"type":"string","maxLength":64,"description":"Five fields. For `kind: schedule`."},"timezone":{"type":"string","maxLength":64,"description":"IANA name, such as `Europe/Bucharest`."},"event_type":{"type":"string","enum":["device.online","device.offline","device.casting","device.not_casting"],"description":"For `kind: event`."}}}}}},"responses":{"201":{"description":"Added.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automations/{id}/triggers/{trigger_id}":{"delete":{"tags":["Automations"],"summary":"Remove a trigger","description":"Stops the automation being started that way. Its other triggers are left alone.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The automation's id, from `GET /automations`."},{"name":"trigger_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The trigger's id, from `GET /automations/{id}`."}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automation-blocks":{"get":{"tags":["Automations"],"summary":"List the steps a flow can be built from","description":"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.","security":[{"apiKey":["automations.read"]}],"responses":{"200":{"description":"The catalogue, and one paragraph on how a flow is wired.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automation-runs":{"get":{"tags":["Automations"],"summary":"List recent runs","description":"Across every automation, newest first. The one call that answers whether last night worked.","security":[{"apiKey":["automations.read"]}],"parameters":[{"name":"automation_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Only this automation's runs."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200},"description":"Defaults to 50."}],"responses":{"200":{"description":"Recent runs.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automation-runs/{id}":{"get":{"tags":["Automations"],"summary":"Get one run, step by step","description":"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.","security":[{"apiKey":["automations.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The run's id."}],"responses":{"200":{"description":"The run and its steps.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/automation-runs/{id}/cancel":{"post":{"tags":["Automations"],"summary":"Stop a run","description":"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.","security":[{"apiKey":["automations.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The run's id."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"responses":{"200":{"description":"It will stop at its next step.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/groups":{"get":{"tags":["Groups"],"summary":"List your groups","description":"Groups are your own filing of your own phones: a label, and the phones in it.\n\nThere 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.\n\nCounts are of every phone in the group, including ones this key cannot reach.","security":[{"apiKey":["devices.read"]}],"responses":{"200":{"description":"Every group in the agency.","content":{"application/json":{"schema":{"type":"object","required":["groups"],"properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/Group"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/devices/{id}/groups":{"put":{"tags":["Groups"],"summary":"Set the groups a device is in","description":"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.\n\n`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.\n\nAnswers 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.","security":[{"apiKey":["groups.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The device's `public_id`, or its sticker (`inventory_id`)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetDeviceGroups"}}}},"responses":{"200":{"description":"The agency's groups, after the change.","content":{"application/json":{"schema":{"type":"object","required":["groups"],"properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/Group"}}}}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`device_not_found`: No such device","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/summary":{"get":{"tags":["Analytics"],"summary":"Agency totals and the daily trend","description":"What the agency did over the period: worked minutes, how many people and phones were active, gestures sent, and a point per day.\n\nFigures 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.","security":[{"apiKey":["analytics.read"]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":7},"description":"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."}],"responses":{"200":{"description":"Totals and trend.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsSummary"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/members":{"get":{"tags":["Analytics"],"summary":"What each person did","description":"Per-person desk minutes, worked minutes, phones touched, sittings and gestures, with the last time each was active.\n\n`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.\n\n`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.\n\n`?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`.\n\nFigures 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.","security":[{"apiKey":["analytics.read"]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":7},"description":"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."},{"name":"include","in":"query","required":false,"schema":{"type":"string","enum":["devices"]},"description":"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."}],"responses":{"200":{"description":"One row per member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberUsageReport"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/devices":{"get":{"tags":["Analytics"],"summary":"What each phone carried, and which carried nothing","description":"Per-phone activity, plus `untouched`, the phones you hold that nobody used in the period. That is the line item that costs money.\n\n**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.\n\nFigures 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.","security":[{"apiKey":["analytics.read"]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":7},"description":"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."}],"responses":{"200":{"description":"One row per reachable phone.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceUsageReport"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/sessions":{"get":{"tags":["Analytics"],"summary":"Who was on which phone, and when","description":"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.\n\nThis 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.\n\n**Limited to the phones this key reaches**, like `GET /analytics/devices` and unlike the agency totals: it names handsets.\n\n**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.\n\n**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.\n\n**`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.\n\nA 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.\n\nFigures 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.","security":[{"apiKey":["analytics.read"]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":7},"description":"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."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"The `next_cursor` from the previous page."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"description":"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."}],"responses":{"200":{"description":"A page of sittings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionPage"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/activity":{"get":{"tags":["Analytics"],"summary":"Worked minutes by hour of the week","description":"`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.\n\nThe 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.","security":[{"apiKey":["analytics.read"]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":7},"description":"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."}],"responses":{"200":{"description":"A 7 × 24 grid of minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityReport"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/members":{"get":{"tags":["People"],"summary":"List the people in your agency","description":"Everybody with an account in this agency, and the roles each of them holds.\n\nUnpaginated, 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.","security":[{"apiKey":["members.read"]}],"responses":{"200":{"description":"Every member.","content":{"application/json":{"schema":{"type":"object","required":["members"],"properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/Member"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/members/{id}":{"get":{"tags":["People"],"summary":"One member","security":[{"apiKey":["members.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The member's id, from `GET /members`."}],"responses":{"200":{"description":"The member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["People"],"summary":"Set which roles a member holds","description":"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.\n\nThe only write in this API that changes what a person can do, and three things bound it:\n\n- **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`.\n- **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.\n- **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.\n\nNothing 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.","security":[{"apiKey":["members.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The member's id, from `GET /members`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetMemberRoles"}}}},"responses":{"200":{"description":"The member, with their new roles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/roles":{"get":{"tags":["People"],"summary":"List the roles in your agency","description":"Every role, what it allows, and whether **this key** may hand it out.\n\nRead `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.\n\nRead-only. Creating a role means choosing a set of permissions, which is a decision for a screen that explains what each one does.","security":[{"apiKey":["members.read"]}],"responses":{"200":{"description":"Every role in the agency.","content":{"application/json":{"schema":{"type":"object","required":["roles"],"properties":{"roles":{"type":"array","items":{"$ref":"#/components/schemas/Role"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/invitations":{"get":{"tags":["People"],"summary":"List invitations","description":"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.","security":[{"apiKey":["members.read"]}],"responses":{"200":{"description":"Every invitation.","content":{"application/json":{"schema":{"type":"object","required":["invitations"],"properties":{"invitations":{"type":"array","items":{"$ref":"#/components/schemas/Invitation"}}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["People"],"summary":"Invite somebody into your agency","description":"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.\n\nThe invitation names a role, from `GET /roles`. A key can only hand out a role its creator could, so check `assignable` before choosing one.\n\nAn 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.\n\nThe link expires. `POST /invitations/{id}/resend` issues a fresh one and invalidates the old.\n\n**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.","security":[{"apiKey":["members.write"]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"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."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInvitation"}}}},"responses":{"201":{"description":"Invited, and the mail is on its way.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation"}}}},"400":{"description":"`invalid_request`: The request could not be understood","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"`idempotency_in_flight`: The first request with this Idempotency-Key is still running","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"`idempotency_key_reused`: This Idempotency-Key was used for a different request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/invitations/{id}":{"delete":{"tags":["People"],"summary":"Withdraw an invitation","description":"The link stops working; the record stays, because it happened and this list is a history as much as a queue.\n\nAn 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.","security":[{"apiKey":["members.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The invitation's id, from `GET /invitations`."}],"responses":{"200":{"description":"Withdrawn.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"revoked":{"type":"boolean"}}}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/invitations/{id}/resend":{"post":{"tags":["People"],"summary":"Send the invitation again","description":"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.\n\nNot idempotent. Each call is meant to produce a fresh link, and replaying a stored response would hand back one that had already been superseded.","security":[{"apiKey":["members.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The invitation's id, from `GET /invitations`."}],"responses":{"200":{"description":"Sent again, with a new link and a new expiry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope\n\n`forbidden`: Not permitted\n\n`key_orphaned`: The account this key acts for is gone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"`not_found`: Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/agency":{"get":{"tags":["Agency"],"summary":"The agency this key belongs to","description":"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.\n\nRead-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.","security":[{"apiKey":["agency.read"]}],"responses":{"200":{"description":"The agency.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Agency"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests\n\n`quota_exceeded`: Daily quota exhausted","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/usage":{"get":{"tags":["Usage"],"summary":"Today's credits, allowance and balance","description":"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.","security":[{"apiKey":["devices.read"]}],"responses":{"200":{"description":"Usage snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Usage"}}}},"401":{"description":"`unauthorized`: Missing or invalid API key","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"`scope_required`: The key lacks a required scope","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"`rate_limited`: Too many requests","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer ali_live_<key_id>_<secret>`. Create one in the dashboard under API access. Each key carries 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 one it needs."}},"parameters":{},"schemas":{"Device":{"type":"object","required":["public_id","name","status","casting"],"properties":{"public_id":{"type":"string","description":"Opaque id used in every device call. Paths also accept the sticker."},"name":{"type":"string"},"inventory_id":{"type":["string","null"],"description":"The sticker on the back, if it has one."},"status":{"type":"string","enum":["online","offline"]},"casting":{"type":"boolean","description":"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":{"type":["object","null"],"properties":{"width":{"type":"integer"},"height":{"type":"integer"}},"description":"Pixel space of the snapshot, and of action coordinates."},"ios_version":{"type":["string","null"]},"region":{"type":"null","deprecated":true,"description":"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":{"type":["string","null"],"format":"date-time","description":"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":{"type":["object","null"],"description":"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.","properties":{"version":{"type":["string","null"]}}}}},"DevicePage":{"type":"object","required":["devices","next_cursor"],"properties":{"devices":{"type":"array","items":{"$ref":"#/components/schemas/Device"}},"next_cursor":{"type":["string","null"]}}},"ControlSessionRequest":{"type":"object","description":"Declares an automation as the driver of a phone, so people who open it start as spectators and can see what is running.","required":["label"],"properties":{"label":{"type":"string","maxLength":64,"description":"What a person should be told is running. \"Nightly poster\", not \"job-4417\"."},"ttl_seconds":{"type":"integer","minimum":10,"maximum":3600,"description":"How long to hold the phone without another call. Defaults to the implicit lease length."}}},"ControlTakeoverRequest":{"type":"object","description":"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.","required":["label"],"properties":{"label":{"type":"string","maxLength":64,"description":"What a person should be told is taking the phone. \"Nightly poster\", not \"job-4417\"."},"ttl_seconds":{"type":"integer","minimum":10,"maximum":3600,"description":"How long to hold the phone without another call. Defaults to the implicit lease length."}}},"UploadRequest":{"type":"object","required":["filename","mime","size_bytes"],"properties":{"filename":{"type":"string","maxLength":200,"description":"The name to store it under. No path separators."},"mime":{"type":"string","description":"The file's content type, e.g. `image/jpeg` or `video/mp4`."},"size_bytes":{"type":"integer","maximum":104857600,"description":"The exact size of the file. Verified against what actually arrives, with a small tolerance."}}},"UploadTicket":{"type":"object","required":["asset_id","upload_url","upload_expires_at","status"],"properties":{"asset_id":{"type":"string"},"upload_url":{"type":"string","description":"Presigned PUT. Send the raw bytes here; the file never passes through the API."},"upload_expires_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["awaiting_upload"]}}},"UploadAccepted":{"type":"object","required":["asset_id","status"],"properties":{"asset_id":{"type":"string"},"status":{"type":"string","enum":["queued"]}}},"Delivery":{"type":"object","required":["asset_id","status"],"properties":{"asset_id":{"type":"string"},"filename":{"type":"string"},"status":{"type":"string","enum":["awaiting_upload","stored","queued","sending","delivered","failed"],"description":"`awaiting_upload` and `stored` describe the upload before `/complete` has queued it; the rest come from the delivery queue."},"attempts":{"type":"integer"},"error":{"type":["string","null"]},"delivered_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Budget":{"type":"object","description":"Credits spent today, the ceiling they are spent against, and the gap.","properties":{"used":{"type":"integer"},"budget":{"type":"integer"},"remaining":{"type":"integer"}}},"ActionResult":{"type":"object","required":["device_ack"],"description":"What came back from an action. Extra fields appear for the verbs that return a value.","properties":{"device_ack":{"type":"boolean","description":"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":{"type":"boolean","description":"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":{"type":"integer","description":"Present when `times` was used: how many presses were sent."},"clipboard":{"type":"string","description":"`clipboard_get` only."},"ip":{"type":["string","null"],"description":"`device_ip` only."},"public":{"type":"boolean","description":"`device_ip` with `public: true` only: present when the address is the one the internet sees."}}},"IpRotation":{"type":"object","required":["id","kind","status","step","attempt","started_at"],"description":"One rotation of one device's public address.","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["sim","proxy"],"description":"How it rotates."},"status":{"type":"string","enum":["running","rotated","unchanged","failed"],"description":"`unchanged`: the same address twice."},"step":{"type":"string","enum":["read_before","cut","restore","read_after","fraud_check","done"]},"attempt":{"type":"integer"},"ip_before":{"type":["string","null"]},"ip_after":{"type":["string","null"]},"fraud_score":{"type":["integer","null"],"description":"0 clean to 100."},"fraud_risk":{"type":["string","null"],"enum":["low","medium","high","critical",null],"description":"Bands of 25."},"failure":{"type":["string","null"],"enum":["not_rotatable","phone_busy","phone_offline","helper_unreachable","box_too_old","no_address_after","proxy_not_configured","proxy_call_failed","internal",null]},"failure_detail":{"type":["string","null"]},"started_at":{"type":"string","format":"date-time"},"finished_at":{"type":["string","null"],"format":"date-time"}}},"Usage":{"type":"object","description":"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.","properties":{"credits":{"$ref":"#/components/schemas/Budget","description":"The only ceiling. Everything else here explains it."},"included_credits":{"type":"integer","description":"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":{"type":"integer","description":"How much of today's spend came out of the balance."},"credit_balance":{"type":"integer","description":"Purchased credits in hand. Held by the agency, shared by its keys and connections, never reset."},"spent_on":{"type":"object","description":"Where today's credits went: one `WorkSpend` per kind of work, under the names priced above. The credits add up to `credits.used`.","properties":{"requests":{"$ref":"#/components/schemas/WorkSpend"},"actions":{"$ref":"#/components/schemas/WorkSpend"},"snapshots":{"$ref":"#/components/schemas/WorkSpend"},"uploads":{"$ref":"#/components/schemas/WorkSpend"},"stream_minutes":{"$ref":"#/components/schemas/WorkSpend"},"management":{"$ref":"#/components/schemas/WorkSpend"}}},"price":{"type":"object","description":"What one unit of each kind costs today. Read it, do not hardcode it.","additionalProperties":{"type":"integer"}},"reachable_devices":{"type":"integer","description":"How many phones this key reaches: the multiplier on the allowance and on the ceiling, floored at 3. Zero leaves only the flat part.\n\nA 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":{"type":"object","description":"Today's spend per phone, keyed by `public_id`, which is what shows where a shared pool went.","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/WorkSpend"}}},"active_streams":{"type":"integer"},"max_concurrent_streams":{"type":"integer"},"resets_at":{"type":"string","format":"date-time"}}},"WorkSpend":{"type":"object","description":"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.","properties":{"count":{"type":"integer"},"credits":{"type":"integer"}}},"Group":{"type":"object","required":["id","name","device_count"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","maxLength":64},"device_count":{"type":"integer","description":"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":{"type":"object","required":["group_ids"],"properties":{"group_ids":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"},"description":"The complete set. An empty array files the phone under nothing."},"new_group_name":{"type":"string","maxLength":64,"description":"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":{"type":"object","required":["from","to","days","timezone"],"description":"The window a report covers, and the clock its days are drawn on.","properties":{"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"},"days":{"type":"integer"},"timezone":{"type":"string","description":"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":{"type":"object","required":["period","active_minutes","trend"],"properties":{"period":{"$ref":"#/components/schemas/Period"},"active_minutes":{"type":"integer"},"users_active":{"type":"integer"},"phones_active":{"type":"integer","description":"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":{"type":"integer","description":"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":{"type":"integer"},"viewed_seconds":{"type":"integer"},"untrusted_minutes":{"type":"integer","description":"The part of `active_minutes` that arrived too late to corroborate. Included in the total, not additional to it."},"last_activity_at":{"type":["string","null"],"format":"date-time","description":"When a usage report last arrived, across the whole retained window rather than this period. The \"is collection working\" signal."},"trend":{"type":"array","description":"One point per day, oldest first.","items":{"type":"object","properties":{"day":{"type":"string","format":"date"},"active_minutes":{"type":"integer"},"users_active":{"type":"integer"},"phones_active":{"type":"integer","description":"Phones touched that day, counted as it happened, so one the agency has since handed back still shows here."}}}}}},"MemberUsage":{"type":"object","required":["member_id","username","active_minutes"],"properties":{"member_id":{"type":"string","format":"uuid"},"username":{"type":"string"},"roles":{"type":"array","items":{"type":"string"},"description":"Role names. Somebody can hold more than one."},"active_minutes":{"type":"integer"},"presence_minutes":{"type":"integer","description":"Desk time, with overlapping sittings merged so six phones at once is one minute. What a timesheet is built from."},"phones_touched":{"type":"integer","description":"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":{"type":"integer","description":"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":{"type":"integer"},"viewed_seconds":{"type":"integer"},"last_active_at":{"type":["string","null"],"format":"date-time"},"untrusted_minutes":{"type":"integer"},"devices":{"type":"array","description":"Only with `?include=devices`. Present as `[]` rather than absent when asked for and there is nothing, so a client can index it either way.","items":{"$ref":"#/components/schemas/MemberDeviceUsage"}}}},"MemberUsageReport":{"type":"object","required":["period","members"],"properties":{"period":{"$ref":"#/components/schemas/Period"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberUsage"}}}},"DeviceUsageReport":{"type":"object","required":["period","devices","untouched"],"properties":{"period":{"$ref":"#/components/schemas/Period"},"devices":{"type":"array","items":{"$ref":"#/components/schemas/DeviceUsage"}},"untouched":{"type":"array","description":"Reachable phones nobody touched in the period. The line item that costs money.","items":{"$ref":"#/components/schemas/DeviceUsage"}}}},"ActivityReport":{"type":"object","required":["period","cells","peak"],"properties":{"period":{"$ref":"#/components/schemas/Period"},"cells":{"type":"array","description":"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.","items":{"type":"array","items":{"type":"integer"}}},"peak":{"type":"integer","description":"The busiest cell, for scaling a colour ramp in one pass."}}},"MemberDeviceUsage":{"type":"object","description":"One person's totals on one phone over the period, from the daily rollup. `active_minutes` are phone-minutes.","required":["public_id","active_minutes"],"properties":{"public_id":{"type":"string"},"name":{"type":["string","null"]},"inventory_id":{"type":["string","null"]},"active_minutes":{"type":"integer"},"gestures":{"type":"integer"},"taps":{"type":"integer"},"swipes":{"type":"integer"},"texts":{"type":"integer"},"keys":{"type":"integer","description":"Individual key presses. Typed text is counted, never stored."},"viewed_seconds":{"type":"integer"},"last_active_at":{"type":["string","null"],"format":"date-time"}}},"SessionPage":{"type":"object","required":["period","sessions","next_cursor","activity_from","retention_days"],"properties":{"period":{"$ref":"#/components/schemas/Period"},"sessions":{"type":"array","items":{"$ref":"#/components/schemas/Session"}},"next_cursor":{"type":["string","null"],"description":"Pass back as `?cursor=`. Null on the last page."},"activity_from":{"type":"string","format":"date-time","description":"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":{"type":"integer","description":"How far back sittings are kept at all. Beyond this the list is empty."}}},"Session":{"type":"object","description":"One person, one phone, one stretch.","required":["session_id","device_public_id","started_at","ended_at","minutes"],"properties":{"session_id":{"type":"string","format":"uuid"},"member_id":{"type":["string","null"],"format":"uuid","description":"Null for a support sitting, along with the username."},"username":{"type":["string","null"]},"support":{"type":"boolean","description":"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":{"type":"string"},"device_name":{"type":["string","null"]},"inventory_id":{"type":["string","null"]},"started_at":{"type":"string","format":"date-time"},"ended_at":{"type":"string","format":"date-time"},"minutes":{"type":"integer","description":"Wall clock, rounded to the minute."},"active_minutes":{"type":["integer","null"],"description":"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":{"type":["integer","null"],"description":"Completed press-to-release gestures, not the pointer stream they decompose into. Null past `activity_from`."}}},"DeviceUsage":{"type":"object","required":["public_id","active_minutes"],"properties":{"public_id":{"type":"string"},"name":{"type":["string","null"]},"inventory_id":{"type":["string","null"]},"active_minutes":{"type":"integer"},"users_touched":{"type":"integer"},"gestures":{"type":"integer"},"viewed_seconds":{"type":"integer"},"last_active_at":{"type":["string","null"],"format":"date-time"}}},"Member":{"type":"object","required":["id","username","email","roles"],"properties":{"id":{"type":"string","format":"uuid"},"username":{"type":"string"},"email":{"type":"string","format":"email"},"roles":{"type":"array","description":"Every role held, highest standing first. What they may do is the union.","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}}},"email_verified":{"type":"boolean"},"two_factor_enabled":{"type":"boolean"},"phone_grants":{"type":"integer","description":"How many phones are granted to this person individually."},"created_at":{"type":"string","format":"date-time"}}},"SetMemberRoles":{"type":"object","required":["role_ids"],"properties":{"role_ids":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"},"description":"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":{"type":"object","required":["id","name","rank","permissions","assignable"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"rank":{"type":"integer","description":"Standing. Higher acts on lower; the three seeded into every agency are 100 (owner), 50 (admin) and 10 (member)."},"system_key":{"type":["string","null"],"enum":["owner","admin","member",null],"description":"Set on the seeded roles, null on anything the agency made itself."},"permissions":{"type":"array","items":{"type":"string"}},"member_count":{"type":"integer"},"assignable":{"type":"boolean","description":"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":{"type":"object","required":["id","email","role","status"],"properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"role":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"status":{"type":"string","enum":["sending","sent","opened","clicked","accepted","expired","revoked","failed"],"description":"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":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"accepted_at":{"type":["string","null"],"format":"date-time"}}},"CreateInvitation":{"type":"object","required":["email","role_id"],"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"An address that already has an account anywhere is refused: a person belongs to exactly one agency."},"role_id":{"type":"string","format":"uuid","description":"From `GET /roles`. Only the ones marked `assignable` will be accepted."}}},"Agency":{"type":"object","required":["id","name","status","timezone"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string","description":"`active`, `not-active`, and the other values the billing lifecycle sets."},"disabled":{"type":"boolean","description":"Always false here: a disabled agency's key is refused with `agency_disabled` before it reaches this endpoint."},"timezone":{"type":"string","description":"IANA name. The day boundary every analytics figure is drawn on."},"phone_limit":{"type":["integer","null"]},"phones_held":{"type":"integer","description":"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":{"type":"integer"},"requires_two_factor":{"type":"boolean","description":"Whether every member must have two-factor confirmed to sign in."},"created_at":{"type":"string","format":"date-time"}}},"Problem":{"type":"object","description":"RFC 7807 problem document. Integrate against `code`, which is stable; `detail` is prose and may be reworded.","required":["status","code","detail"],"properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string","enum":["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":{"type":"string"},"request_id":{"type":"string","description":"Quote this when contacting support."}}}}}}