---
title: "Billing"
description: "Inspect your prepaid balance, top up by card, configure auto-reload thresholds and amounts, and list every transaction behind your organization's usage."
---

ThunderPhone is prepaid. Each organization carries a USD balance that
decrements as calls are placed; if the balance reaches `$0.00`, inbound
calls are rejected and outbound calls return `402 Payment Required`.
These endpoints let you query balance state, top up via Stripe, and
configure automatic reloads.

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/billing` | Get billing account state |
| `PATCH` | `/v1/billing` | Update auto-reload settings |
| `POST` | `/v1/billing/top-up` | Create a top-up payment intent (one-time charge) |
| `POST` | `/v1/billing/setup-intent` | Create a Stripe SetupIntent for saving a card |
| `POST` | `/v1/billing/default-payment-method` | Set the default payment method |
| `GET` | `/v1/billing/payment-methods` | List saved cards |
| `DELETE` | `/v1/billing/payment-methods/{payment_method_id}` | Remove a saved card |
| `GET` | `/v1/billing/transactions` | List balance transactions |
| `GET` | `/v1/billing/transactions/{transaction_id}/receipt` | Get the receipt link for a top-up |

<Warning>
  The `GET` endpoints work with an `sk_live_` API key. Everything that
  moves money or changes how it moves (auto-reload and the monthly limit,
  top-ups, saving, removing, or choosing the default card) returns `403` to
  an API key and needs a signed-in admin session (`Authorization: Token
  <session-token>` plus `X-ThunderPhone-Org`), so a leaked key can't top up
  from your card or raise your limits. Most teams do this in the dashboard
  under **Organization → Billing Settings**. See
  [What API keys can't do](/api-reference/introduction#what-api-keys-cant-do).
</Warning>

## Billing account object

```json
{
  "balance_cents": 125000,
  "balance_usd": "1250.00",
  "currency": "usd",
  "auto_reload_enabled": true,
  "auto_reload_minimum_cents": 1000,
  "auto_reload_amount_cents": 5000,
  "auto_reload_monthly_limit_cents": null,
  "auto_reload_status": "ok",
  "auto_reload_next_retry_at": null,
  "auto_reload_failed_amount_cents": null,
  "auto_reload_failed_at": null,
  "auto_reload_consecutive_declines": null,
  "last_auto_reload": {
    "amount_cents": 5000,
    "created_at": "2026-09-20T14:02:11.482Z"
  },
  "auto_reload_month_spend_cents": 5000,
  "average_daily_spend_cents_7d": 390,
  "balance_runway_days": 320,
  "last_top_up": {
    "amount_cents": 5000,
    "created_at": "2026-09-20T14:02:11.482Z",
    "kind": "auto"
  },
  "has_payment_method": true,
  "has_ever_topped_up": true
}
```

| Field | Type | Description |
|-------|------|-------------|
| `balance_cents` | integer | Current balance in USD cents |
| `balance_usd` | string | Formatted, e.g. `"1250.00"` |
| `currency` | string | Always `usd` |
| `auto_reload_enabled` | boolean | Whether automatic top-ups are on. Turned off automatically when a top-up is refunded or disputed; set it back to `true` to resume |
| `auto_reload_minimum_cents` | integer | Balance threshold that triggers a reload |
| `auto_reload_amount_cents` | integer | Amount to charge on reload |
| `auto_reload_monthly_limit_cents` | integer \| null | Cap on auto-reload spend per calendar month. `null` = no cap. Sending `0` is treated as `null` |
| `auto_reload_status` | string | Read-only. `off` (auto-reload disabled), `ok`, `retrying` (the default card was declined, or Stripe could not charge it at all, in which case `auto_reload_failure_code` is `payment_method_unusable`; the next attempt waits out a 1h / 6h / 24h backoff), `stopped` (the bank refused the default card outright, or the card was declined 5 times in a row, in which case `auto_reload_failure_code` is `repeated_declines`; it is not retried until you set another default card, the card's details are updated, or a manual top-up succeeds), or `capped` (this month's auto-reloads plus `auto_reload_amount_cents` would exceed `auto_reload_monthly_limit_cents`, so no auto-reload runs until the next calendar month in UTC or a higher limit) |
| `auto_reload_next_retry_at` | string \| null | Read-only. ISO 8601 end of the current backoff while `retrying`. Can be in the past: the retry runs on the next call after it, including a call attempt that is rejected because the balance is below zero |
| `auto_reload_failed_amount_cents` | integer \| null | Read-only. Amount the declined auto-reload tried to charge, while `retrying` or `stopped` |
| `auto_reload_failed_at` | string \| null | Read-only. ISO 8601 time of the latest decline, while `retrying` or `stopped` |
| `auto_reload_consecutive_declines` | integer \| null | Read-only. Failed auto-reloads in a row on the current default card, while `retrying` or `stopped`; `null` otherwise |
| `last_auto_reload` | object \| null | Read-only. `amount_cents` and `created_at` of the newest successful auto-reload, or `null` before the first one |
| `auto_reload_month_spend_cents` | integer | Read-only. Auto-reload credits this calendar month (UTC), as `auto_reload_monthly_limit_cents` counts them |
| `average_daily_spend_cents_7d` | integer \| null | Read-only. Usage charges (calls, phone-number rent, conversion reports, voice samples) over the last 7 days, averaged per day of history (7 days, or the account's age if younger, at least 1 day), rounded. Top-ups, auto-reloads and adjustments are excluded. `null` when there was no usage in that window |
| `balance_runway_days` | integer \| null | Read-only. Whole days `balance_cents` lasts at the 7-day average (`0` means less than a day). `null` when the balance is zero or negative or there was no recent usage |
| `last_top_up` | object \| null | Read-only. `amount_cents`, `created_at` and `kind` (`manual` or `auto`) of the newest successful top-up, or `null` before the first one |
| `has_payment_method` | boolean | Derived — true iff a default Stripe payment method is set |
| `has_ever_topped_up` | boolean | Derived — true once the account has at least one successful manual top-up or auto-reload |

---

## Get billing account

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/billing \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `200 OK` with a [Billing account object](#billing-account-object).

---

## Update auto-reload

Enable, disable, or reconfigure automatic balance reloads. A
payment method must be on file (`has_payment_method: true`) before
`auto_reload_enabled` can be set to `true` — otherwise the server
returns `422 Unprocessable Entity` with
`{"detail": "Auto-reload requires a default payment method. Create a setup intent first."}`.

<CodeGroup>
```bash Enable auto-reload
curl -X PATCH https://api.thunderphone.com/v1/billing \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{
    "auto_reload_enabled": true,
    "auto_reload_minimum_cents": 1000,
    "auto_reload_amount_cents": 5000,
    "auto_reload_monthly_limit_cents": 50000
  }'
```

```bash Disable auto-reload
curl -X PATCH https://api.thunderphone.com/v1/billing \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"auto_reload_enabled": false}'
```
</CodeGroup>

| Field | Type | Required when enabling |
|-------|------|------------------------|
| `auto_reload_enabled` | boolean | |
| `auto_reload_minimum_cents` | integer ≥ 0 | yes — triggers a reload once balance drops below |
| `auto_reload_amount_cents` | integer > 0 | yes — amount charged on reload |
| `auto_reload_monthly_limit_cents` | integer ≥ 0, or `null` | no — `null` means unlimited |

Returns `200 OK` with the updated [Billing account object](#billing-account-object).

---

## Top up balance

Create a one-time charge to increase your balance. Uses the default
payment method unless `payment_method_id` is supplied.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/billing/top-up \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"amount_cents": 10000}'
```

```python Python
result = requests.post(
    "https://api.thunderphone.com/v1/billing/top-up",
    headers={"Authorization": "Token YOUR_SESSION_TOKEN", "X-ThunderPhone-Org": "42"},
    json={"amount_cents": 10000},
).json()
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount_cents` | integer ≥ 1 | yes | USD cents. No server-enforced maximum; the dashboard suggests common amounts |
| `payment_method_id` | string | no | Stripe PM id. Defaults to the org's default |

Returns `201 Created`:

```json
{
  "payment_intent_id": "pi_abc123",
  "status":            "succeeded",
  "client_secret":     "pi_abc123_secret_..."
}
```

| Field | Type | Description |
|-------|------|-------------|
| `payment_intent_id` | string | Stripe PaymentIntent id |
| `status` | string | Mirrors Stripe's own `PaymentIntent.status` — typically `succeeded`, `requires_action`, or `requires_payment_method` |
| `client_secret` | string | Pass to Stripe.js / stripe-react on the client side if `status === "requires_action"` (3DS) |

When `status === "succeeded"` the balance is credited synchronously
from the request. When `status === "requires_action"`, complete the
3DS flow client-side and wait for the `payment_intent.succeeded`
webhook — then poll [`GET /v1/billing`](#get-billing-account) to
observe the credit.

### Errors

Validation problems (e.g. a missing or non-positive `amount_cents`)
return `400` with a standard DRF field-error body:

```json
{ "amount_cents": ["Ensure this value is greater than or equal to 1."] }
```

Payment failures return a structured error body with a
machine-readable `code`, plus `message`/`detail`, a `retryable` hint,
and a `next_action` your client can act on:

```json 402 example
{
  "code": "payment_authentication_required",
  "message": "This card requires additional verification. Please retry and complete authentication when prompted.",
  "detail": "This card requires additional verification. Please retry and complete authentication when prompted.",
  "retryable": true,
  "next_action": "authenticate_payment"
}
```

| Status | `code` | `retryable` | `next_action` | Condition |
|--------|--------|-------------|---------------|-----------|
| `402` | `payment_authentication_required` | `true` | `authenticate_payment` | 3-D Secure / SCA required — complete authentication client-side with the PaymentIntent's `client_secret`, then retry |
| `402` | `card_declined` | `false` | `update_payment_method` | The bank declined the card (all decline reasons are collapsed into this one code) |
| `422` | `billing_request_invalid` | `false` | `update_payment_method` | Stripe rejected the request (bad parameters, unknown customer) |
| `503` | `billing_provider_unavailable` | `true` | `retry_payment` | Transient Stripe outage or rate limit; retry shortly |
| `502` | `billing_provider_error` | `true` | `retry_payment` | Any other unexpected Stripe error |

These same coded errors are returned by the other billing write
endpoints when they hit Stripe (`/setup-intent`,
`/default-payment-method`).

---

## Add / save a payment method

Two-step flow: create a SetupIntent, confirm it client-side, then set
the resulting payment-method id as the org's default.

### 1. Create a SetupIntent

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/billing/setup-intent \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42"
```
</CodeGroup>

```json Response
{
  "setup_intent_id": "seti_abc123",
  "client_secret": "seti_abc123_secret_...",
  "status": "requires_payment_method"
}
```

### 2. Confirm client-side with Stripe.js

See the [Stripe documentation](https://docs.stripe.com/js/setup_intents/confirm_card_setup)
for `stripe.confirmCardSetup()`. On success you receive a
`payment_method` id.

### 3. Mark the payment method as default

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/billing/default-payment-method \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"payment_method_id": "pm_abc123"}'
```
</CodeGroup>

Returns `200 OK` with the updated [Billing account object](#billing-account-object);
`has_payment_method` is now `true`.

To switch the default to a card that is already saved, call this endpoint
with that card's id from [List saved cards](#list-saved-cards).

---

## List saved cards

Returns every card saved on the organization, the default first and the
rest newest first. Top-ups and auto-reloads charge the default card.
Requires the admin or owner role.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/billing/payment-methods \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{
  "payment_methods": [
    {
      "id": "pm_abc123",
      "brand": "visa",
      "last4": "4242",
      "exp_month": 4,
      "exp_year": 2031,
      "is_default": true
    },
    {
      "id": "pm_def456",
      "brand": "mastercard",
      "last4": "4444",
      "exp_month": 12,
      "exp_year": 2030,
      "is_default": false
    }
  ]
}
```

An organization that has never saved a card gets an empty list.

---

## Remove a saved card

Detaches the card from the organization. This cannot be undone; save the
card again to use it later. Requires the admin or owner role.

<CodeGroup>
```bash cURL
curl -X DELETE https://api.thunderphone.com/v1/billing/payment-methods/pm_def456 \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42"
```
</CodeGroup>

Returns `204 No Content`.

| Status | `code` | Meaning |
|--------|--------|---------|
| `404` | — | No saved card with that id on this organization |
| `409` | `default_payment_method` | The card is the default. Set another card as default first |
| `409` | `payment_method_in_use` | An auto-reload is charging this card right now. Retry shortly |

---

## List transactions

Returns every ledger entry on the account — top-ups, per-call usage,
monthly number fees, and manual adjustments — newest first.

<CodeGroup>
```bash cURL
curl 'https://api.thunderphone.com/v1/billing/transactions?limit=200' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
txns = requests.get(
    "https://api.thunderphone.com/v1/billing/transactions",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    params={"limit": 200},
).json()
```
</CodeGroup>

### Query parameters

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | integer | 200 | 1–1000 |
| `offset` | integer | 0 | |
| `kind` | string | | Filter to one [transaction kind](#transaction-object) |
| `start_date` | ISO 8601 | | Filter on `created_at` |
| `end_date` | ISO 8601 | | |

The response is a **plain JSON array** of transaction objects (no
`{results, total}` envelope). Page forward by increasing `offset`
until you receive fewer rows than `limit`.

### Transaction object

{/* rate:billing-example */}

```json
{
  "id": 3421,
  "kind": "call_usage",
  "description": "Call billing: 2 minutes × $0.02",
  "amount_cents": -4,
  "amount_usd": "-0.04",
  "call_id": 987654321,
  "phone_number_id": 201,
  "phone_number_value": "+15551234567",
  "metadata": {},
  "has_receipt": false,
  "created_at": "2026-04-20T18:25:06.201Z"
}
```

{/* /rate */}

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Transaction id |
| `kind` | string | One of `manual_topup`, `auto_reload`, `call_usage`, `phone_number_monthly`, `conversion_report`, `adjustment` |
| `description` | string | Human-readable summary |
| `amount_cents` | integer | Negative for usage/fees, positive for credits |
| `amount_usd` | string | Formatted signed amount |
| `call_id` | integer \| null | Set on `call_usage` rows |
| `phone_number_id` | integer \| null | Set on `phone_number_monthly` rows |
| `phone_number_value` | string \| null | E.164 for the number billed |
| `metadata` | object | Kind-specific details |
| `has_receipt` | boolean | `true` on card-funded `manual_topup` and `auto_reload` credits. [Get a receipt](#get-a-receipt) for these rows |
| `created_at` | timestamp | |

---

## Get a receipt

Returns a link to the payment receipt for a top-up or automatic top-up,
for rows where `has_receipt` is `true`. The receipt is hosted by our
payment processor, Stripe, and shows the amount, date, and card charged.
Owners and admins can fetch it; other members get `403`, because the receipt shows the card brand and last four digits.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/billing/transactions/3398/receipt \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python Python
receipt = requests.get(
    "https://api.thunderphone.com/v1/billing/transactions/3398/receipt",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
).json()
print(receipt["receipt_url"])
```
</CodeGroup>

```json Response
{
  "receipt_url": "https://pay.stripe.com/receipts/payment/..."
}
```

| Status | `code` | Meaning |
|--------|--------|---------|
| `404` | — | No transaction with that id on this organization, or the transaction has no receipt |
| `404` | `receipt_unavailable` | The payment has no receipt yet. Try again later |

---

## Stripe webhook

ThunderPhone receives Stripe webhooks at
`/v1/stripe/webhook` internally — you don't interact with this endpoint
directly. It processes `payment_intent.succeeded`,
`payment_intent.payment_failed`, and `setup_intent.succeeded` events to
update balances and mark payment methods ready.

---

## Related

<CardGroup cols={2}>
  <Card title="Outbound Calls" icon="arrow-up-right" href="/api-reference/outbound-calls">
    Outbound calls require positive balance.
  </Card>
  <Card title="Pricing" icon="credit-card" href="https://thunderphone.com/pricing">
    Per-minute pricing by product tier.
  </Card>
</CardGroup>
