# Billing over WebSocket

Renting phones costs money. These four funs read your rental and hand back **links** for a
person to open: a Stripe Checkout page, or an approval page. Both are inert until someone acts
on them.

All four need the `billing` ability. See [Frames](/websocket/frames) for the request and reply
envelope, and [Events](/websocket/events) for the `billing_request` push.

| Fun | Data fields | Reply |
|---|---|---|
| `/app/billing/summary` | *(none)* | `200` with the rental summary |
| `/app/billing/rentals` | `phones` + `country`, or `items`, plus `accept_terms` | `201` with a checkout link |
| `/app/billing/requests` | `phones` or `add`, plus `accept_terms` | `201` applied, or `202` pending approval |
| `/app/billing/requests/get` | `request_id` | `200` with the request's status |

## accept_terms is required

Both funs that can spend money require `accept_terms: true`. Before you send it, show the
person [terms](https://0bull.net/terms) and [privacy](https://0bull.net/privacy) and get their
agreement, including to a monthly renewal charge for each assigned phone.

Leaving it out, or sending `false`, is refused with `422` and
`Show the user https://0bull.net/terms and https://0bull.net/privacy, get their agreement, and
pass accept_terms: true.`

## Read the rental

`/app/billing/summary` takes no fields.

```json
→ { "fun": "/app/billing/summary", "msgid": "1", "data": {} }
← { "fun": "/app/billing/summary", "msgid": "1", "status": 200,
    "data": { "subscribed": true, "phones": 3, "price_per_phone": 49.0, "monthly": 147.0,
              "on_grace_period": false,
              "pending_orders": [
                { "id": 88, "country": "ES", "phones": 1, "availability": "24h",
                  "placed_at": "2026-02-10T17:41:03+00:00" }
              ],
              "lines": [
                { "id": 511, "status": "active", "cancelling": false,
                  "renews_at": "2026-03-02T00:00:00+00:00" },
                { "id": 512, "status": "active", "cancelling": false,
                  "renews_at": "2026-03-14T00:00:00+00:00" }
              ] } }
```

| Field | Meaning |
|---|---|
| `subscribed` | Whether any rental is active. |
| `phones` | How many phones the rental covers. |
| `price_per_phone` | List price for one phone a month. |
| `monthly` | Total a month. Present once there is a rental. |
| `on_grace_period` | Whether any phone is cancelling but still running. Present once there is a rental. |
| `pending_orders` | Orders waiting for an admin to assign phones. Nothing has been charged for these. |
| `lines` | One entry per phone. Present once there is a rental. |
| `hint` | A short next step. Present only while there is no rental. |

**Every assigned phone runs its own monthly cycle**, from its own assignment date. That is why
`lines` carries one entry per phone, each with its own `status` and `renews_at`. Adding a phone
never moves an existing renewal date, and nothing is prorated.

Start here before proposing any change.

## Start a rental

`/app/billing/rentals` opens a Stripe Checkout page for a first subscription.

| Field | Type | | Description |
|---|---|---|---|
| `phones` | integer | *one of* | Phones for one country, 1 to 50. Give this or `items`. |
| `country` | string | optional | ISO 3166-1 alpha-2 country for `phones`, such as `ES`, `US` or `JP`. Defaults to `ES`. |
| `items` | array | *one of* | Rows of `{country, quantity}` for a multi-country order. 1 to 10 rows, at most 50 phones in total, each country once. |
| `accept_terms` | boolean | **required** | Must be `true`. |

The reply is `201` with `checkout_url`, the total `phones`, the normalized `items`, and a
short `next` describing what the link does.

**What the checkout link does.** Opening it saves the card and holds one full month per phone
on it. The order is placed once checkout finishes. The held amount is charged when an admin
assigns each phone, and each assigned phone then runs its own monthly cycle from that date.
Nothing is charged at order time, and a card that cannot cover the hold places no order.

Refusals:

- `You already have an active rental subscription. Change the phone count instead.` (`409`)
- `That is more than 50 phones in total. Split it into separate orders.` (`409`)
- `Each country can only appear once. Put the whole amount for a country on one line.` (`409`)
- `Could not start checkout. Please try again.` (`502`) when Stripe cannot be reached.

## Change the phone count

`/app/billing/requests` changes how many phones the subscription covers.

| Field | Type | | Description |
|---|---|---|---|
| `phones` | integer | *one of* | The **total** the subscription should cover afterwards, 1 to 50. |
| `add` | integer | *one of* | Signed change to the current count, from -49 to 49. `add: 1` adds one, `add: -2` removes two. |
| `accept_terms` | boolean | **required** | Must be `true`. |

Give one of `phones` or `add`, not both.

Read `applied` in the reply to know what happened:

| `applied` | Status | What happened | What you get |
|---|---|---|---|
| `false` | `202` | Filed, waiting on the account owner. | `request_id`, `approval_url`, `expires_at` and an estimated charge. Nothing has been charged. |
| `true` | `201` | Placed under a standing allowance the owner set themselves. | `request_id`, `from_phones`, `to_phones`. The card is charged when each phone is assigned. |

A standing allowance only ever covers an **increase** that lands at or below it. An increase
past it, and every decrease, still asks. When a bank needs to confirm the card, the reply comes
back pending instead, with an approval link that leads to the confirmation.

A request expires in an hour and can be answered once.

Adding phones costs full list price per phone, held now and charged on assignment. Removing
phones takes effect at each removed phone's own renewal, with no credit now.

Refusals:

- `No active rental subscription. Start a rental before changing the phone count.` (`409`)
- `The subscription already covers 3 phones.` (`409`) when the change is a no-op.
- `That would leave the subscription at 0 phones. It has to end up between 1 and 50.` (`409`)

## Check a filed request

`/app/billing/requests/get` reads what the owner decided.

| Field | Type | | Description |
|---|---|---|---|
| `request_id` | string | **required** | The `request_id` from `/app/billing/requests`. |

The reply carries `request_id`, `status`, `to_phones`, `approval_url`, `expires_at` and
`resolved_at`. `status` is one of `pending`, `approved`, `declined`, `failed` (the card was
refused, nothing was charged) or `expired`.

A request belonging to anyone else answers `404` with `Billing request not found.`

## Worked example: read the rental, then change it

<CodeTabs syncKey="lang">

```python title="Python"
from zerobull import ZeroBull

with ZeroBull() as client, client.socket() as socket:
    summary = socket.billing.summary()
    print(summary.subscribed, summary.phones, summary.price_per_phone)
    for line in summary.lines:
        print(line.id, line.status, line.renews_at)
    for order in summary.pending_orders:
        print(order.country, order.phones, order.availability)

    if not summary.subscribed:
        rental = socket.billing.start_rental(accept_terms=True, phones=2, country="ES")
        print(rental.checkout_url, rental.phones, rental.next)
    else:
        change = socket.billing.request_phone_count(accept_terms=True, add=1)
        if change.applied:
            print(change.from_phones, change.to_phones)
        else:
            print(change.approval_url, change.charge_on_assignment)
            state = socket.billing.get_request(change.request_id)
            print(state.status, state.expires_at, state.resolved_at)
```

```typescript title="TypeScript"
import { ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();
await using socket = client.socket();
await socket.connect();

const summary = await socket.billing.summary();
console.log(summary.subscribed, summary.phones, summary.price_per_phone);
for (const line of summary.lines ?? []) {
  console.log(line.id, line.status, line.renews_at);
}
for (const order of summary.pending_orders) {
  console.log(order.country, order.phones, order.availability);
}

if (!summary.subscribed) {
  const rental = await socket.billing.startRental({
    accept_terms: true,
    phones: 2,
    country: "ES",
  });
  console.log(rental.checkout_url, rental.phones, rental.next);
} else {
  const change = await socket.billing.requestPhoneCount({ accept_terms: true, add: 1 });
  if (change.applied) {
    console.log(change.from_phones, change.to_phones);
  } else {
    console.log(change.approval_url, change.charge_on_assignment);
    const state = await socket.billing.getRequest(change.request_id);
    console.log(state.status, state.expires_at, state.resolved_at);
  }
}
```

```json title="Raw frames"
→ { "fun": "/app/billing/rentals", "msgid": "1",
    "data": { "phones": 2, "country": "ES", "accept_terms": true } }
← { "fun": "/app/billing/rentals", "msgid": "1", "status": 201,
    "data": { "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
              "phones": 2,
              "items": [ { "country": "ES", "quantity": 2 } ],
              "next": "Open this link to save the card and hold one month per phone. The order is placed once checkout finishes, and the held amount is charged when an admin assigns the phones." } }

→ { "fun": "/app/billing/requests", "msgid": "2",
    "data": { "add": 1, "accept_terms": true } }
← { "fun": "/app/billing/requests", "msgid": "2", "status": 202,
    "data": { "applied": false, "request_id": "a7d2f0b5-1c93-4a6e-8f20-53b7c9e14d68",
              "approval_url": "https://0bull.net/billing/requests/a7d2f0b5-...",
              "from_phones": 3, "to_phones": 4,
              "charge_now": null, "charge_on_assignment": 49.0, "monthly_after": 196.0,
              "pricing_unavailable": false,
              "expires_at": "2026-02-11T10:20:00+00:00",
              "next": "Open the approval link to place the order and hold full price per phone on the card; the hold is charged when an admin assigns the phones, each phone then on its own monthly cycle." } }

→ { "fun": "/app/billing/requests/get", "msgid": "3",
    "data": { "request_id": "a7d2f0b5-..." } }
← { "fun": "/app/billing/requests/get", "msgid": "3", "status": 200,
    "data": { "request_id": "a7d2f0b5-...", "status": "approved", "to_phones": 4,
              "approval_url": "https://0bull.net/billing/requests/a7d2f0b5-...",
              "expires_at": "2026-02-11T10:20:00+00:00",
              "resolved_at": "2026-02-11T09:31:44+00:00" } }
```

</CodeTabs>
