docs / api Markdown

REST API

Base URL: https://api.fones.ai/v1 (also /v1 on fones.ai). JSON in, JSON out. The machine-readable version is /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.
  • 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

{
  "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

{
  "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:

{
  "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
{ "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.

{
  "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.
  • 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