# bitcoinrouter.ai HTTP API

The API behind the site, at `https://bitcoinrouter.ai/api`. It does
everything the site does, with no browser, and the docs' "For agents"
section walks through the flow. Request and response bodies are JSON,
except the CSV exports and the Lexe wallet's callback.

## Conventions

- **Session**: signup and login set an `HttpOnly`, `SameSite=Lax`, `Secure`
  cookie named `br_session`, and logout clears it. Both also return the
  token as `session`, which works as `Authorization: Bearer <session>` and
  takes precedence over the cookie. Endpoints marked *auth* return 401
  without a session, or with an `Authorization` header that isn't a valid
  bearer.
- **Errors**: `{"error": "<message>"}` with a 4xx or 5xx status. A 409's
  `error` is a stable machine-readable code instead of prose, listed per
  endpoint. 429 means rate limited.
- **Money**: USD fields are JSON numbers in dollars (`1.25`), converted from
  micro-dollars. Sats are integers.
- **Time**: milliseconds since the UNIX epoch, as integers.

## Accounts

### `POST /api/signup`

```json
{ "username": "alice" | null, "password": "..." }
```

201 `{ "account_id": 42, "session": "..." }`, and sets the session cookie.
400 unless a given username, lowercased, is `[a-z0-9_]{3,32}` with at
least one letter, and the password has no leading or trailing whitespace,
is 10 to 128 bytes, and reaches zxcvbn score 3. 409 `username_taken`.

### `POST /api/signup/code`

No body. Creates an account with no username or password, whose only login
is a generated account code: 20 characters of lowercase base32 in groups of
four. 201
`{ "account_id": 42, "login_code": "k7mq-3xzp-wq2n-7hd4-vt6s", "session": "..." }`,
and sets the session cookie. A lost code can't be recovered, so the account
is lost with it. Shares the signup rate limit. 409 `logged_in` with a valid
session.

A code account is deleted once it is a week old if it never paid, meaning
it has no password, wallet, key or ledger entry (such as an admin credit),
no payment that ever settled, and none made in the past week.

### `POST /api/login`

```json
{ "identifier": "alice" | "42", "password": "..." }
```

`identifier` is a username or an account id, ignoring case and surrounding
whitespace. `password` is the account's password or, while it has none, its
account code, ignoring case, dashes and whitespace. 200 `{ "account_id": 42,
"session": "..." }`, and sets the cookie. 401 on any failure, without saying
which.

### `POST /api/logout` *auth*

204, ends the request's session and clears the cookie.

### `GET /api/me` *auth*

```json
{
  "account_id": 42,
  "username": "alice" | null,
  "login_code": null | "k7mq-3xzp-wq2n-7hd4-vt6s",
  "created_at": 1760000000000,
  "admin": false,
  "paused_at": null | 1760000000000,
  "balance_usd": -0.40,
  "credit_usd": 1.00,
  "max_credit_usd": 1.00,
  "credit_full_at_usd": 20.00,
  "lifetime_paid_usd": 25.00,
  "lifetime_spent_usd": 25.40,
  "headroom_usd": 0.60,
  "reserve_usd": 0.00,
  "target_usd": 4.00,
  "refill_target_usd": null | 10.0,
  "refundable_usd": 0.00,
  "funding": [ {                 // in draw order
    "id": 3,
    "kind": "lexe" | "nwc",
    "name": "Lexe",
    "status": "active" | "needs_attention" | "unresponsive",
    "detail": null | "Credential revoked",
    "expires_at": null | 1790000000000,
    "created_at": 1760000000000,
    "refundable_usd": 0.00,
    "receives": true
  } ],
  "wallet_debts": [ { "account_id": 7, "funding_id": 3, "owed_usd": 3.20 } ],
  "keys": [ KeyInfo ],
  "payments": [ PaymentInfo ],   // newest first, last 20
  "refunds": [ RefundInfo ],     // newest first, last 20
  "events": [ EventInfo ]        // newest first, last 30 days
}
```

`login_code` is the account code, or `null` once the account has a
password.

`admin` says whether the account may use the admin API.

`paused_at` is when the account was paused, or `null` while it isn't. While
paused, `credit_usd` and `reserve_usd` are zero. See "Pausing and refunds".

`target_usd` is the larger of `refill_target_usd` and the largest default
target across the providers of the account's keys (zero without keys), at
most $100. `credit_usd` is 5% of `lifetime_spent_usd`, everything ever
charged for usage, up to `max_credit_usd`. `max_credit_usd` is the largest
credit cap across the same providers, and `credit_full_at_usd` is the
lifetime spend at which credit reaches it. See DESIGN.md "Money".

Keys run while `headroom_usd` is above `reserve_usd`, and pause at it. With
an `active` wallet, `reserve_usd` is zero. Without one, the account is
prepaid, and there is no wallet to collect a last turn's overshoot from. So
`reserve_usd` is the largest prepaid reserve across the same providers
($0.05 on Tinfoil, $0.25 on TrustedRouter), or zero with no keys yet. The
reserve stays part of the account's balance, spent after the next payment.

`funding` lists the account's wallets, at most five. Automatic pulls draw
from the first that is `active` and not backing off after a failed payment.
A wallet's `status` is `needs_attention` once it refuses a payment or a
check, with `detail` saying why, and `unresponsive` while it doesn't answer
our liveness checks. Credit needs at least one `active` wallet.

`refundable_usd` is what `POST /api/refund` would send the wallets, before
the fee. It is the sum of each wallet's `refundable_usd`, the unspent part of
what was pulled from it. A wallet counts only while it's connected and
`receives` refunds. Every Lexe wallet does, and an NWC wallet only if it
offers `make_invoice`. Once removed, a Lexe wallet's pulls count again when
the same wallet reconnects, but an NWC connection's pulls stop counting.
Usage spends what came in otherwise (invoices, credits) first, then pulls no
wallet can take back. See DESIGN.md "Refunds".

`wallet_debts` lists the other accounts this account's wallets funded that
owe money, each with `owed_usd` and `funding_id`, the wallet that owes it.
While the list isn't empty, the account gets no credit and can't create
keys. `POST /api/funding/settle-debt` pays the debts.

### `PUT /api/me/settings` *auth*

```json
{ "refill_target_usd": null | 10.0 }
```

200 with the `GET /api/me` body. 400 if `refill_target_usd` is below the
default target or above $100.

### `PUT /api/me/password` *auth*

```json
{ "password": "...", "username": null | "alice" }
```

Give a code account a password, which replaces its account code, and
`username` if given. Both are validated as at signup (400, 409
`username_taken`). Logs out the account's other sessions. 200 with the
`GET /api/me` body. There is no password change yet, so this is 409
`password_set` if the account already has a password.

### `POST /api/me/login-code` *auth*

Replace the account code with a new one, and log out the account's other
sessions. 200 `{ "login_code": "..." }`. 409 `password_set` if the account
logs in with a password.

## Providers

### `GET /api/providers`

```json
[ {
  "id": "tinfoil",
  "name": "Tinfoil",
  "tier": "private" | "standard",
  "base_url": "https://inference.tinfoil.sh/v1",
  "docs_url": "https://docs.tinfoil.sh",
  "privacy_note": "Models run in a secure enclave. Nobody, including us or Tinfoil, can read your prompts.",
  "premium_percent": 7.0,
  "max_credit_usd": 1.00,
  "max_turn_cost_usd": 1.00,
  "concurrency": 1,
  "target_usd": 1.00
} ]
```

`target_usd` is what a new account prepays. With the default `concurrency`
of 1, it is also the largest single request the account can make.
`max_credit_usd` caps the credit line earned at this provider.
`premium_percent` is our markup over the provider's price.

## Funding

Each connect adds a wallet, named:

- `Lexe` for a Lexe wallet.
- For NWC, after the `wallet` picked: `Coinos`, `Alby`, `Alby Go`, `Zeus` or
  `Primal`.
- For `other`, or a pasted string without `wallet`, after its first relay's
  host (`Coinos`, `Alby` or `Primal`), else `NWC wallet`.

If another wallet has that name, the new one gets ` 2`, ` 3` and so on. A
new Lexe wallet goes first in the draw order, and any other last.

Reconnecting a Lexe wallet that is already connected replaces it, keeping
its place and name. An NWC connection has no identity, so connecting one
always adds a wallet.

At five wallets, the start endpoints and `POST /api/funding/nwc` answer 409
`too_many_wallets`. A Lexe start while a Lexe wallet is connected may be a
reconnect, so it is exempt. A flow that completes at the limit anyway is
rejected unless it reconnects a wallet.

### `POST /api/funding/lexe/start` *auth*

```json
{ "delivery": "post" | "redirect" }
```

200 `{ "flow_id": "...", "connection_string": "https://lexe.app/connect?..." }`

The frontend renders the string as a QR code and an "Open in Lexe" link,
then polls the flow. Flows expire after ten minutes. `delivery` sets where
the wallet sends its response:

- `post` (desktop): the wallet POSTs it to the callback below.
- `redirect` (iOS, iPadOS, Android): the wallet opens
  `/connect/return?flow=...&response=...` on this site, which relays it to
  the redirect endpoint below, so the user ends up back here.

### `GET /api/funding/lexe/flows/{flow_id}` *auth*

200 `{ "state": "pending" | "granted" | "rejected" | "expired", "detail": null | "..." }`,
or 404 for an unknown flow. On `granted`, the wallet is already added, so
refetch `/api/me`.

### `POST /api/funding/lexe/callback/{flow_id}`

The Lexe wallet calls this for `post` flows, by LexeConnect `post_url`
delivery. The body is the opaque response blob. 200 on accept, 400
otherwise.

### `POST /api/funding/lexe/redirect/{flow_id}`

```json
{ "url": "https://bitcoinrouter.ai/connect/return?flow=...&response=..." }
```

The `/connect/return` page calls this for `redirect` flows with the URL the
wallet opened, by LexeConnect `redirect_uri` delivery. The response only
decrypts with the flow's key, so no session is needed, and this works in
whichever browser the phone opens. 200 with the flow's final
`{ "state", "detail" }`, as from `GET /api/funding/lexe/flows/{flow_id}`.
400 if the response is invalid or the flow already finished, 404 if it
expired or is unknown.

### `POST /api/funding/settle-debt` *auth*

```json
{ "account_id": 7, "funding_id": null | 3 }
```

Pay what one of the account's wallets owes on account 7, crediting
account 7. The payment comes from the wallet `funding_id`, by default the
one that owes it (the debt's `funding_id`). The amount is the debt, clamped
to ten cents to $100. So a debt under ten cents is slightly overpaid,
leaving account 7 a small positive balance, and a larger one takes several
payments.

202 `{ "payment_id": 12 }`, then poll `GET /api/payments/{id}`. 409
`no_funding` without a wallet. 404 if account 7 isn't in `wallet_debts` or
`funding_id` isn't one of the account's wallets. 409 `payment_pending`
while account 7 has a pull in flight, 409 `funding_changed` if the wallet
was removed meanwhile.

### `POST /api/funding/nwc` *auth*

`{ "uri": "nostr+walletconnect://...", "wallet": "zeus" }`: connect a
pasted NWC connection string. The string may omit the `//`, and has one or
more `relay` params. The optional `wallet` takes any value `start` does,
and names the wallet. We fetch the wallet's info event, require NIP-44
(`nip44_v2`), and call `get_info`, which must allow `pay_invoice` on
mainnet. 204 once added. 400 with a plain message for a malformed string, a
NIP-04-only or unreachable wallet, no `pay_invoice`, a relay that isn't
`wss://`, or any other field in the body. Shares the LexeConnect start rate
limit.

### `POST /api/funding/nwc/start` *auth*

`{ "wallet": "coinos" | "alby" | "alby_go" | "zeus" | "primal" | "other" }`:
start a one-click NWC-08 flow. We generate the connection's key and keep it.
Flows expire after ten minutes. 400 for any other field in the body.

- `coinos`, `alby`: 200 `{ "flow_id": "...", "url": "https://coinos.io/apps/new?..." }`.
  Open `url` in a popup. On approval, the wallet's page posts a
  `{ "type": "nwc:success", "walletPubkey", "relayUrl" }` message to the
  opener. Accept it only from `url`'s origin and the popup, then call
  `complete`. Without an opener, the page instead redirects to
  `/connect/nwc/return?flow=<id>` with the same values as query params:
  `wallet_pubkey` and `relay_url` per NWC-08, `pubkey` and `relay` from Alby.
- any other wallet: 200 `{ "flow_id": "...", "uri": "nostr+walletauth://..." }`.
  Show `uri` as a QR code and a link. The wallet answers on our relay, so
  poll the flow.

### `GET /api/funding/nwc/flows/{flow_id}` *auth*

As `GET /api/funding/lexe/flows/{flow_id}`.

### `POST /api/funding/nwc/flows/{flow_id}/complete` *auth*

`{ "wallet_pubkey": "<hex>", "relay_url": "wss://...", "state": "..." | null }`:
report the HTTP flow's approval. `state` must match when present. Coinos and
Alby omit it. We verify the wallet as `POST /api/funding/nwc` does, then
store it. 200 with the flow's `{ "state", "detail" }`, `granted` or
`rejected`. A repeated report gets the flow's current state. 400 for a wrong
`state` or a malformed body, 404 for an unknown or expired flow.

### `PATCH /api/funding/{id}` *auth*

`{ "name": "Savings" }`: rename a wallet. Trimmed, 1 to 40 characters, else
400. 204, or 404 if it isn't one of the account's wallets.

### `PUT /api/funding/order` *auth*

`{ "ids": [3, 1, 4] }`: set the draw order, first to pull from first. 204.
409 `funding_changed` unless `ids` lists each of the account's wallets
exactly once. After a 409, refetch `/api/me`.

### `DELETE /api/funding/{id}` *auth*

Remove a wallet. 204, or 404 if it isn't one of the account's wallets. 409
`payment_pending` while a payment draws from it, including one for another
account's debt. 409 `debt_outstanding` if the account balance is negative
and this is its only `active` wallet. An inactive wallet can always go.

Removing the last `active` wallet ends credit, so keys are capped at current
usage. The wallet's `refundable_usd` stops being refundable, for good if
it's NWC, or until the same wallet reconnects if it's Lexe.

## Top-ups, invoices and payments

A payment is a pull, which one of the account's wallets pays when we ask
it, or an invoice, which we show the user to pay from any wallet. Unpaid
invoices cost nobody anything, so they never hold up a pull.

### `POST /api/topup` *auth*

```json
{ "provider": null | "tinfoil", "funding_id": null | 3 }
```

Pull from the wallet `funding_id`, by default the first `active` one, else
the first. The pull brings headroom up to the target, counting `provider`
(the provider of a key about to be minted) toward it. Settling the pull
reactivates the paying wallet, so it also counts the credit the account
will have then.

400 if headroom is already at the target, unless the wallet isn't `active`,
in which case the pull is ten cents, to reactivate it. The user never picks
the amount, so sending `amount_usd` is 400 "Payments are sized to your
buffer", and 400 if `provider` isn't enabled. 202 `{ "payment_id": 7 }`.
409 `account_paused` while paused, 409 `no_funding` without a wallet, 404
if `funding_id` isn't one of the account's wallets, 409 `payment_pending`
while another pull is in flight, 409 `funding_changed` if the wallet was
removed meanwhile.

### `POST /api/invoices` *auth*

```json
{ "amount_usd": null | 1.0, "provider": null | "tinfoil" }
```

Create an invoice for the user to pay from any Lightning wallet. A `null`
amount means whatever brings headroom up to the target with `provider`
counted, at least ten cents. So an account that has paid nothing gets the
provider's target. Unlike a pull, paying an invoice reactivates no wallet,
so this counts only the credit the account has now.

400 if the amount is outside ten cents to $1,000, or `provider` isn't
enabled. 409 `balance_cap` if paying it, on top of the account's other
unpaid, unexpired invoices, would take the balance past $1,000, counting a
debt as zero. A `null` amount stops short of that instead, and is 409 once
under ten cents of room is left.

```json
InvoiceInfo = {
  "payment_id": 7,
  "number": 1,
  "invoice": "lnbc...",
  "amount_usd": 1.00,
  "amount_sats": 893,
  "expires_at": 1760000600000
}
```

201 with a new invoice, or 200 with a pending one for the same amount that
has at least two minutes left, so a double click doesn't make two. 409
`too_many_invoices` while the account has three unexpired pending ones.
Invoices expire after ten minutes. We credit an invoice within seconds of
payment, so poll `GET /api/payments/{id}` until it leaves `pending`. What an
invoice pays is non-refundable.

### `GET /api/payments/{id}` *auth*

One of the account's payments, or one its wallet made to settle another
account's debt.

```json
{
  "id": 7,
  "number": 3,
  "kind": "pull" | "invoice",
  "amount_usd": 4.00,
  "amount_sats": 3571,
  "state": "pending" | "settled" | "failed" | "expired",
  "error": null | "...",
  "invoice": "lnbc...",
  "wallet_name": null | "Lexe",
  "created_at": 1760000000000,
  "expires_at": 1760000600000,
  "settled_at": null | 1760000030000
}
```

404 unless the payment is the account's, or one its wallet made for another
account's debt. `number` counts the account's payments from 1, and the
payment's invoice says "Payment 3". `wallet_name` is the wallet it was drawn
from, removed ones included. It is `null` for an invoice, and for a payment
from another account's wallet settling this account's debt. `expires_at` is
when its invoice expires. An unpaid invoice turns `expired` some minutes
later.

### `DELETE /api/payments/{id}` *auth*

Cancel one of the account's unpaid invoices, so it stops counting toward
the prepaid cap. 204. 404 unless it is the account's, pending and
unexpired. The invoice stays payable on our node until it expires, and a
payment that lands anyway still credits the account.

## Pausing and refunds

What we pull from a wallet is a prepayment for inference, not a balance to
withdraw. While the account is paused, the unspent part can go back to the
wallets it came from, all of it at once. What an invoice or a credit paid
can't. See DESIGN.md "Refunds".

### `POST /api/pause` *auth*

Pause the account. While it is paused:

- Every key is capped at its usage and paused, with `paused_reason`
  `account_paused`.
- There is no credit.
- Nothing is pulled but debt, which is pulled right away.
- Creating keys and `POST /api/topup` answer 409 `account_paused`.
- Invoices, wallet changes and refunds work as usual.

204, also if already paused.

### `POST /api/resume` *auth*

Lift the pause. The keys resume, credit returns, and a refill is pulled if
headroom is below half the target. 204, also if not paused. 409
`refund_pending` while a refund is in flight.

### `POST /api/refund` *auth*

Refund the paused account. This first takes a final usage reading of every
key, as revoking one does, so a turn in flight is charged first. If a
provider can't be read, the request fails, to be retried. Then each wallet
with a `refundable_usd` gets one payment of that amount, in sats at today's
rate, less our node's 0.5% fee. A wallet with under a cent to refund is
skipped, and that amount stays spendable.

```json
{
  "refunds": [ RefundInfo ],     // pending, paid in the background
  "errors": [ { "funding_id": 3, "wallet_name": "Coinos", "error": "..." } ]
}
```

202. `errors` lists the wallets that couldn't say where to send their
refund. They get no refund, and each gets a `refund_failed` event.

409 `not_paused`, and 409 `refund_pending` while a refund is in flight. A
pull in flight would settle after the refund was sized, so it is 409
`payment_pending`. 409 `nothing_to_refund` if there is nothing to refund.

Poll `GET /api/refunds/{id}` until it leaves `pending`, which it does once
our node says whether it was sent. If our node never accepts it, it is tried
again for up to an hour, then fails with the reason. A failed refund changes
nothing, so the user can try again.

Usage charged while a refund is in flight still spends what it is sending,
so the balance may end a little negative. That is debt, pulled as any other.

### `GET /api/refunds/{id}` *auth*

One of the account's refunds, else 404.

```json
RefundInfo = {
  "id": 2,
  "number": 1,
  "wallet_name": null | "Lexe",
  "amount_usd": 4.20,
  "amount_sats": 4179,
  "fee_sats": 21,
  "state": "pending" | "settled" | "failed",
  "error": null | "Payment failed",
  "created_at": 1760000000000,
  "settled_at": null | 1760000030000
}
```

`number` counts the account's refunds from 1, and the wallet shows
"Refund 1". `amount_usd` comes off the balance when the refund settles.
`amount_sats` is what the wallet receives, after `fee_sats`. `wallet_name`
is the wallet refunded to, removed ones included.

## Keys

### `POST /api/keys` *auth*

```json
{ "provider": "tinfoil", "name": null | "laptop" }
```

201 `{ "key": KeyInfo, "secret": "tk_..." }`. The secret is returned exactly
once. `name` is an optional label of up to 40 characters. 400 if `provider`
isn't enabled. The preconditions are checked in this order, each a 409:

1. `account_paused` while the account is paused. See "Pausing and refunds".
2. `wallet_has_debt` while another account funded by one of its wallets
   owes money. See `wallet_debts`.
3. `key_limit` at ten live keys across providers, or one without a wallet.
4. `topup_required` while headroom is below the target. The frontend then
   offers `POST /api/topup`, or `POST /api/invoices` without a wallet.

```json
KeyInfo = {
  "id": 3,
  "name": null | "laptop",
  "provider": "tinfoil",
  "provider_name": "Tinfoil",
  "tier": "private",
  "base_url": "https://inference.tinfoil.sh/v1",
  "cap_usd": 4.00,
  "usage_usd": 0.12,
  "paused_reason": null | "exhausted" | "account_paused" | "provider_error",
  "created_at": 1760000000000
}
```

`cap_usd` and `usage_usd` include the premium, so they are in the same
dollars as `balance_usd` and `headroom_usd`. `paused_reason` is:

- `exhausted` while headroom is at or below `reserve_usd`. Pair it with the
  `funding` statuses to choose the hint.
- `account_paused` while the account is paused.
- `provider_error` once the provider has rejected the key for good.

### `GET /api/keys/quote?provider=<id>` *auth*

200 `{ "payment_usd": 0.30 }`: what `POST /api/keys` at `provider` needs
paid first, or 0 if nothing. This is the `POST /api/topup` refill with
`provider` counted. Without a wallet, pay it with `POST /api/invoices`. The
frontend shows it before the user commits. 400 if the provider is not
enabled. 409 `key_limit` if the account can't create another key, as for
`POST /api/keys`.

### `PATCH /api/keys/{id}` *auth*

`{ "name": "laptop" | null }`: rename a key. Trimmed, at most 40
characters, else 400. Blank or null clears the name. 204, or 404 if it
isn't one of the account's live keys.

### `DELETE /api/keys/{id}` *auth*

Revoke at the provider and locally. First the key's cap is frozen and a
final usage reading taken, so nothing used since the last poll goes
unbilled. 204. 404 unless it's one of the account's live keys. 5xx, with
the key left live, if the provider can't be reached.

## Events

```json
EventInfo = {
  "id": 11,
  "kind": "pull_failed" | "funding_revoked" | "key_paused" | "key_resumed"
        | "overshoot_debt" | "wallet_reused" | "payment_settled"
        | "funding_unresponsive" | "funding_restored" | "wallet_debt_paid"
        | "account_paused" | "account_resumed" | "refund_settled"
        | "refund_failed",
  "detail": "...",
  "wallet_name": null | "Coinos",
  "created_at": 1760000000000
}
```

The frontend maps each `kind` to a fixed remediation hint.

`pull_failed` and `funding_revoked` go to the account whose wallet failed
to pay, even when the payment was for another account's debt. A payment of
another account's debt is also recorded there, as `wallet_debt_paid`.
`payment_settled` goes to the account credited.

`wallet_name` names the wallet the event concerns, removed ones included.
It is set for `pull_failed`, `funding_revoked`, `funding_unresponsive`,
`funding_restored`, `wallet_debt_paid`, `wallet_reused`, `refund_settled`
and `refund_failed`, and `null` otherwise.

`refund_settled`'s `detail` reads "Refunded $4.20", and `refund_failed`'s is
the error. `account_paused` and `account_resumed` say what the change means
for the keys.

## History

Full history, newest first, a page at a time. Pass a page's `next_before`
as `before` to get the next one. `next_before` is set whenever the page is
full, so the next page may be empty. `null` means there is no more. `limit`
is clamped to 1 to its maximum.

```json
Page<T, C> = { "items": [ T ], "next_before": null | C }
```

### `GET /api/payments?before=<id>&limit=<n>` *auth*

200 `Page<PaymentInfo, id>`: payments in all states, with ids below
`before`. `limit` defaults to 50, at most 200.

### `GET /api/usage?before=<day>&limit=<n>` *auth*

200 `Page<UsageInfo, day>`: usage charges summed per UTC day and key. A page
covers the `limit` latest days with usage before `before` (`YYYY-MM-DD`),
by key within a day. `limit` counts days, defaulting to 30, at most 365.
400 on a malformed `before`.

```json
UsageInfo = {
  "day": "2026-10-17",
  "key_id": 3,
  "key_name": null | "laptop",
  "provider": "tinfoil",
  "charged_usd": 0.04
}
```

`charged_usd` includes the premium, like `balance_usd`. Revoked keys are
included.

### `GET /api/events?before=<id>&limit=<n>` *auth*

200 `Page<EventInfo, id>`: events with ids below `before`, kept for a year.
`limit` defaults to 50, at most 200.

### `GET /api/payments.csv` and `GET /api/usage.csv` *auth*

200 `text/csv` with `Content-Disposition: attachment`: the full history,
unpaginated, in the same order, with a header row. Timestamps are ISO 8601
in UTC.

```
id,created_at_iso,amount_usd,amount_sats,state,settled_at_iso,error,wallet_name,number
day,key_id,key_name,provider,charged_usd
```
