ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Billing

Billing

Inspect your prepaid balance, top up by card, configure auto-reload thresholds and amounts, and list every transaction behind your organization

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

MethodPathDescription
GET/v1/billingGet billing account state
PATCH/v1/billingUpdate auto-reload settings
POST/v1/billing/top-upCreate a top-up payment intent (one-time charge)
POST/v1/billing/setup-intentCreate a Stripe SetupIntent for saving a card
POST/v1/billing/default-payment-methodSet the default payment method
GET/v1/billing/payment-methodsList saved cards
DELETE/v1/billing/payment-methods/{payment_method_id}Remove a saved card
GET/v1/billing/transactionsList balance transactions
GET/v1/billing/transactions/{transaction_id}/receiptGet the receipt link for a top-up

Billing account object

{
  "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
}
FieldTypeDescription
balance_centsintegerCurrent balance in USD cents
balance_usdstringFormatted, e.g. "1250.00"
currencystringAlways usd
auto_reload_enabledbooleanWhether 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_centsintegerBalance threshold that triggers a reload
auto_reload_amount_centsintegerAmount to charge on reload
auto_reload_monthly_limit_centsinteger | nullCap on auto-reload spend per calendar month. null = no cap. Sending 0 is treated as null
auto_reload_statusstringRead-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_atstring | nullRead-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_centsinteger | nullRead-only. Amount the declined auto-reload tried to charge, while retrying or stopped
auto_reload_failed_atstring | nullRead-only. ISO 8601 time of the latest decline, while retrying or stopped
auto_reload_consecutive_declinesinteger | nullRead-only. Failed auto-reloads in a row on the current default card, while retrying or stopped; null otherwise
last_auto_reloadobject | nullRead-only. amount_cents and created_at of the newest successful auto-reload, or null before the first one
auto_reload_month_spend_centsintegerRead-only. Auto-reload credits this calendar month (UTC), as auto_reload_monthly_limit_cents counts them
average_daily_spend_cents_7dinteger | nullRead-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_daysinteger | nullRead-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_upobject | nullRead-only. amount_cents, created_at and kind (manual or auto) of the newest successful top-up, or null before the first one
has_payment_methodbooleanDerived — true iff a default Stripe payment method is set
has_ever_topped_upbooleanDerived — true once the account has at least one successful manual top-up or auto-reload

Get billing account

cURL
curl https://api.thunderphone.com/v1/billing \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"

Returns 200 OK with a 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."}.

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
  }'
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}'
FieldTypeRequired when enabling
auto_reload_enabledboolean
auto_reload_minimum_centsinteger ≥ 0yes — triggers a reload once balance drops below
auto_reload_amount_centsinteger > 0yes — amount charged on reload
auto_reload_monthly_limit_centsinteger ≥ 0, or nullno — null means unlimited

Returns 200 OK with the updated 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.

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
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()
FieldTypeRequiredDescription
amount_centsinteger ≥ 1yesUSD cents. No server-enforced maximum; the dashboard suggests common amounts
payment_method_idstringnoStripe PM id. Defaults to the org's default

Returns 201 Created:

{
  "payment_intent_id": "pi_abc123",
  "status":            "succeeded",
  "client_secret":     "pi_abc123_secret_..."
}
FieldTypeDescription
payment_intent_idstringStripe PaymentIntent id
statusstringMirrors Stripe's own PaymentIntent.status — typically succeeded, requires_action, or requires_payment_method
client_secretstringPass 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 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:

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

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"
}
Statuscoderetryablenext_actionCondition
402payment_authentication_requiredtrueauthenticate_payment3-D Secure / SCA required — complete authentication client-side with the PaymentIntent's client_secret, then retry
402card_declinedfalseupdate_payment_methodThe bank declined the card (all decline reasons are collapsed into this one code)
422billing_request_invalidfalseupdate_payment_methodStripe rejected the request (bad parameters, unknown customer)
503billing_provider_unavailabletrueretry_paymentTransient Stripe outage or rate limit; retry shortly
502billing_provider_errortrueretry_paymentAny 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

cURL
curl -X POST https://api.thunderphone.com/v1/billing/setup-intent \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42"
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 for stripe.confirmCardSetup(). On success you receive a payment_method id.

3. Mark the payment method as default

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"}'

Returns 200 OK with the updated 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

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.

cURL
curl https://api.thunderphone.com/v1/billing/payment-methods \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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.

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"

Returns 204 No Content.

StatuscodeMeaning
404—No saved card with that id on this organization
409default_payment_methodThe card is the default. Set another card as default first
409payment_method_in_useAn 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.

cURL
curl 'https://api.thunderphone.com/v1/billing/transactions?limit=200' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
txns = requests.get(
    "https://api.thunderphone.com/v1/billing/transactions",
    headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
    params={"limit": 200},
).json()

Query parameters

ParamTypeDefaultDescription
limitinteger2001–1000
offsetinteger0
kindstringFilter to one transaction kind
start_dateISO 8601Filter on created_at
end_dateISO 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

{
  "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"
}
FieldTypeDescription
idintegerTransaction id
kindstringOne of manual_topup, auto_reload, call_usage, phone_number_monthly, conversion_report, adjustment
descriptionstringHuman-readable summary
amount_centsintegerNegative for usage/fees, positive for credits
amount_usdstringFormatted signed amount
call_idinteger | nullSet on call_usage rows
phone_number_idinteger | nullSet on phone_number_monthly rows
phone_number_valuestring | nullE.164 for the number billed
metadataobjectKind-specific details
has_receiptbooleantrue on card-funded manual_topup and auto_reload credits. Get a receipt for these rows
created_attimestamp

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.

cURL
curl https://api.thunderphone.com/v1/billing/transactions/3398/receipt \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
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"])
Response
{
  "receipt_url": "https://pay.stripe.com/receipts/payment/..."
}
StatuscodeMeaning
404—No transaction with that id on this organization, or the transaction has no receipt
404receipt_unavailableThe 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.