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:controlorphones: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_centson 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_beforeis null on the last one. - Idempotency. Routes that buy accept an
Idempotency-Keyheader (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 a409 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(withmissing_cents) when the balance does not cover the rest of the period; nothing is charged.503 no_capacitywhen no number can be had right now: the charge is refunded at once. A number that fails later is refunded too, withnumber.failed.409 conflictwhen the phone has one (or one is being added or taken off right now);409 phone_not_readywhile the phone is being set up (buy it withnumber: trueinstead);409 phone_unpaidwhile 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_centsis what renews every 30 days.GET /v1/api-keyslists keys (never the secret):{ "id", "name", "scopes", "last4", "created_at", "last_used_at" }.POST /v1/api-keyswith{ "name"?, "scopes"? }answers201with the same fields pluskey: the key is shown this once. Up to 10 keys.DELETE /v1/api-keys/:idrevokes one.PUT /v1/webhookwith{ "url" }(https, public) sets the endpoint. It answers{ "url", "secret_last4" }, plussecretthe first time: the signing secret (whsec_...) is shown this once. Changing the URL keeps the secret.GET /v1/webhookanswers{ "url", "secret_last4" }, never the secret.DELETEremoves the URL.POST /v1/webhook/rotate-secretmakes a new secret and answers{ "url", "secret", "secret_last4", "previous_secret_until" }: until then deliveries are signed with both.POST /v1/webhook/testsends awebhook.testevent and answers{ "id", "delivered", "status_code", "error" }. See Webhooks.POST /v1/billing/topupwith{ "amount_usd": 50 }(whole dollars, from $10) returns{ "url" }, a checkout page.GET /v1/billing/subscriptionslists 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" }.statusisactive(also with renewal off:auto_renewis then false, and it ends atpaid_until),grace(unpaid, still running) or, for the moment it takes to end,ending.GET /v1/billing/ledgerlists every charge and credit:{ "id", "amount_cents", "kind", "ref", "created_at" }.kindistopup,phone(what phones and add-ons cost),refundoradjustment. Forphoneandrefund,refstarts 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 |