# fones > fones gives AI agents real Android phones in the cloud, with root, an optional US mobile number and an optional static IP. The customer's own agent drives the phone over MCP or REST: screenshots, UI nodes, taps, swipes, text, keys, apps, root shell and SMS. Phones, numbers and IPs are bought from a prepaid balance, monthly, from the console or the API. - MCP: `https://api.fones.ai/mcp`, streamable HTTP, `Authorization: Bearer fones_...` (keep the key in `FONES_API_KEY`). - REST: `https://api.fones.ai/v1`, same keys, errors `{ "error": { "code", "message" } }`. OpenAPI: https://fones.ai/openapi.json. - Screen coordinates are a 0 to 1000 grid on both axes; `ui_snapshot` returns node centers on the same grid. Loop: look, act, look again. - MCP tools: `list_phones`, `get_phone`, `screenshot`, `ui_snapshot`, `tap`, `long_press`, `swipe`, `type_text`, `press_key`, `open_app`, `shell`, `list_apps`, `install_app`, `read_sms`, `get_catalog`, `buy_phone`, `add_number`, `add_proxy`, `release_phone`. - Prices per month: Cloud phone $19, Real phone identity $49, Number $9, Static IP $6. Custom setups on request. Minimum top-up $10. Cancel any time; partial months are not refunded. US only for now. - Key scopes: `phones:read`, `phones:control`, `phones:buy`. ## Docs - [Overview](https://fones.ai/docs.md): How to give an AI agent a real Android phone over MCP or REST, buy phones, numbers and static IPs from the API, and get webhooks. - [Quickstart](https://fones.ai/docs/quickstart.md): From zero to your agent tapping on a real Android phone, in five calls with curl. - [Connect your agent](https://fones.ai/docs/connect.md): Add the fones MCP server to Claude Code, Cursor, Codex, Claude Desktop or any MCP client, with your API key in an environment variable. - [MCP tools](https://fones.ai/docs/mcp.md): Every MCP tool of fones, its arguments and its scope, plus the 0 to 1000 grid, the look-act-look loop and errors. - [REST API](https://fones.ai/docs/api.md): The fones REST API, every route with its scope, body and answer, plus errors, pagination and idempotency. - [Webhooks](https://fones.ai/docs/webhooks.md): The events fones sends to your endpoint (phone ready, SMS received, balance low...), how they are signed and how retries work. ## API - [OpenAPI 3.1](https://fones.ai/openapi.json): every REST route, schema and webhook event - [Pricing](https://fones.ai/pricing.md): prices and how billing works ## Optional - [Everything in one file](https://fones.ai/llms-full.txt): this summary, every docs page, pricing and the policies - [Terms of Service](https://fones.ai/terms.md) - [Privacy Policy](https://fones.ai/privacy.md) - [Acceptable use](https://fones.ai/acceptable-use.md) - [whasp](https://whasp.ai): WhatsApp numbers for AI agents, ready to use, on the same phones --- # Docs > How to give an AI agent a real Android phone over MCP or REST, buy phones, numbers and static IPs from the API, and get webhooks. Source: https://fones.ai/docs fones gives AI agents real Android phones in the cloud. Your agent drives them over MCP or REST: it reads the screen, taps, types, opens apps, runs root shell commands and reads the texts that reach the phone's number. You buy phones, numbers and static IPs from a prepaid balance, in the console or from the API. ## Start here - [Quickstart](https://fones.ai/docs/quickstart): an API key, a phone, and your first look and tap, in five calls. - [Connect your agent](https://fones.ai/docs/connect): ready blocks for Claude Code, Cursor, Codex, Claude Desktop and any MCP client. - [MCP tools](https://fones.ai/docs/mcp): every tool, its arguments and its scope. - [REST API](https://fones.ai/docs/api): the same actions over HTTP, plus keys, webhook and billing. - [Webhooks](https://fones.ai/docs/webhooks): events, signatures and retries. ## The basics - REST base URL: `https://api.fones.ai/v1`. MCP endpoint: `https://api.fones.ai/mcp`. - Auth: `Authorization: Bearer fones_...` with an API key from the console. Keep it in `FONES_API_KEY`. - Screen coordinates are a grid from 0 to 1000 on both axes. `(500, 500)` is the middle of the screen on every phone. - Errors look like `{ "error": { "code": "...", "message": "..." } }`, on REST and MCP alike. - Money is in US cents. Times are ISO 8601. ## For agents Every docs page has a Markdown twin: add `.md` to its URL, or ask for `Accept: text/markdown`. The whole site in one file is [/llms-full.txt](https://fones.ai/llms-full.txt), a short summary is [/llms.txt](https://fones.ai/llms.txt), and the API is described in [/openapi.json](https://fones.ai/openapi.json). --- # Quickstart > From zero to your agent tapping on a real Android phone, in five calls with curl. Source: https://fones.ai/docs/quickstart Five calls: check the catalog, buy a phone, wait for it, look at the screen, tap. Then hand the phone to your agent over MCP. ## 1. Get a key Sign in at [fones.ai/login](https://fones.ai/login) with your email, top up the balance (from $10) and create an API key under Developers. The key is shown once. Keep it in an environment variable: ```bash export FONES_API_KEY=fones_... # from Developers, API keys export API=https://api.fones.ai/v1 ``` New keys have every scope: `phones:read`, `phones:control` and `phones:buy`. Create a key without `phones:buy` for an agent that should never spend. ## 2. Check the catalog ```bash curl $API/catalog -H "Authorization: Bearer $FONES_API_KEY" ``` The answer lists the tiers (`cloud`, `identity`), the models you can pick for a real phone identity, the prices in cents and your balance. ## 3. Buy a phone ```bash curl -X POST $API/phones \ -H "Authorization: Bearer $FONES_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: first-phone" \ -d '{"tier":"cloud","number":true,"label":"first"}' ``` ```json { "id": "ph_7k2m9q", "label": "first", "tier": "cloud", "status": "provisioning", "number": null, "number_status": "provisioning", "ip": null, "proxy_status": null, "monthly_cents": 2800 } ``` The first month ($19 for the phone, $9 for the number) comes from the balance now. Send the same `Idempotency-Key` again and you get the same phone, never a second one. The phone starts as `provisioning`. ## 4. Wait until it is ready ```bash curl $API/phones/ph_7k2m9q -H "Authorization: Bearer $FONES_API_KEY" ``` When `status` is `ready`, the phone has its number and, if you asked, its static IP (one that could not be set up is refunded instead, with `number.failed` or `proxy.failed`: the phone is ready all the same). With a [webhook](https://fones.ai/docs/webhooks) you get `phone.ready` instead of polling. If setup fails you get `phone.failed` and the charge back. ## 5. Look, then act ```bash curl $API/phones/ph_7k2m9q/screenshot -H "Authorization: Bearer $FONES_API_KEY" -o screen.png curl $API/phones/ph_7k2m9q/ui -H "Authorization: Bearer $FONES_API_KEY" ``` `ui` returns the screen as nodes: text, id, class, whether it is clickable, and its center on the 0 to 1000 grid. Tap one: ```bash curl -X POST $API/phones/ph_7k2m9q/actions \ -H "Authorization: Bearer $FONES_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type":"tap","x":500,"y":418}' ``` That is the whole loop: look, act, look again. In code: ```ts const api = (path: string, init: RequestInit = {}) => fetch(`https://api.fones.ai/v1${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.FONES_API_KEY}`, 'Content-Type': 'application/json' }, }).then((r) => r.json()); const phone = 'ph_7k2m9q'; const { nodes } = await api(`/phones/${phone}/ui`); // look const button = nodes.find((n) => n.text === 'Send code'); await api(`/phones/${phone}/actions`, { // act method: 'POST', body: JSON.stringify({ type: 'tap', x: button.x, y: button.y }), }); const sms = await api(`/phones/${phone}/sms`); // look again console.log(sms.data[0]?.code); ``` ```python import os, requests api = "https://api.fones.ai/v1" s = requests.Session() s.headers["Authorization"] = f"Bearer {os.environ['FONES_API_KEY']}" phone = "ph_7k2m9q" nodes = s.get(f"{api}/phones/{phone}/ui").json()["nodes"] # look button = next(n for n in nodes if n["text"] == "Send code") s.post(f"{api}/phones/{phone}/actions", # act json={"type": "tap", "x": button["x"], "y": button["y"]}) sms = s.get(f"{api}/phones/{phone}/sms").json()["data"] # look again print(sms[0]["code"] if sms else None) ``` ## Next - [Connect your agent](https://fones.ai/docs/connect) and let it drive the phone itself. - [MCP tools](https://fones.ai/docs/mcp) and the [REST API](https://fones.ai/docs/api) for every action. - When you are done, `DELETE /v1/phones/ph_7k2m9q` stops the renewal; the phone runs to the end of the paid 30 days. --- # Connect your agent > Add the fones MCP server to Claude Code, Cursor, Codex, Claude Desktop or any MCP client, with your API key in an environment variable. Source: https://fones.ai/docs/connect One MCP server for every client: - URL: `https://api.fones.ai/mcp` - Transport: streamable HTTP, stateless - Auth: `Authorization: Bearer fones_...` Create a key in the console under Developers and keep it in `FONES_API_KEY`. The blocks below read it from there, so it never lands in a file you commit. The tools a key can call follow its scopes: a key without `phones:buy` cannot buy. ## Claude Code ```bash claude mcp add --transport http --scope user \ fones https://api.fones.ai/mcp \ --header "Authorization: Bearer $FONES_API_KEY" ``` Check it with `claude mcp list`, or `/mcp` inside a session. The shell expands the variable, so the key is saved in `~/.claude.json`. To keep it out of every file, add a project file instead; Claude Code reads `${FONES_API_KEY}` from the environment when it starts: ```json { "mcpServers": { "fones": { "type": "http", "url": "https://api.fones.ai/mcp", "headers": { "Authorization": "Bearer ${FONES_API_KEY}" } } } } ``` ## Cursor ```json { "mcpServers": { "fones": { "url": "https://api.fones.ai/mcp", "headers": { "Authorization": "Bearer ${env:FONES_API_KEY}" } } } } ``` Put it in `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one. Cursor fills `${env:FONES_API_KEY}` from the environment it was started in. The server shows up under MCP in Cursor's settings. ## Codex ```toml [mcp_servers.fones] url = "https://api.fones.ai/mcp" bearer_token_env_var = "FONES_API_KEY" ``` Or from the terminal: ```bash codex mcp add fones --url https://api.fones.ai/mcp --bearer-token-env-var FONES_API_KEY ``` Codex sends the variable's value as the bearer token. It must be set in the shell that runs `codex`. Check with `codex mcp list`, or `/mcp` in a session. ## Claude Desktop Claude Desktop reads `claude_desktop_config.json` (Settings, Developer, Edit config). It does not see your shell's variables, so the key goes in the file, and `mcp-remote` connects for it: ```json { "mcpServers": { "fones": { "command": "npx", "args": [ "-y", "mcp-remote", "https://api.fones.ai/mcp", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer fones_..." } } } } ``` Restart Claude Desktop after saving. The file lives in `~/Library/Application Support/Claude/` on macOS and `%APPDATA%\Claude\` on Windows. You can also add the server as a custom connector: Settings, Connectors, Add custom connector, with the URL above and an `Authorization` request header. Connectors connect from Anthropic's cloud, not from your computer. ## Any MCP client Most clients take this shape. VS Code calls the top-level key `servers` instead of `mcpServers`. ```json { "mcpServers": { "fones": { "type": "http", "url": "https://api.fones.ai/mcp", "headers": { "Authorization": "Bearer fones_..." } } } } ``` Check the endpoint and your key without a client: ```bash curl -s https://api.fones.ai/mcp \ -H "Authorization: Bearer $FONES_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ## Or ask your agent Paste this into an agent that can edit its own config: ```text Add an MCP server named "fones": streamable HTTP at https://api.fones.ai/mcp, header "Authorization: Bearer ". Keep the key in the environment, not in a file I commit. Then call list_phones and show me the result. ``` ## Then Ask for something small first: "Take a screenshot of my phone and tell me what is on it." Your agent will call `list_phones`, then `screenshot`. From there it can drive any app. The [MCP tools](https://fones.ai/docs/mcp) page lists what it can call. --- # MCP tools > Every MCP tool of fones, its arguments and its scope, plus the 0 to 1000 grid, the look-act-look loop and errors. Source: https://fones.ai/docs/mcp The MCP server at `https://api.fones.ai/mcp` has these tools. They share their code with the [REST API](https://fones.ai/docs/api), so the two always agree. A tool your key has no scope for is not listed to your agent. ## The loop Phones change under your agent: a dialog opens, a page loads late. The tools are built for a short loop: 1. **Look.** `screenshot` for an image, `ui_snapshot` for the screen as text. `ui_snapshot` is cheaper and gives exact centers; take a screenshot when the screen is mostly pictures. 2. **Act.** One `tap`, `swipe`, `type_text` or `press_key`. 3. **Look again** before the next action, to see what the action did. ## Coordinates Every point is on a grid from 0 to 1000 on both axes, whatever the phone's resolution: `(0, 0)` is the top left corner, `(1000, 1000)` the bottom right, `(500, 500)` the middle. `ui_snapshot` returns each node's center on the same grid, so a node's `x` and `y` can go straight into `tap`. ## Which phone Tools that act on a phone take `phone_id` (`ph_...`, from `list_phones`). When the account has exactly one phone, `phone_id` can be left out. ## Tools ### Look | Tool | Arguments | Scope | What | | --- | --- | --- | --- | | `list_phones` | `limit?, before?` | `phones:read` | Your phones with status, number and IP. | | `get_phone` | `phone_id` | `phones:read` | One phone. | | `screenshot` | `phone_id` | `phones:read` | The screen as an image. | | `ui_snapshot` | `phone_id` | `phones:read` | The screen as nodes: text, description, id, class, clickable, center x and y and bounds on the grid. | ### Act | Tool | Arguments | Scope | What | | --- | --- | --- | --- | | `tap` | `phone_id, x, y` | `phones:control` | Tap a point. | | `long_press` | `phone_id, x, y, ms?` | `phones:control` | Press and hold a point. | | `swipe` | `phone_id, x1, y1, x2, y2, ms?` | `phones:control` | Swipe from one point to another. | | `type_text` | `phone_id, text` | `phones:control` | Type into the focused field. | | `press_key` | `phone_id, key` | `phones:control` | One key: home, back, enter, recents, delete, tab, power, wake. | | `open_app` | `phone_id, package` | `phones:control` | Start an app by package name. | | `shell` | `phone_id, command` | `phones:control` | Run a root shell command. Output is cut at 64 KB. | ### Apps | Tool | Arguments | Scope | What | | --- | --- | --- | --- | | `list_apps` | none | `phones:read` | The app catalog. | | `install_app` | `phone_id, app` | `phones:control` | Install an app from the catalog. | ### Texts | Tool | Arguments | Scope | What | | --- | --- | --- | --- | | `read_sms` | `phone_id, since?, limit?, before?` | `phones:read` | Texts on the phone's number, newest first, with the number that got each and the code when one is found. | ### Buy | Tool | Arguments | Scope | What | | --- | --- | --- | --- | | `get_catalog` | none | `phones:read` | What can be bought, the prices and your balance. | | `buy_phone` | `tier, model?, number?, proxy?, label?, idempotency_key?` | `phones:buy` | Buy a phone from the balance. | | `add_number` | `phone_id, idempotency_key?` | `phones:buy` | Add a US number to a ready phone: the rest of the period now, then renewed with it. | | `add_proxy` | `phone_id, idempotency_key?` | `phones:buy` | Add a static IP to a ready phone: the rest of the period now, then renewed with it. | | `release_phone` | `phone_id, now?` | `phones:buy` | Stop renewing, or release at once with `now`. One still provisioning is cancelled and refunded. | Keys for `press_key`: `home`, `back`, `enter`, `recents`, `delete`, `tab`, `power`, `wake`. `ms` is a duration in milliseconds. `since` is an ISO 8601 time. ## What comes back - `screenshot`: an image (PNG or JPEG). - `ui_snapshot`: `{ "nodes": [...] }`, each with `text`, `description`, `id`, `class`, `clickable`, its center `x`, `y` and its `bounds` `[left, top, right, bottom]`, all on the grid; empty nodes nothing can tap are left out. - `read_sms`: texts newest first, each with `number` (the number that received it), `from`, `text`, `code` (the verification code found in the text, or null) and `received_at`. - `buy_phone`: the new phone with status `provisioning`. It is ready when `get_phone` says `ready`, or when your webhook gets `phone.ready`. Pass `idempotency_key` and a retry never buys twice. - `add_number`, `add_proxy`: the phone, with `number_status` or `proxy_status` `provisioning` until the number or IP shows. They charge the rest of the phone's current period now; once ready the add-on is part of the phone's renewal price. They accept `idempotency_key` too. - `list_phones`, `read_sms`, `list_apps`: `{ "data": [...] }`; the first two page with `limit` and `before` (`next_before`). - Actions: `{ "ok": true }`, or for `shell` `{ "ok": true, "output": "..." }`. ## Scopes | Scope | Tools | | --- | --- | | `phones:read` | look at phones, screens, texts, apps, catalog | | `phones:control` | gestures, text, keys, apps, shell | | `phones:buy` | buy phones, numbers and static IPs, release phones | ## Errors A failed call returns an error result with `{ "error": { "code", "message" } }`, the same body as REST. The codes are listed in the [REST API](https://fones.ai/docs/api#errors). Three worth knowing: `phone_not_ready` (the phone is still provisioning, try again shortly), `insufficient_balance` (top up first; `missing_cents` says how much) and `phone_error` (the phone did not answer: retry, or look first when the message says so). Two cases are protocol errors (JSON-RPC `-32602`) instead, and nothing runs: arguments that do not fit the tool (a wrong type, a value out of range, or an argument the tool does not have, such as `numbr`), with a message naming the argument; and a tool outside your key's scopes, which answers `Tool not found`. That is on purpose: such a tool is not listed for that key either, so to your agent it does not exist. The server is stateless: `tools/list` and `tools/call` work without an `initialize` first. --- # 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=` 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": "", "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=` 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": "" }` 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": "", "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 | | --- | --- | | `@` | a period of the phone and its add-ons (the first one: the purchase); as a `refund`, a failed setup given back | | `@number.@bought`, `@proxy.@bought` | the n-th number or static IP added later, for the rest of the period it was bought in | | `@number.@ready`, `@proxy.@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 | | `@number.`, `@proxy.` | `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` | --- # Webhooks > The events fones sends to your endpoint (phone ready, SMS received, balance low...), how they are signed and how retries work. Source: https://fones.ai/docs/webhooks One HTTPS endpoint per account. Set it in the console under Developers (the console's session only: an API key cannot move where events go). Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com/), so the official `standardwebhooks` libraries verify them. ## Delivery Each event is a `POST` with a JSON body: ```json { "id": "evt_3n8w1c", "type": "sms.received", "created_at": "2026-10-22T10:24:31Z", "data": { "phone_id": "ph_7k2m9q", "number": "+15555550134", "from": "+15555550188", "text": "Your Market code is 482913. Do not share it.", "code": "482913", "received_at": "2026-10-22T10:24:29Z" } } ``` Three headers come with it: - `webhook-id`: the event id. It stays the same across retries: use it to skip duplicates. - `webhook-timestamp`: when this attempt was signed, in unix seconds. - `webhook-signature`: `v1,`, the HMAC-SHA256 of `..` keyed with your secret. Right after a rotation it holds two signatures separated by a space (see below); one match is enough. Answer with any 2xx within 10 seconds, then do the slow work. Anything else is retried with backoff for about a day. Events can arrive more than once and out of order. ## The signing secret The secret looks like `whsec_`. It is shown once: in the answer of the first `PUT /v1/webhook` (or in the console when you set the URL) and when you rotate it. Changing the URL keeps it; `GET /v1/webhook` only gives its last 4 characters. `POST /v1/webhook/rotate-secret` (or Rotate in the console) answers the new secret with `previous_secret_until`: for the next 24 hours every delivery is signed with both the new secret and the old one (`v1, v1,`), so you can switch your receiver without missing an event. After that only the new one signs. ## Verify the signature Check the signature on the raw body, before parsing it. The libraries also refuse a timestamp more than 5 minutes away. Node (`npm install standardwebhooks`): ```ts import { Webhook } from 'standardwebhooks'; const wh = new Webhook(process.env.WEBHOOK_SECRET!); // whsec_... // In your handler, with the body as it came (a string or a Buffer): const event = wh.verify(rawBody, { 'webhook-id': req.headers['webhook-id'], 'webhook-timestamp': req.headers['webhook-timestamp'], 'webhook-signature': req.headers['webhook-signature'], }); // throws WebhookVerificationError when it does not match ``` Python (`pip install standardwebhooks`): ```python import os from standardwebhooks.webhooks import Webhook wh = Webhook(os.environ["WEBHOOK_SECRET"]) # whsec_... event = wh.verify(raw_body, dict(request.headers)) # raises WebhookVerificationError ``` Without a library (Web Crypto: Node 20+, Workers, Deno, Bun): ```ts export async function verify(req: Request, secret: string) { const id = req.headers.get('webhook-id') ?? ''; const ts = req.headers.get('webhook-timestamp') ?? ''; const body = await req.text(); if (!id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null; const raw = Uint8Array.from(atob(secret.replace(/^whsec_/, '')), (c) => c.charCodeAt(0)); const key = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${id}.${ts}.${body}`))); const expected = btoa(String.fromCharCode(...mac)); const given = (req.headers.get('webhook-signature') ?? '').split(' ').map((s) => s.replace(/^v1,/, '')); // compare in constant time in production (e.g. crypto.timingSafeEqual in Node) return given.includes(expected) ? JSON.parse(body) : null; } ``` ## Events | Event | When | `data` | | --- | --- | --- | | `phone.ready` | a phone you bought is ready to drive | the phone | | `phone.failed` | a phone could not be set up; the charge is refunded | the phone, with `error` | | `phone.renewed` | a phone renewed for another month from the balance, with its add-ons, in one charge | the phone, with `renewed_until` | | `phone.ended` | a phone reached its end, with its add-ons | the phone, with `reason`: `cancelled` (renewal off), `unpaid` (3 days of grace passed) or `released` | | `number.ready` | a number added to a phone is active | `{ phone_id, number }` | | `number.failed` | a number added to a phone could not be set up; refunded | `{ phone_id, error }` | | `proxy.ready` | a static IP added to a phone is active | `{ phone_id, ip }` | | `proxy.failed` | a static IP added to a phone could not be set up; refunded | `{ phone_id, error }` | | `number.removed` | a number was taken off a phone (no refund once it worked) | `{ phone_id }` | | `proxy.removed` | a static IP was taken off a phone (no refund once it worked) | `{ phone_id }` | | `sms.received` | a text reached a phone's number; `number` is the number that received it | `{ phone_id, number, from, text, code, received_at }` | | `balance.low` | the balance will not cover what renews in the next 7 days | `{ balance_cents, renewals_cents }` | "The phone" is the same object as `GET /v1/phones/:id`. Every event is recorded in the same transaction as the change it reports, so none is lost; its `id` is fixed for that change, so a retry or a replay never sends a second one. ## Test it `POST /v1/webhook/test` (or the button in the console) sends a `webhook.test` event (`data`: `{ message }`) to your endpoint and returns `{ id, delivered, status_code, error }`. The console lists the recent deliveries with their status. --- # Pricing > Monthly prices, paid from a prepaid balance in US dollars. Cancel any time. Source: https://fones.ai/pricing | Item | What | Price | | --- | --- | --- | | Cloud phone | Android 13 with root and 8 cores. For automation, testing and most apps. | $19 / month | | Real phone identity | Apps see a real retail model, like a Pixel or a Galaxy. For apps that check the phone. | $49 / month | | Number | A real US mobile number on one phone. Receives SMS; codes are read for you. | $9 / month | | Static IP | A fixed US residential IP for one phone: same address, same place, every session. | $6 / month | | Custom | Many phones, other countries, other setups | Talk to us: hello@fones.ai | ## How billing works - Top up the balance from $10, in the console. - Buying a phone takes 30 days of the phone and its add-ons from the balance at once. If something cannot be set up, its charge comes back. - A number or static IP added later costs the days left in the phone's period, then renews with the phone. - The phone renews with its add-ons every 30 days in one charge. If the balance is short, everything stays on for 3 days of grace. We email you before that happens. - Cancel any time: the phone runs to the end of the period you paid, then ends. Partial periods are not refunded. - A number and a static IP belong to one phone and end with it. - US only for now. No per-minute billing yet. - An agent can read prices and the balance with `get_catalog` and buy with `buy_phone` if its key has the `phones:buy` scope. --- # Acceptable use > What you may not do with fones phones, numbers and static IPs, and what happens if you do. Source: https://fones.ai/acceptable-use. Last updated: October 22, 2026 fones phones are Android phones with real numbers and real IPs. Use them the way you would use your own phone. These rules are part of the [Terms](https://fones.ai/terms) and cover you, your users and your agents. ## Not allowed - **Spam.** Bulk or unsolicited messages, calls, posts, comments or reviews. - **Fraud.** Deceiving people or services for gain: payment fraud, fake engagement, fake reviews, phishing, impersonation. - **Accounts that are not yours.** Signing in to, recovering or holding accounts you are not allowed to use; credential stuffing; receiving someone else's verification codes. - **Harassment.** Threats, stalking, or content meant to hurt someone. - **Anything illegal**, where you are, where your users are, or where the phones are. - **Attacks from our phones.** Scanning, denial of service, malware, or breaking into systems. - **Reselling** phones, numbers or IPs as a service of your own, unless we agreed it in writing. ## Your risk Apps have their own rules. If an app limits, flags or bans an account you run on a fones phone, that risk is yours. ## What we do If we see a breach, or get a credible report of one, we can stop the phones involved at once, revoke keys, and suspend or close the account. Where we can, we tell you first. We keep the activity log of the phones involved as evidence. ## Not sure? If you are unsure whether something is allowed, write to support@fones.ai before you build it. --- # Privacy Policy > What fones keeps about you and your phones, why, for how long, and who processes it. Source: https://fones.ai/privacy. Last updated: October 22, 2026 This policy explains what fones ("we") keeps when you use the site, the console, the API and the MCP server, and why. ## What we keep - **Account.** Your email address, and the sign-in sessions of the console. - **API keys.** A fingerprint (SHA-256) of each key, its name, scopes and last use. We never keep the key itself. - **Billing.** Your balance, top-ups and charges. Card details stay with our payment processor; we never see them. - **Phones.** What you bought, its status, number and IP, and the activity log: each action sent to a phone (which key, what, the result, when), kept 30 days. - **Texts.** SMS that reach your numbers, kept 30 days so `read_sms` and the console can show them. - **Webhook.** Your endpoint URL and the recent delivery attempts. - **Logs.** Request logs (IP address, path, status, time) for security and debugging, kept up to 30 days. We do not keep screenshots after we send them to you, and we do not record the phone's screen. ## What is on your phones The apps, accounts and files on your phones are yours. We access them only to run the service, to help when you ask, or to investigate abuse. When a phone ends, it is wiped before anyone else uses it. ## Why To run the service you asked for (the contract), to bill it, to keep it secure and to stop abuse (our legitimate interest), and to meet legal duties such as tax records. ## Who processes it - Cloudflare (hosting, database, email), worldwide. - Our phone, number and IP providers, for the phones, numbers and addresses you buy. - Our payment processor, for top-ups. We do not sell your data and do not use it for advertising. We share it with authorities only when the law requires. ## Cookies The console uses one cookie to keep you signed in. The site sets no tracking cookies. The theme you pick is kept in your browser's local storage. ## Your rights You can ask for a copy of your data, its correction or its deletion, and you can close your account at any time. Write to support@fones.ai. Records we must keep by law (for example invoices) stay for the time the law sets. ## Changes We may update this policy; the date at the top changes when we do. Questions: support@fones.ai. --- # Terms of Service > The terms for using fones: phones, numbers and static IPs, the prepaid balance, renewals, refunds and the rules of use. Source: https://fones.ai/terms. Last updated: October 22, 2026 These terms cover fones: the site, the console, the API and the MCP server. "fones", "we" and "us" mean the operator of fones.ai. "You" means the company or person that opens an account. By using fones you accept these terms. If you accept them for a company, you confirm you can bind it. ## What fones is fones rents Android phones that run in the cloud, with optional US mobile numbers and static IPs, to be driven by software, including AI agents, over an API and an MCP server. You decide what your software does on the phones. fones is for business use; you must be at least 18 to open an account. ## Your account and keys You sign in with a link we email you, so keep that inbox secure. You are responsible for everything done with your account and your API keys, including what your agents do. Keep keys secret, give each agent only the scopes it needs, and revoke any key that leaks. ## Phones, numbers and static IPs - A phone is rented in periods of 30 days, together with its number and static IP if it has them. It is yours to use while it is paid; it stays ours. - A number and a static IP belong to one phone and end with it. - When a phone ends, everything on it is wiped. Keep what you need elsewhere. - Apps and websites you use from a phone have their own terms. Following them is your job. If an app limits, flags or bans an account you run, that is not our breach and does not entitle you to a refund. - Numbers come from a mobile carrier through our provider. Some services refuse some numbers; we cannot promise that a given service accepts one. ## Prepaid balance and renewals - fones runs on a prepaid balance in US dollars, topped up in the console through our payment processor, from $10. - Buying a phone takes its first 30 days, and those of the add-ons bought with it, from the balance at once. If a phone or an add-on cannot be set up, we credit its charge back. - A number or static IP added later is charged for the days left in the phone's current period; from the next renewal it is part of the phone's price. Removing one takes effect at once and is not refunded. - While renewal is on, the phone renews with its add-ons every 30 days in one charge. If the balance cannot cover it, the phone and its add-ons stay on for 3 days of grace, then end. - You can turn renewal off or end a phone at any time. It runs to the end of the paid period, unless you release it at once. ## Refunds Unused balance is not refunded, unless the law requires it. A period that has started is not refunded, including when you end a phone or remove an add-on early or when we suspend your account for breaking these terms. ## Rules of use Our [Acceptable use policy](https://fones.ai/acceptable-use) is part of these terms. If you break it, we can stop the phones involved, suspend or close your account, and keep your balance where the law allows. We may warn you first, but need not. ## Your data The content of your phones (apps, accounts, files, texts) is yours. We look at it only to run the service, to help you when you ask, or to investigate abuse. Our [Privacy Policy](https://fones.ai/privacy) explains what we keep and for how long. ## Service as is fones is provided "as is" and "as available", without an uptime guarantee. Phones can go offline, apps can change, texts can arrive late or not at all. As far as the law allows, we disclaim all warranties, express or implied. We may change features, limits and prices; price changes apply from your next renewal, and we tell you before. ## Liability As far as the law allows, we are not liable for indirect or consequential losses, lost profits or lost data, and our total liability is limited to what you paid us in the 3 months before the claim. ## Ending You can close your account at any time from the console or by writing to support@fones.ai. We can end the service with 30 days notice, or at once for a breach of these terms. ## Changes and contact We may update these terms; the date at the top changes when we do, and we email you about material changes. Questions: support@fones.ai.