> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phoneuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Buy, drive and return a device

> The whole loop, end to end, in the console and over the API, with the readings from a real run.

This is the shortest complete thing you can do with phonebase: get a device,
make it do something, give it back. Everything below was run end to end with a
real card on a real device. The numbers are readings, not illustrations.

<Info>
  **What it costs.** One cloud phone, billed monthly at the rate published on
  [Billing modes](/billing/modes#rates). Returning it at the end gives back the
  part of the month you did not use, so a loop you finish in an afternoon costs a
  fraction of a month rather than a month.

  **What you need.** A browser, an org **admin** sign in, and a card. Nothing
  installed. About fifteen minutes.

  **What you do not need.** An API key, unless you want to do step 3.
</Info>

## Step 1: Buy a device

Go to **`/devices/add`**.

The screen draws itself from `GET /v1/skus`, which is the deployment's actual
price list. One entry per pair of thing and billing mode, read on 2026-09-13:

```json theme={null}
{
  "sku": "cloud_phone",
  "billing": "subscription",
  "price": { "amountMinor": 4900, "currency": "usd" },
  "shipsNow": true,
  "stock": "in_stock"
}
```

Three things to read off that.

`amountMinor` is the **minor unit**: `1900` with `currency: "usd"` means
nineteen dollars, not nineteen hundred. Prices are pre tax and come from the
payment provider's own price objects, carried through unaltered; this service
does no arithmetic on a charge. The current rate card is on
[Billing modes](/billing/modes#rates), and
[phonebase.co](https://phonebase.co/#pricing) is canonical if the two disagree.

A pair this deployment holds no price for is **absent from the list** rather
than shown without a number. So the list is exactly what can be bought here
today, and a card you cannot see is not a card you are missing.

`stock` says whether a free unit of that kind exists right now, and it has
three answers:

| `stock` | What it means for the buyer |
| - | - |
| `in_stock` | A unit is free. Paying binds a device straight away. |
| `out_of_stock` | None is free. The purchase still goes through and the device is promised instead. |
| `unknown` | This deployment could not read its shelf just now. The price is real; how soon is not known. |

`shipsNow` is the older field for the same answer: `true` exactly when `stock`
is `in_stock`, so it cannot tell `out_of_stock` from `unknown`.

<Note>
  `out_of_stock` (and `shipsNow: false`) does **not** mean the purchase is
  refused. By default it succeeds and the device is promised instead, which
  `GET /v1/fulfilments` then reports as `awaiting_provisioning` with a deadline.
  It is a verdict, never a count: how much stock is left is not published.
</Note>

Press the buy button and the console starts a checkout with
`POST /v1/checkout-sessions`, which hands back somewhere to send the browser:

```json theme={null}
{ "checkoutUrl": "https://...", "orderId": "...", "expiresAt": "..." }
```

The checkout is hosted by the payment provider. **Promotion codes are
accepted** on it, so a code you were given goes in there rather than anywhere in
this product.

Pay, and the order settles on its own. You do not have to do anything to claim
the device: the payment provider tells the control plane, the control plane
grants the subscription and allocates a unit, and the device then appears in
`GET /v1/devices` and on `/devices`. If you would rather be told than poll, the
`fulfilment.changed` [webhook](/guides/webhooks) is the event for exactly this.

<Warning>
  Buying is **admin only and session only**. An API key cannot start a purchase
  and a member cannot commit the org to a subscription; both answer `403`. An
  org that already has a subscription answers `409`.
</Warning>

## Step 2: Drive it from the console

Open **`/devices/:id`** for the device that appeared.

1. **Click the screen.** The picture is the tap target. The console turns where
   you clicked into a coordinate on the normalized grid and sends the tap. Your
   first tap takes a lease on the device for **ten minutes** if your org holds
   none; if an agent's run already holds one, your tap runs under it and the
   run keeps going.
2. **Keep clicking.** A lease you took yourself stays alive while you drive.
3. **Give the device back** when you are done, or let the lease expire.

Read the disabled controls as information rather than as breakage. A device that
cannot press hardware keys draws that control disabled and says
`Action not supported on this device`. That is its `capabilities` block
speaking. The cloud phone used for this walkthrough reported `tap`, `swipe`,
`typeText: "native"`, `openApp: "intent"` and `installApp: "apk"`, and
`pressKey: false`.

Release the lease before you wander off. Nothing pauses the clock while a lease
is held, and nothing watches for idleness. A lease expires ten minutes after it
was granted and can be renewed, and no lease lives longer than sixty minutes
from when it was acquired, so the worst case is bounded but it is not free.

## Step 3: Drive it from the API

Everything the console just did is three routes. Mint an API key at **`/keys`**
with three of the eight scopes: `devices:read` to see it, `devices:lease` to take
it, `devices:act` to touch it. Keys are for servers: an API key sent from a
browser origin is refused with `api_key_from_browser_origin` whatever its scopes
are.

```bash theme={null}
export PHONEBASE_API_KEY="pbk_<keyId>_<secret>"
export DEVICE=<deviceId>
export BASE=https://mcp.phonebase.co
```

### Take the device

```bash theme={null}
curl -sS -X POST "$BASE/v1/devices/$DEVICE/lease" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY"
```

```json theme={null}
{
  "deviceId": "...",
  "leaseId": "...",
  "acquiredAt": "...",
  "expiresAt": "..."
}
```

`201`, and the request takes no body at all. Keep the `leaseId` (`export
LEASE=...`): it is a credential, it is returned by this call and by no other,
and every gesture and the release need it.

A refusal does not say why, and that is deliberate. Somebody else holding the
device, a spent quota and an unreachable device all answer the same way.

### Look at the screen

```bash theme={null}
curl -sS "$BASE/v1/devices/$DEVICE/screenshot" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY" -o screen.jpg
```

`200`, `image/jpeg`. The measured run returned a 720 by 1280 frame of 52428
bytes. **You do not need the lease for this one.** Any credential in the org
that can see the device can watch it, whoever is holding it, which is the point:
a person should be able to see what a device is doing before deciding whether to
interrupt it.

Watching writes no receipt and returns no `frameId`. If you intend to bind an
action to the exact frame you decided on, take the screenshot through the MCP
`screenshot` tool instead, which issues one. See
[Reading a trace](/getting-started/trace).

### Act

```bash theme={null}
curl -sS -X POST "$BASE/v1/devices/$DEVICE/actions" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"tap","x":500,"y":500,"leaseId":"'"$LEASE"'"}'
```

```json theme={null}
{
  "deviceId": "...",
  "status": "committed",
  "committedAt": "...",
  "idempotencyKey": "..."
}
```

`200`. `status` has exactly one value: an action that did not commit is an
error, not a different status. The response resolves only once the **device**
applied the gesture, which is why it is not instant. One tap measured today took
5076 ms from request to committed.

Six gestures are available on this route, discriminated by `action`:

| `action` | Fields it takes beyond `leaseId` |
| - | - |
| `tap` | `x`, `y` |
| `long_press` | `x`, `y`, optional `durationMs` |
| `swipe` | `fromX`, `fromY`, `toX`, `toY`, optional `durationMs` |
| `type_text` | `text` |
| `press_key` | `key` |
| `open_app` | `appId` |

`press_key` takes one of `home`, `back`, `enter`, `delete`, `app_switch`,
`power`, `volume_up`, `volume_down`. Any of them can still be refused by a
device that declared `pressKey: false`, which is the whole reason to read
`capabilities` first rather than to assume a set.

Every attempt is written to the receipt ledger **before** it is dispatched, so
`GET /v1/receipts` can tell you what happened even if you lost this response.

Send your own `idempotencyKey` and a retry replays that outcome instead of
touching the phone twice. It is a **field in the body**, not a header, and there
is no `Idempotency-Key` header anywhere on this API. When you do not send one
the service generates it and returns it, which is why the response above carries
one you never chose.

### Give it back

```bash theme={null}
curl -sS -X DELETE "$BASE/v1/devices/$DEVICE/lease" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"leaseId":"'"$LEASE"'"}'
```

The lease id goes in the **body of a DELETE**, which is unusual and deliberate:
the id is a credential, and a query string is written to access logs, browser
history and `Referer` headers. Releasing a device you do not hold does nothing,
so a retry is safe.

Working longer than ten minutes? `PATCH /v1/devices/{deviceId}/lease` with the
same `leaseId` slides the expiry to ten minutes from now. It does not add ten
minutes to the old one, and the `leaseId` does not change. Renew **before** it
expires: an expired lease cannot be renewed, because by then the device belongs
to whoever takes it next.

## Step 4: Return the device

Back on **`/devices/:id`**, press **`Return device`**.

That is `POST /v1/fulfilments/{id}/return`, and it does three things at once: the
device goes back to the pool, your ownership of it ends, and billing for it
stops. It is **admin only**, like buying.

```json theme={null}
{
  "id": "...",
  "state": "cancelled",
  "alreadyDone": false,
  "deviceId": "...",
  "leaseCut": false
}
```

**You get back the part you did not use, not the whole payment.** You had the
device and you used it. The amount is computed by the payment provider from its
own prices; this service states no amount here or anywhere, because an amount
computed on this side would be a second opinion about a price it deliberately
cannot read. The measured run ended with the invoice refunded and the
subscription cancelled at the provider.

Three fields worth knowing:

* `alreadyDone: true` means the order had already ended before this call, so
  nothing was asked of the payment provider and no second refund was issued.
  Pressing the button twice answers `200` either way and is not an error.
* `deviceId` names the device that went back. It is **non null on a return**,
  where a device really changed hands, and null on a cancellation, where there
  was never a device to give back.
* `leaseCut` says whether a session you were holding right then was cut. Whether
  it is depends on the deployment, not on this call. Either way the device stops
  being yours immediately.

An order still **awaiting provisioning** cannot be returned, because there is
nothing to give back. That is `409`, and `POST /v1/fulfilments/{id}/cancel` is
the route for it. Cancelling an order that never arrived returns all of the
money.

### The order afterwards is a different object

Read the order back from `GET /v1/fulfilments` a few seconds later and it does
not look like the response you just got. Both readings are correct, and
comparing them field by field is a good way to confuse yourself:

```json theme={null}
{
  "state": "cancelled",
  "deviceId": null,
  "revocationReason": "customer_returned"
}
```

`deviceId` is `null` here and non null in the return's own response. That is not
a contradiction. The return response says **which device went back**. The
listing says **which device this order currently holds**, which is none, because
it went back.

**`state` reads `cancelled` after a return, not `returned`.** There is no
separate returned state. The direction is carried by `revocationReason`, which
is `customer_returned` when you gave a device back and `customer_cancelled` when
you cancelled an order that had not arrived.

<Warning>
  A `null` `revocationReason` on a cancelled row means **not recorded**, never a
  reason of its own. Do not render it as one.
</Warning>

At the same moment the sku goes back to `shipsNow: true` on `GET /v1/skus`. That
is the shelf side of the same event: the device you returned is the one the next
buyer gets.

## What you were charged

* **The device**, monthly, at the published rate, less the unused remainder you
  got back on return.
* **Active time is not a separate line on a monthly device.** A monthly device
  has no meter: run it all month and the invoice reads the same. A per minute
  mode exists in the billing model, but **no per minute pair is on the price
  list today**, so a subscription is what you can actually buy. See
  [Billing modes](/billing/modes).
* **Agent steps are metered but not separately priced.** Steps are included in
  the device price today, and no separate step rate is published.

There is **no spend cap**. No dollar limit and no minute limit: nothing
suspends a lease or refuses the next one. There is an optional alert that
emails you when the next invoice passes a level, and it only tells you. The
limits that actually stop something are the lease expiry and a run's own step
budget. See [Spending controls](/billing/spending-controls).

## Where to go next

<Columns cols={2}>
  <Card title="Webhooks" href="/guides/webhooks" icon="bell">
    Be told when a run ends instead of polling for it.
  </Card>

  <Card title="Agent tier" href="/agent/overview" icon="robot">
    Hand a goal to a worker instead of driving each step yourself.
  </Card>

  <Card title="The console" href="/getting-started/console" icon="window">
    Every screen, and the four surfaces that have no screen.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.