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 osgarden 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)#
{
"balance_micro_usd": 4985000,
"pending_micro_usd": 15000,
"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.
POST /v1/wallet/topup — start a Stripe Checkout (dashboard-only)#
Body { "amount_usd"?: 25 } (min 5, max 10,000; defaults to 5) →
{ "url": "https://checkout.stripe.com/…" }. Redirect the user there; the wallet is
credited when Stripe confirms payment (see webhook). Errors: 400 invalid_amount.
Preview mode (current production state): Stripe is deliberately not configured.
The endpoint responds 503 topups_disabled with a thank-you detail (which the
dashboard shows as an info notice), and records the attempt — user and amount — in
the topup_interest table. That table is the demand signal that decides whether
payments are worth enabling; usage meanwhile runs on the welcome credit.
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)#
{
"apps": [
{
"app_id": "…",
"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).
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#
const wallet = await osgarden.wallet.get();
const { url } = await osgarden.wallet.topup(25); // → redirect to Stripe
const { apps } = await osgarden.apps.list();
await osgarden.apps.setCap("app_client_id", 20); // USD/month; 0 blocksThe SDK surfaces 402 as an OsgardenError with message insufficient_balance or
spend_cap_exceeded — apps should catch these and point the user at their dashboard.