Billing API — Wallet, Top-up & App Budgets

Wallet balance, Stripe top-ups, and per-app monthly spend caps. These are dashboard surfaces: apps are untrusted and must not read a user's cross-app financial state or raise their own cap. Endpoints marked dashboard-only reject any session whose OAuth client isn't the tallpond dashboard (sessions with no app identity — the userinfo fallback — are allowed, since budgets aren't enforced there anyway).

All /v1/* endpoints require authentication.

Wallet#

GET /v1/wallet — balance & recent activity (dashboard-only)#

json
{
  "balance_micro_usd": 4985000,
  "pending_micro_usd": 15000,
  "status": "active",
  "delinquent_since": null,
  "transactions": [
    {
      "id": "",
      "amountMicroUsd": -15000,
      "opType": "db_usage",
      "settledAt": "",
      "metadata": {}
    }
  ]
}

Amounts are in microdollars (1 USD = 1,000,000). pending_micro_usd is the sum of open escrow holds. status is active, grace (negative balance — everything still works), or frozen (data access blocked); delinquent_since is when the negative balance started, null while active. See the delinquency lifecycle in metering.md.

GET /v1/dashboard/spend?range=1d — grouped spend (dashboard-only)#

Returns every debit in the selected window aggregated by app and operation type; it never paginates individual ledger rows. range is one of 1h, 1d (default), 1w, or 1m (30 days).

json
{
  "range": "1d",
  "since": "2026-08-24T12:00:00.000Z",
  "until": "2026-08-25T12:00:00.000Z",
  "total_micro_usd": 1532,
  "apps": [
    {
      "app_id": "",
      "name": "Shared Notes",
      "micro_usd": 1532,
      "operations": 418,
      "categories": [
        { "op_type": "db_usage", "micro_usd": 1210, "operations": 400 },
        { "op_type": "db_at_rest", "micro_usd": 322, "operations": 18 }
      ]
    }
  ]
}

Amounts are positive costs in microdollars. Credits and top-ups are excluded. The immutable transaction ledger remains the source of truth; the endpoint performs an indexed bounded aggregation, avoiding a second cumulative balance that could drift.

POST /v1/wallet/topup — start a Stripe Checkout (dashboard-only)#

Dashboard-only routes require the authenticated token's OAuth client to match DASHBOARD_CLIENT_ID; sessions without client identity are rejected.

Body { "amount_usd"?: 25 } (min 10, max 10,000; defaults to 10) → { "url": "https://checkout.stripe.com/…" }. Redirect the user there; the wallet is credited when Stripe confirms payment (see webhook). Errors: 400 invalid_amount, 503 topups_disabled (Stripe misconfigured — not the normal production state).

A frozen account's discretionary ops (DB reads/writes, storage egress, file access, AI/upload holds) return 402 account_frozen instead of running; a top-up that restores a non-negative balance clears the freeze immediately.

POST /stripe/webhook — payment confirmation (Stripe-signed, no user auth)#

Verifies the stripe-signature against STRIPE_WEBHOOK_SECRET and, on checkout.session.completed with payment_status: "paid", credits the wallet from the session metadata. Idempotent per Stripe session. → { "received": true }.

App budgets#

A per-app monthly spend cap is the user's primary protection against a malicious or buggy app. Every metered op checks it; hitting the cap fails 402 spend_cap_exceeded.

GET /v1/apps — my app budgets (dashboard-only)#

json
{
  "apps": [
    {
      "app_id": "",
      "name": "Pad",
      "url": "https://pad.tallpond.app",
      "icon_url": "https://pad.tallpond.app/pad-icon.svg",
      "cap_micro_usd": 20000000,
      "spent_micro_usd": 1250000,
      "pending_micro_usd": 0,
      "window_start": "2026-07-01T00:00:00.000Z"
    }
  ]
}

Spend is normalized to the current UTC month (window_start). icon_url is resolved from the active release's declared HTML icon or web app manifest; clients should use it directly rather than probing conventional favicon paths. It is null when the release does not declare an icon.

PUT /v1/apps/:appId/cap — set an app's cap (dashboard-only)#

Body { "cap_usd": 20 } (0–10,000; 0 blocks the app entirely) → { "app_id": "…", "cap_micro_usd": 20000000 }. Errors: 400 invalid_cap.

Planned: spend-threshold notifications#

There is no global (cross-app) monthly cap by design — the per-app cap is the intended control surface, and a user can already zero out any app's cap to stop its spend. Threshold notifications (e.g. an email at 50/80/100% of an app's cap) are planned but not yet implemented — see not-yet-implemented.md.

New-user credit#

A user's first consent — including the silent skip path when Hydra remembers a prior grant — credits a welcome balance ($5.00 default, tunable via WELCOME_CREDIT_USD; see authentication.md). "New" means has never received a welcome credit — deliberately not "has no ledger row", since sub-threshold usage metering can create the ledger row (slightly negative) before the credit fires.

SDK#

ts
const wallet = await tallpond.wallet.get();
const { url } = await tallpond.wallet.topup(25); // → redirect to Stripe

const { apps } = await tallpond.apps.list();
await tallpond.apps.setCap("app_client_id", 20); // USD/month; 0 blocks

The SDK surfaces 402 as an TallpondError with message insufficient_balance or spend_cap_exceeded — apps should catch these and point the user at their dashboard.