# REST API

> The fones REST API, every route with its scope, body and answer, plus errors, pagination and idempotency.

Source: https://fones.ai/docs/api

Base URL: `https://api.fones.ai/v1` (also `/v1` on `fones.ai`). JSON in, JSON out. The machine-readable version is [/openapi.json](https://fones.ai/openapi.json).

## Conventions

- **Auth.** `Authorization: Bearer fones_...`. Each phone route needs one scope: `phones:read`, `phones:control` or `phones:buy`. The console's session cookie can do everything.
- **Errors.** Any non-2xx answer has the body `{ "error": { "code": "...", "message": "..." } }`, sometimes with more fields (`missing_cents` on a 402). See [Errors](#errors).
- **Lists** return `{ "data": [...], "next_before": "..." }`, newest first. Pass `?limit=` (1 to 100, default 50) and `?before=<next_before>` for the next page; `next_before` is null on the last one.
- **Idempotency.** Routes that buy accept an `Idempotency-Key` header (up to 200 printable characters, kept 30 days). The same key returns the same phone again and never charges twice, also for retries sent at the same time; the same key with another request (another body, route or phone) is a `409 idempotency_conflict`.
- **Coordinates** are a grid from 0 to 1000 on both axes, whatever the phone's resolution.
- **Money** is in US cents (`balance_cents`, `monthly_cents`). Times are ISO 8601 in UTC.

## Routes

| Method and path | Scope | What |
| --- | --- | --- |
| `GET /v1/catalog` | read | tiers, models, prices, your balance |
| `GET /v1/phones` | read | your phones |
| `POST /v1/phones` | buy | buy a phone, 202 with status `provisioning` |
| `GET /v1/phones/:id` | read | one phone |
| `PATCH /v1/phones/:id` | buy | change `label` or `auto_renew` |
| `DELETE /v1/phones/:id` | buy | stop renewing; `?now=true` releases at once |
| `GET /v1/phones/:id/screenshot` | read | the screen as an image |
| `GET /v1/phones/:id/ui` | read | the screen as nodes |
| `POST /v1/phones/:id/actions` | control | one action: tap, swipe, type, key, app, shell |
| `POST /v1/phones/:id/number` | buy | add a US number |
| `DELETE /v1/phones/:id/number` | buy | remove the number |
| `GET /v1/phones/:id/sms` | read | texts received, newest first |
| `POST /v1/phones/:id/proxy` | buy | add a static IP |
| `DELETE /v1/phones/:id/proxy` | buy | remove the static IP |
| `GET /v1/apps` | read | the app catalog |
| `POST /v1/phones/:id/apps` | control | install an app from the catalog |
| `GET /v1/activity` | read | every action sent to your phones, 30 days |
| `GET /v1/account` | any | email, balance, webhook |
| `POST /v1/billing/topup` | any | a checkout link to top up |
| `GET /v1/billing/ledger` | any | balance history |
| `GET /v1/billing/subscriptions` | any | what renews next |
| `GET /v1/billing/orders` | any | top-ups |
| `GET /v1/webhook/deliveries` | any | events sent to your webhook, 7 days |
| `GET /v1/api-keys` `POST` `DELETE /v1/api-keys/:id` | console | API keys |
| `GET` `PUT` `DELETE /v1/webhook`, `POST /v1/webhook/test`, `POST /v1/webhook/rotate-secret` | console | the webhook endpoint |

"any" routes take any key or the session. "console" routes take the signed-in console session only (`403 session_required` with a key), so a leaked key can neither mint keys nor move your webhook. Writes with the session must come from the console's own origin.

## Catalog

Each add-on includes `available`. Unconfigured numbers and static IPs are refused
before charging, both with a new phone and when added later. Phones alone remain available.

`GET /v1/catalog`

```json
{
  "tiers": [
    { "id": "cloud", "name": "Cloud phone", "monthly_cents": 1900 },
    { "id": "identity", "name": "Real phone identity", "monthly_cents": 4900 }
  ],
  "models": [
    { "id": "android13", "name": "Cloud phone, Android 13", "tier": "cloud" },
    { "id": "s24fe", "name": "Samsung Galaxy S24 FE", "tier": "identity" },
    { "id": "pixel6", "name": "Google Pixel 6", "tier": "identity" }
  ],
  "addons": [
    { "id": "number", "name": "Number", "monthly_cents": 900, "available": false },
    { "id": "proxy", "name": "Static IP", "monthly_cents": 600, "available": false }
  ],
  "balance_cents": 5000
}
```

## Phones

### The phone object

```json
{
  "id": "ph_7k2m9q",
  "label": "signup-bot",
  "tier": "identity",
  "model": "pixel6",
  "status": "ready",
  "online": true,
  "number": "+15555550134",
  "number_status": "ready",
  "ip": "203.0.113.24",
  "proxy_status": "ready",
  "auto_renew": true,
  "monthly_cents": 6400,
  "paid_until": "2026-11-22T10:24:00Z",
  "created_at": "2026-10-22T10:20:11Z",
  "ready_at": "2026-10-22T10:24:00Z",
  "ended_at": null,
  "error": null
}
```

`status` is `provisioning`, `ready`, `failed` or `ended`. `online` says whether the phone is running (as of the last few minutes). `number_status` and `proxy_status` are `provisioning` while a number or static IP you paid for is being set up, `ready` once `number` or `ip` shows it, and null without one. `monthly_cents` is what the next renewal costs: the phone and its ready add-ons (0 once it ended or failed). `paid_until` is the end of the current period, the same for the phone and its add-ons; null for a failed (refunded) setup.

### Buy a phone

`POST /v1/phones` with an `Idempotency-Key` header.

| Field | Type | |
| --- | --- | --- |
| `tier` | `cloud` or `identity` | required |
| `model` | string | a model id of that tier from the catalog; default per tier |
| `number` | boolean | add a US number |
| `proxy` | boolean | add a static IP |
| `label` | string | your name for it, up to 80 characters |

The first month of the phone and the add-ons you asked for comes from the balance at once, in one charge, all or nothing. Answers `202` with the phone, status `provisioning`; about 15 to 30 minutes later `phone.ready` (with `number.ready` and `proxy.ready` for what came with it). If the setup fails for good, the phone is `failed`, the whole charge is refunded and you get `phone.failed`. An add-on bought with the phone that cannot be set up never fails the phone: the phone becomes ready without it, and that add-on alone is taken off and refunded (`number.failed`, `proxy.failed`). `402 insufficient_balance` (with `missing_cents`) when the balance is short; nothing is charged.

### Change or end a phone

`PATCH /v1/phones/:id` with `{ "label": "...", "auto_renew": false }` (`label: null` clears it). Renewal goes on or off for the phone and its add-ons together.

A phone and its add-ons renew in one charge at `paid_until`. If the balance is short then, they all keep working for 3 days of grace (renewal is tried again hourly), then end together; nothing is charged for a part of them.

`DELETE /v1/phones/:id` turns renewal off: the phone runs until `paid_until`, then ends with its add-ons. `DELETE /v1/phones/:id?now=true` releases it at once, with no refund; the phone is wiped before anyone else gets it. Both answer the phone. A phone still `provisioning` (never ready) is cancelled at once by either one: it ends and everything it was charged comes back (`phone.ended`, reason `released`). Whenever a phone ends, a number or static IP still being set up then is refunded, as when it is removed before it worked.

## Screen and actions

### Look

`GET /v1/phones/:id/screenshot` returns the image (`image/png` or `image/jpeg`). With `?format=json`: `{ "type": "image/png", "data": "<base64>", "width": 1080, "height": 2400 }`.

`GET /v1/phones/:id/ui` returns the nodes on screen (empty nodes nothing can tap are left out). `x`, `y` is a node's center and `bounds` its box as `[left, top, right, bottom]`, all on the grid:

```json
{
  "nodes": [
    { "text": "Phone number", "description": null, "id": "com.example.market:id/phone", "class": "android.widget.EditText", "clickable": true, "x": 500, "y": 418, "bounds": [56, 395, 944, 441] },
    { "text": "Send code", "description": null, "id": "com.example.market:id/send", "class": "android.widget.Button", "clickable": true, "x": 500, "y": 560, "bounds": [56, 538, 944, 582] }
  ]
}
```

The grid is worked out from the screen size the phone has at that moment, so it stays right after a `wm size` from the shell.

### Act

`POST /v1/phones/:id/actions` with one action:

| `type` | Fields | |
| --- | --- | --- |
| `tap` | `x`, `y` | |
| `long_press` | `x`, `y`, `ms?` | 200 to 10000, default 800 ms |
| `swipe` | `x1`, `y1`, `x2`, `y2`, `ms?` | 1 to 10000, default 300 ms |
| `type` | `text` | into the focused field, up to 4096 characters |
| `key` | `key` | `home` `back` `enter` `recents` `delete` `tab` `power` `wake` |
| `open_app` | `package` | for example `com.android.settings` |
| `shell` | `command` | root shell, up to 3000 characters; output cut at 64 KB |

```json
{ "type": "swipe", "x1": 500, "y1": 800, "x2": 500, "y2": 200 }
```

Answers `{ "ok": true }`, or `{ "ok": true, "output": "..." }` for `shell`. `409 phone_not_ready` while the phone is provisioning, failed or ended; `502 phone_error` when the phone did not answer (retry), or did not confirm (look before retrying).

## Number and texts

`POST /v1/phones/:id/number` adds a US mobile number to a ready phone ($9 a month; `Idempotency-Key` accepted). It is charged now for what is left of the phone's current period (half a period left: half the price). Once it is ready it is part of the phone's renewal price; if the phone renews while it is still being set up, it is charged for the rest of the new period when it becomes ready (and taken off and refunded if that cannot be paid). It answers `202` with the phone, `number_status` `provisioning`, then `ready` with `number.ready`.

- `402 insufficient_balance` (with `missing_cents`) when the balance does not cover the rest of the period; nothing is charged.
- `503 no_capacity` when no number can be had right now: the charge is refunded at once. A number that fails later is refunded too, with `number.failed`.
- `409 conflict` when the phone has one (or one is being added or taken off right now); `409 phone_not_ready` while the phone is being set up (buy it with `number: true` instead); `409 phone_unpaid` while the phone's period is unpaid (in grace after a short renewal): top up, and once it renews add the number.
- A number that fails, or that you remove before it is ready, gets back everything it was charged.

`DELETE /v1/phones/:id/number` takes it off at once and the next renewal is cheaper (`number.removed`); no refund once it worked. `409 phone_not_ready` while the phone is being set up.

`GET /v1/phones/:id/sms` lists texts, newest first, each with `number`, the number that received it. `?since=<ISO time>` for the ones from then on. Texts arrive within about a minute.

```json
{
  "data": [
    { "id": "sms_5q2r8t", "number": "+15555550134", "from": "+15555550188", "text": "Your Market code is 482913. Do not share it.", "code": "482913", "received_at": "2026-10-22T10:24:31Z" }
  ],
  "next_before": null
}
```

## Static IP

`POST /v1/phones/:id/proxy` gives a ready phone a fixed US residential IP ($6 a month; `Idempotency-Key` accepted), charged and renewed like a number. It answers `202` with `proxy_status` `provisioning`; once the phone routes through it (a minute or two) `ip` shows it and you get `proxy.ready`. `503 no_capacity` when none is free: refunded at once. If routing fails for good you get `proxy.failed` and the refund. The same `402` and `409` answers as for a number. `DELETE /v1/phones/:id/proxy` takes it off at once (`proxy.removed`); no refund once it worked, everything back before.

## Apps

`GET /v1/apps` lists the catalog: `{ "data": [{ "id": "...", "name": "...", "package": "..." }] }`.

`POST /v1/phones/:id/apps` with `{ "app": "<id>" }` installs one on the phone and answers `{ "ok": true }` (it can take a minute). Then `open_app` with its package.

## Activity

`GET /v1/activity` lists every action sent to your phones in the last 30 days, newest first: `{ "id", "phone_id", "actor", "action", "detail", "ok", "error", "ms", "at" }`. `actor` is the API key id, or `console`. `detail` is short (`500,418`, `home`, `12 characters`): typed text and shell commands are never kept, only their length. `error` is the error code of a failed action, `ms` how long the phone took. Filter with `?phone_id=` and `?ok=false` (only failed actions) or `?ok=true`.

## Account, keys, webhook, billing

- `GET /v1/account`: `{ "id", "email", "balance_cents", "monthly_cents", "webhook_url", "created_at" }`; `monthly_cents` is what renews every 30 days.
- `GET /v1/api-keys` lists keys (never the secret): `{ "id", "name", "scopes", "last4", "created_at", "last_used_at" }`. `POST /v1/api-keys` with `{ "name"?, "scopes"? }` answers `201` with the same fields plus `key`: the key is shown this once. Up to 10 keys. `DELETE /v1/api-keys/:id` revokes one.
- `PUT /v1/webhook` with `{ "url" }` (https, public) sets the endpoint. It answers `{ "url", "secret_last4" }`, plus `secret` the first time: the signing secret (`whsec_...`) is shown this once. Changing the URL keeps the secret. `GET /v1/webhook` answers `{ "url", "secret_last4" }`, never the secret. `DELETE` removes the URL. `POST /v1/webhook/rotate-secret` makes a new secret and answers `{ "url", "secret", "secret_last4", "previous_secret_until" }`: until then deliveries are signed with both. `POST /v1/webhook/test` sends a `webhook.test` event and answers `{ "id", "delivered", "status_code", "error" }`. See [Webhooks](https://fones.ai/docs/webhooks).
- `POST /v1/billing/topup` with `{ "amount_usd": 50 }` (whole dollars, from $10) returns `{ "url" }`, a checkout page.
- `GET /v1/billing/subscriptions` lists what renews: one subscription per phone, whose price is the phone and its add-ons: `{ "id", "kind": "phone", "ref": "<phone id>", "price_cents", "status", "auto_renew", "paid_until", "renews_at", "created_at" }`. `status` is `active` (also with renewal off: `auto_renew` is then false, and it ends at `paid_until`), `grace` (unpaid, still running) or, for the moment it takes to end, `ending`.
- `GET /v1/billing/ledger` lists every charge and credit: `{ "id", "amount_cents", "kind", "ref", "created_at" }`. `kind` is `topup`, `phone` (what phones and add-ons cost), `refund` or `adjustment`. For `phone` and `refund`, `ref` starts with the phone id, then `@`:

| `ref` | What |
| --- | --- |
| `<phone id>@<period start, unix ms>` | a period of the phone and its add-ons (the first one: the purchase); as a `refund`, a failed setup given back |
| `<phone id>@number.<n>@bought`, `<phone id>@proxy.<n>@bought` | the n-th number or static IP added later, for the rest of the period it was bought in |
| `<phone id>@number.<n>@ready`, `<phone id>@proxy.<n>@ready` | the same add-on, for the rest of the period it became ready in, when the phone renewed while it was being set up |
| `<phone id>@number.<n>`, `<phone id>@proxy.<n>` | `refund` only: that add-on, failed or removed before it worked, all it was charged |

`ref.split("@")[0]` is the phone id.

## Errors

| Status | `code` | When |
| --- | --- | --- |
| 400 | `invalid_request` | a field is missing, wrong or unknown (a typo such as `numbr` is refused, never ignored); `message` says which |
| 401 | `unauthorized` | no key, or a revoked one |
| 402 | `insufficient_balance` | the balance does not cover the purchase; `missing_cents` says by how much |
| 403 | `insufficient_scope` | the key lacks the scope for this route |
| 403 | `session_required` | a console route called with a key |
| 404 | `not_found` | no such phone (or not yours), app or key |
| 409 | `phone_not_ready` | the phone is provisioning, failed or ended |
| 409 | `conflict` | for example a number on a phone that has one |
| 409 | `phone_unpaid` | an add-on for a phone whose period is unpaid (in grace); top up first |
| 409 | `idempotency_conflict` | an `Idempotency-Key` already used for another request |
| 429 | `rate_limited` | too many requests; retry in a few seconds |
| 502 | `phone_error` | the phone did not answer; retry, or look first when it says so |
| 503 | `no_capacity` | no number or static IP can be had right now; the charge is refunded |
| 503 | `sales_closed` | top-ups are not open |
| 500 | `internal_error` | our fault; safe to retry with the same `Idempotency-Key` |
