# Balance, top-ups and keys

Check what you have, top up over Lightning, top up automatically from your own wallet, see what each request cost, and manage keys from code.

## Checking the balance

Both of your balances, and how much of this key's cap is used.

`GET https://nymbot.ai/api/v1/credits/balance` — needs an API key. `POST` works too, for clients that expect it.

`balance` is the two balances together in dollars at the current Bitcoin price, for tools that expect a single number (`null` if the price cannot be read). The rest is in credits and sats, which is how the balances are actually kept. `key` describes the key that asked. A key that has reached its cap can still check the balance.

Response

```
{
  "balance": 49.18,
  "balance_sats": 42037,
  "standard": { "credits": 120.4, "sats": 1204 },
  "pro": { "credits": 408.33, "sats": 40833 },
  "key": {
    "id": "4f0c9a1be27d3856",
    "name": "laptop scripts",
    "limit_sats": 20000,
    "period_used_sats": 3412,
    "total_used_sats": 18230,
    "reset_period": "monthly",
    "reset_at": "2026-10-01T00:00:00Z"
  }
}
```

| Status | When |
| --- | --- |
| `401` | The key is missing, unknown, revoked or expired. |

cURL

```
curl https://nymbot.ai/api/v1/credits/balance \
  -H "Authorization: Bearer $NYMBOT_API_KEY"
```

Python

```
import os
import requests

res = requests.get(
    "https://nymbot.ai/api/v1/credits/balance",
    headers={"Authorization": "Bearer " + os.environ["NYMBOT_API_KEY"]},
)
balance = res.json()
print(balance["standard"]["sats"], balance["pro"]["sats"])
```

JavaScript

```
const res = await fetch("https://nymbot.ai/api/v1/credits/balance", {
  headers: { "Authorization": "Bearer " + process.env.NYMBOT_API_KEY },
});
const balance = await res.json();
console.log(balance.standard.sats, balance.pro.sats);
```

## Payment methods

How you can top up, and the limits. Lightning is the only method.

`GET https://nymbot.ai/api/v1/topup/payment-methods` — no key needed.

A top-up is 10 to 1,000,000 sats; a Pro top-up has to buy at least one Pro credit, so it starts at 100 sats. The dollar limits follow the Bitcoin price. `bulk_bonus` lists the extra credit on larger top-ups, the same as in the app: 10%, 15% or 20% more on standard top-ups from 500, 1,000 or 5,000 sats, and on Pro top-ups from 5,000, 10,000 or 50,000 sats.

Response

```
{
  "supported_methods": [
    {
      "method": "btc-lightning",
      "display_name": "Bitcoin Lightning",
      "supported_currencies": ["SATS", "USD", "BTC"],
      "limits": {
        "SATS": { "min": 10, "max": 1000000 },
        "USD": { "min": 0.02, "max": 1170 },
        "BTC": { "min": 1e-7, "max": 0.01 }
      },
      "tiers": ["standard", "pro"],
      "default_tier": "pro",
      "tier_min_sats": { "standard": 10, "pro": 100 },
      "sats_per_credit": { "standard": 10, "pro": 100 },
      "bulk_bonus": [
        { "bonus": 0.1, "standard_sats": 500, "pro_sats": 5000 },
        { "bonus": 0.15, "standard_sats": 1000, "pro_sats": 10000 },
        { "bonus": 0.2, "standard_sats": 5000, "pro_sats": 50000 }
      ]
    }
  ]
}
```

cURL

```
curl https://nymbot.ai/api/v1/topup/payment-methods
```

Python

```
import requests

methods = requests.get("https://nymbot.ai/api/v1/topup/payment-methods").json()
print(methods["supported_methods"][0]["limits"])
```

JavaScript

```
const methods = await (await fetch("https://nymbot.ai/api/v1/topup/payment-methods")).json();
console.log(methods.supported_methods[0].limits);
```

## Topping up over Lightning

Makes a Lightning invoice that adds credit to the nym the key belongs to. Pay it from any Lightning wallet, then [check it](#topup-status) to have the credit added.

`POST https://nymbot.ai/api/v1/topup/create/btc-lightning` — needs an API key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount` | number | Yes | How much, in `currency`. A whole number for sats. |
| `currency` | string | No | `SATS` (the default), `USD` or `BTC`. Dollars are converted at the current Bitcoin price. |
| `tier` | string | No | `pro` (the default) or `standard`: which balance the credit goes to. |

A standard credit is 10 sats and a Pro credit 100 sats, plus any bulk bonus; `credits` says what this invoice will add.

Response

```
{
  "invoice_id": "b7d41e0c95a2f38e6c1d0e9a4b7f2c61d3e8a05f9b2c4d7e1a6f3b8c0d5e2a9f4",
  "payment_request": "lnbc100u1p5...",
  "amount_sats": 10000,
  "credits": 115,
  "tier": "pro",
  "expires_at": "2026-09-30T09:27:00Z",
  "status": "pending"
}
```

| Status | When |
| --- | --- |
| `400` | Another method in the path (`unsupported_method`), an unknown currency (`unsupported_currency`) or tier, a missing amount, or an amount below the minimum (`amount_too_small`), above 1,000,000 sats (`amount_too_large`) or refused by the Lightning wallet (`amount_out_of_range`). |
| `429` | More than 60 invoices for this nym, or 120 from this address, in an hour (`rate_limit_exceeded`, with `Retry-After`). |
| `502` | No invoice could be made right now (`invoice_unavailable`, with `Retry-After`). |

cURL

```
curl https://nymbot.ai/api/v1/topup/create/btc-lightning \
  -H "Authorization: Bearer $NYMBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 10000, "currency": "SATS", "tier": "pro"}'
```

Python

```
import os
import requests

res = requests.post(
    "https://nymbot.ai/api/v1/topup/create/btc-lightning",
    headers={"Authorization": "Bearer " + os.environ["NYMBOT_API_KEY"]},
    json={"amount": 10000, "currency": "SATS", "tier": "pro"},
)
invoice = res.json()
print(invoice["payment_request"])
```

JavaScript

```
const res = await fetch("https://nymbot.ai/api/v1/topup/create/btc-lightning", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + process.env.NYMBOT_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: 10000, currency: "SATS", tier: "pro" }),
});
const invoice = await res.json();
console.log(invoice.payment_request);
```

## Checking a top-up

Asks whether the invoice has been paid and, once it has, adds the credit. Checking is what credits it, so after paying, check until the status is `credited`. Checking again afterwards is safe: the credit lands once, however many times you ask.

`GET https://nymbot.ai/api/v1/topup/status/{invoice_id}` — needs a key from the nym that made the invoice.

`status` is `pending` (not paid yet), `paid` (paid, but not yet credited; check again), `credited` (on your balance) or `expired` (not paid in time). The balance fields are for the tier the invoice tops up.

Response

```
{
  "invoice_id": "b7d41e0c95a2f38e6c1d0e9a4b7f2c61d3e8a05f9b2c4d7e1a6f3b8c0d5e2a9f4",
  "status": "credited",
  "amount_sats": 10000,
  "credits": 115,
  "tier": "pro",
  "expires_at": null,
  "balance_credits": 523.33,
  "balance_sats": 52333
}
```

| Status | When |
| --- | --- |
| `400` | The id is not the 64-character id from the create call. |
| `404` | No invoice by that id for your nym (`invoice_not_found`). |

cURL

```
curl https://nymbot.ai/api/v1/topup/status/$INVOICE_ID \
  -H "Authorization: Bearer $NYMBOT_API_KEY"
```

Python

```
import os, time
import requests

headers = {"Authorization": "Bearer " + os.environ["NYMBOT_API_KEY"]}
url = "https://nymbot.ai/api/v1/topup/status/" + invoice["invoice_id"]

while True:
    status = requests.get(url, headers=headers).json()["status"]
    if status in ("credited", "expired"):
        break
    time.sleep(3)
print(status)
```

JavaScript

```
const headers = { "Authorization": "Bearer " + process.env.NYMBOT_API_KEY };
const url = "https://nymbot.ai/api/v1/topup/status/" + invoice.invoice_id;

let status;
do {
  await new Promise((r) => setTimeout(r, 3000));
  status = (await (await fetch(url, { headers })).json()).status;
} while (status !== "credited" && status !== "expired");
console.log(status);
```

## Query history

One row per request: what it was, which model, how many tokens and what it cost. No prompts or answers are kept, so none are returned. Rows are kept for 90 days, newest first. A key that has reached its cap can still read its history.

`GET https://nymbot.ai/api/v1/queries/history` — needs an API key, which sees its own requests, or a [signed request](#nip98) from your nym, which sees every key's.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer (query) | No | Default 1, at most 1,000; a higher page is `400` `invalid_value`. |
| `page_count` | integer (query) | No | Rows per page. Default 20, at most 100. |
| `start_date` `end_date` | string (query) | No | ISO 8601 dates or times. |
| `model` | string (query) | No | Only this model. |
| `type` | string (query) | No | `chat`, `responses`, `messages`, `image`, `video`, `speech`, `transcription` or `embedding`. |
| `all_keys` | boolean (query) | No | With a key: `true` includes every key of the same nym. Default `false`. |
| `key_id` | string (query) | No | With a signed request, or with `all_keys=true`: only this key. |

Response

```
{
  "data": [
    {
      "id": "q_71c4e9a0",
      "timestamp": "2026-09-30T08:12:00Z",
      "model": "anthropic/claude-sonnet-5",
      "type": "chat",
      "input_tokens": 1240,
      "output_tokens": 380,
      "cached_tokens": 0,
      "cost_sats": 16.2,
      "cost_usd": 0.01895,
      "balance": "pro",
      "key_id": "4f0c9a1be27d3856",
      "web_search": false,
      "status": "ok"
    }
  ],
  "pagination": { "page": 1, "page_count": 20, "total": 311, "total_pages": 16 }
}
```

cURL

```
curl "https://nymbot.ai/api/v1/queries/history?page_count=50&type=chat" \
  -H "Authorization: Bearer $NYMBOT_API_KEY"
```

Python

```
import os
import requests

res = requests.get(
    "https://nymbot.ai/api/v1/queries/history",
    headers={"Authorization": "Bearer " + os.environ["NYMBOT_API_KEY"]},
    params={"page_count": 50, "type": "chat"},
)
for row in res.json()["data"]:
    print(row["timestamp"], row["model"], row["cost_sats"])
```

JavaScript

```
const res = await fetch("https://nymbot.ai/api/v1/queries/history?page_count=50&type=chat", {
  headers: { "Authorization": "Bearer " + process.env.NYMBOT_API_KEY },
});
for (const row of (await res.json()).data) console.log(row.timestamp, row.model, row.cost_sats);
```

## Signing account requests

Making, changing and revoking keys, the account summary and automatic top-ups do not take an API key. They take a signature from your nym, so a leaked key can spend up to its cap but can never make another key or raise its own cap.

The app does this for you: everything in its **API** sheet uses these endpoints. You only need this section to manage keys from your own code.

The signature is a Nostr event of kind 27235 (NIP-98), sent base64-encoded in the `Authorization` header with the word `Nostr` in front:

The event

```
{
  "kind": 27235,
  "created_at": 1790726400,
  "tags": [
    ["u", "https://nymbot.ai/api/v1/keys"],
    ["method", "POST"],
    ["nonce", "9c4e21f07a3b...16 random bytes in hex"],
    ["payload", "3f1a0d7c8e2b...sha256 of the exact request body in hex"]
  ],
  "content": "",
  "pubkey": "your public key in hex",
  "id": "...",
  "sig": "..."
}
```

- `u` is the full URL of the request, query string included, exactly as sent.
- `method` is the HTTP method.
- `payload` is the SHA-256 of the raw request body, in hex. It is required on `POST` and `PATCH`, and the body you send has to be byte for byte the one you hashed.
- `created_at` has to be within 60 seconds of the server's clock.
- Every event works once, a `GET` included, so a captured header cannot be replayed. Sign a new one for every request. Add a `nonce` tag with a random value so two requests signed in the same second still differ.
- The body of a signed request can be at most 64 KB, and a body needs `Content-Type: application/json`.

A missing event returns `401` `missing_nostr_auth`; one that is badly formed, badly signed, too old, or for a different URL, method or body returns `invalid_nostr_auth`, with the reason in the message; a reused one returns `nostr_auth_replayed`. An API key sent to these endpoints is refused. The signature is checked before the body is read, and each address can fail it 30 times a minute (an IPv6 address counts as its whole /64); after that it gets `429` with `Retry-After`.

Browsers can call these endpoints only from Nymbot's own sites (`https://nymbot.ai`, `https://nymchat.app` and their subdomains). A page on any other site gets no CORS headers back, so it cannot read what they return. Scripts and native apps, which send no `Origin`, are not affected.

> **Your secret key**
>
> Signing needs your nym's secret key (the `nsec`), which controls everything: your identity, your history and your balance. Only put it in a script on a machine you trust, read it from the environment rather than writing it into the file, and prefer the app when you can.

These helpers build the header. The later examples on this page use them. They read the secret key in hex from `NOSTR_SECRET_HEX`; the cURL one uses the [nak](https://github.com/fiatjaf/nak) command-line tool, which takes an nsec or hex key, and `sha256sum` (on macOS, `shasum -a 256`).

cURL

```
nostr_auth() {
  method="$1"; url="$2"; body="$3"
  nonce=$(openssl rand -hex 16)
  if [ -n "$body" ]; then
    hash=$(printf '%s' "$body" | sha256sum | cut -d' ' -f1)
    event=$(nak event --sec "$NOSTR_SECRET_HEX" -k 27235 -t "u=$url" -t "method=$method" -t "nonce=$nonce" -t "payload=$hash")
  else
    event=$(nak event --sec "$NOSTR_SECRET_HEX" -k 27235 -t "u=$url" -t "method=$method" -t "nonce=$nonce")
  fi
  printf 'Nostr %s' "$(printf '%s' "$event" | base64 | tr -d '\n')"
}
```

Python

```
# pip install coincurve requests
import base64, hashlib, json, os, time
from coincurve import PrivateKey, PublicKeyXOnly

SECRET = bytes.fromhex(os.environ["NOSTR_SECRET_HEX"])

def nostr_auth(method, url, body=b""):
    pubkey = PublicKeyXOnly.from_secret(SECRET).format().hex()
    tags = [["u", url], ["method", method], ["nonce", os.urandom(16).hex()]]
    if body:
        tags.append(["payload", hashlib.sha256(body).hexdigest()])
    created_at = int(time.time())
    serialized = json.dumps([0, pubkey, created_at, 27235, tags, ""], separators=(",", ":"), ensure_ascii=False)
    event_id = hashlib.sha256(serialized.encode()).digest()
    event = {
        "id": event_id.hex(),
        "pubkey": pubkey,
        "created_at": created_at,
        "kind": 27235,
        "tags": tags,
        "content": "",
        "sig": PrivateKey(SECRET).sign_schnorr(event_id).hex(),
    }
    return "Nostr " + base64.b64encode(json.dumps(event).encode()).decode()
```

JavaScript

```
// npm install nostr-tools
import { createHash, randomBytes } from "node:crypto";
import { finalizeEvent } from "nostr-tools/pure";

const secret = Buffer.from(process.env.NOSTR_SECRET_HEX, "hex");

export function nostrAuth(method, url, body = "") {
  const tags = [["u", url], ["method", method], ["nonce", randomBytes(16).toString("hex")]];
  if (body) tags.push(["payload", createHash("sha256").update(body).digest("hex")]);
  const event = finalizeEvent(
    { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: "" },
    secret,
  );
  return "Nostr " + Buffer.from(JSON.stringify(event)).toString("base64");
}
```

## The account summary

What the app's API sheet shows at the top: your public key, both balances, how many keys are active (not revoked or expired), and the [automatic top-up](#nwc) settings, or `null` when the server does not offer them.

`GET https://nymbot.ai/api/v1/account` — needs a [signed request](#nip98).

Response

```
{
  "data": {
    "pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
    "balances": {
      "standard": { "credits": 120.4, "sats": 1204 },
      "pro": { "credits": 408.33, "sats": 40833 }
    },
    "keys_active": 3,
    "nwc_auto_topup": {
      "connected": true, "threshold_sats": 5000, "topup_sats": 20000, "tier": "pro",
      "last_topup_at": null, "last_topup_sats": null, "last_error": null
    }
  }
}
```

cURL

```
URL=https://nymbot.ai/api/v1/account
curl "$URL" -H "Authorization: $(nostr_auth GET "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/account"
print(requests.get(url, headers={"Authorization": nostr_auth("GET", url)}).json())
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/account";
const res = await fetch(url, { headers: { "Authorization": nostrAuth("GET", url) } });
console.log(await res.json());
```

## Managing keys

The endpoints behind the app's key list. All of them need a [signed request](#nip98). Every key is returned in this form, with times in ISO 8601 and amounts in sats:

Key object

```
{
  "id": "4f0c9a1be27d3856",
  "name": "laptop scripts",
  "hint": "sk-nymbot-Qm7x…c2Lw",
  "limit_sats": 20000,
  "reset_period": "monthly",
  "reset_at": "2026-10-01T00:00:00Z",
  "expire_at": null,
  "period_used_sats": 3412,
  "total_used_sats": 18230,
  "created_at": "2026-08-14T09:21:07Z",
  "updated_at": "2026-09-02T17:40:55Z",
  "last_used_at": "2026-09-30T08:12:00Z",
  "revoked_at": null
}
```

`hint` is enough to recognize a key but not to use it. The key itself is returned only once, when it is made.

### Listing keys

`GET https://nymbot.ai/api/v1/keys` — signed.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `include_revoked` | boolean (query) | No | Include revoked keys. Default `false`. |

Response

```
{ "data": [ { "id": "4f0c9a1be27d3856", "name": "laptop scripts", "hint": "sk-nymbot-Qm7x…c2Lw", "...": "..." } ] }
```

cURL

```
URL=https://nymbot.ai/api/v1/keys
curl "$URL" -H "Authorization: $(nostr_auth GET "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/keys"
for key in requests.get(url, headers={"Authorization": nostr_auth("GET", url)}).json()["data"]:
    print(key["id"], key["name"], key["period_used_sats"], key["limit_sats"])
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/keys";
const { data } = await (await fetch(url, { headers: { "Authorization": nostrAuth("GET", url) } })).json();
for (const key of data) console.log(key.id, key.name, key.period_used_sats, key.limit_sats);
```

### Making a key

`POST https://nymbot.ai/api/v1/keys` — signed. Returns `201`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | 1 to 40 characters, different from your other active keys (ignoring case). |
| `limit_sats` | integer | No | The spending cap in sats, at least 1. Leave it out for no cap. |
| `reset_period` | string | No | `daily`, `weekly` or `monthly`. Needs `limit_sats`. Leave it out for a cap that never resets. |
| `expire_at` | string or integer | No | When the key stops working: an ISO 8601 time, or milliseconds since 1970. |

Response (201)

```
{
  "data": {
    "id": "4f0c9a1be27d3856",
    "name": "laptop scripts",
    "hint": "sk-nymbot-Qm7x…c2Lw",
    "limit_sats": 20000,
    "reset_period": "monthly",
    "key": "sk-nymbot-Qm7x...c2Lw",
    "...": "the rest of the key object"
  }
}
```

| Status | When |
| --- | --- |
| `400` | A missing or too long name; a name already in use (`duplicate_name`); a cap that is not a whole number of at least 1; a reset period without a cap; an expiry in the past; an unknown field (`unknown_parameter`); or 25 active keys already (`too_many_keys`). |
| `429` | More than 60 keys made by this nym, or 120 from this address, in an hour (`rate_limit_exceeded`, with `Retry-After`). |

cURL

```
URL=https://nymbot.ai/api/v1/keys
BODY='{"name":"laptop scripts","limit_sats":20000,"reset_period":"monthly"}'
curl "$URL" \
  -H "Authorization: $(nostr_auth POST "$URL" "$BODY")" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

Python

```
import json
import requests

url = "https://nymbot.ai/api/v1/keys"
body = json.dumps({"name": "laptop scripts", "limit_sats": 20000, "reset_period": "monthly"}).encode()
res = requests.post(
    url,
    data=body,
    headers={"Authorization": nostr_auth("POST", url, body), "Content-Type": "application/json"},
)
print(res.json()["data"]["key"])
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/keys";
const body = JSON.stringify({ name: "laptop scripts", limit_sats: 20000, reset_period: "monthly" });
const res = await fetch(url, {
  method: "POST",
  headers: { "Authorization": nostrAuth("POST", url, body), "Content-Type": "application/json" },
  body,
});
console.log((await res.json()).data.key);
```

### Reading one key

`GET https://nymbot.ai/api/v1/keys/{id}` — signed.

Returns `{"data": {…}}` with the key object, or `404` `key_not_found` if no key of yours has that id.

cURL

```
URL=https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856
curl "$URL" -H "Authorization: $(nostr_auth GET "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856"
print(requests.get(url, headers={"Authorization": nostr_auth("GET", url)}).json()["data"])
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856";
console.log((await (await fetch(url, { headers: { "Authorization": nostrAuth("GET", url) } })).json()).data);
```

### Changing a key

`PATCH https://nymbot.ai/api/v1/keys/{id}` — signed.

Send any of `name`, `limit_sats`, `reset_period` and `expire_at`, with the same rules as when making a key. `null` clears a field: no cap, no reset, no expiry. Changing the reset period starts a new period at zero. A revoked key cannot be changed (`400` `key_revoked`). Returns `{"data": {…}}` with the updated key object.

cURL

```
URL=https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856
BODY='{"limit_sats":50000,"expire_at":null}'
curl -X PATCH "$URL" \
  -H "Authorization: $(nostr_auth PATCH "$URL" "$BODY")" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

Python

```
import json
import requests

url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856"
body = json.dumps({"limit_sats": 50000, "expire_at": None}).encode()
res = requests.patch(
    url,
    data=body,
    headers={"Authorization": nostr_auth("PATCH", url, body), "Content-Type": "application/json"},
)
print(res.json()["data"])
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856";
const body = JSON.stringify({ limit_sats: 50000, expire_at: null });
const res = await fetch(url, {
  method: "PATCH",
  headers: { "Authorization": nostrAuth("PATCH", url, body), "Content-Type": "application/json" },
  body,
});
console.log((await res.json()).data);
```

### Revoking a key

`DELETE https://nymbot.ai/api/v1/keys/{id}` — signed.

Stops the key at once, for good. It stays in the list with `revoked_at` set, and can be seen with `include_revoked=true`. Revoking a key that is already revoked answers the same way. Only the newest 50 revoked keys are kept; older ones are deleted when another key is revoked.

Response

```
{ "data": { "id": "4f0c9a1be27d3856", "revoked": true } }
```

cURL

```
URL=https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856
curl -X DELETE "$URL" -H "Authorization: $(nostr_auth DELETE "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856"
print(requests.delete(url, headers={"Authorization": nostr_auth("DELETE", url)}).json())
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/keys/4f0c9a1be27d3856";
const res = await fetch(url, { method: "DELETE", headers: { "Authorization": nostrAuth("DELETE", url) } });
console.log(await res.json());
```

## NWC auto-top-up

Connect a Lightning wallet with Nostr Wallet Connect and Nymbot tops up a balance by itself when API spending runs it low. The app's API sheet has the same settings; these are the endpoints behind it. All of them need a [signed request](#nip98).

How it works: after an API request is charged to the balance you chose to watch, if that balance has fallen below your threshold, Nymbot makes an invoice for your top-up amount, asks your wallet to pay it, and adds the credit. It tops up at most once every 5 minutes for each nym and balance, so a burst of requests cannot drain the wallet. Spending in the apps does not trigger it. The time and size of the last top-up, and the last error, are in the settings; if a payment went through after an error, checking its invoice with [the top-up status](#topup-status) credits it.

> **Before you connect a wallet**
>
> A connection string lets whoever holds it ask your wallet to pay. Nymbot stores it encrypted and only ever uses it to pay its own top-up invoices, but make a connection just for this, with a spending budget in your wallet, so the most it could ever pay is a number you chose. The wallet has to support `pay_invoice`.

### Connecting a wallet

`POST https://nymbot.ai/api/v1/nwc-auto-topup/connect` — signed.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `nwc_url` | string | Yes | The connection string, starting `nostr+walletconnect://`. Nymbot asks the wallet for `get_info` before saving it, and stores it encrypted. |
| `threshold_sats` | integer | Yes | Top up when the balance falls below this many sats. At least 1,000. |
| `topup_sats` | integer | Yes | How much to add each time. 1,000 to 1,000,000 sats. |
| `tier` | string | No | `pro` (the default) or `standard`: the balance to watch and top up. |

Response

```
{
  "data": {
    "connected": true,
    "threshold_sats": 5000,
    "topup_sats": 20000,
    "tier": "pro",
    "last_topup_at": null,
    "last_topup_sats": null,
    "last_error": null
  }
}
```

| Status | When |
| --- | --- |
| `400` | Not a connection string (`invalid_nwc_url`); the wallet did not answer over its relay (`nwc_unreachable`) or refused the check (`nwc_rejected`); the connection cannot pay invoices (`nwc_missing_permission`); or an amount outside the limits. |
| `501` | Automatic top-ups are not turned on for this server (`nwc_unavailable`). The same applies to the other two endpoints. |

cURL

```
URL=https://nymbot.ai/api/v1/nwc-auto-topup/connect
BODY='{"nwc_url":"nostr+walletconnect://...","threshold_sats":5000,"topup_sats":20000,"tier":"pro"}'
curl "$URL" \
  -H "Authorization: $(nostr_auth POST "$URL" "$BODY")" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

Python

```
import json, os
import requests

url = "https://nymbot.ai/api/v1/nwc-auto-topup/connect"
body = json.dumps({
    "nwc_url": os.environ["NWC_URL"],
    "threshold_sats": 5000,
    "topup_sats": 20000,
    "tier": "pro",
}).encode()
res = requests.post(
    url,
    data=body,
    headers={"Authorization": nostr_auth("POST", url, body), "Content-Type": "application/json"},
)
print(res.json())
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/nwc-auto-topup/connect";
const body = JSON.stringify({
  nwc_url: process.env.NWC_URL,
  threshold_sats: 5000,
  topup_sats: 20000,
  tier: "pro",
});
const res = await fetch(url, {
  method: "POST",
  headers: { "Authorization": nostrAuth("POST", url, body), "Content-Type": "application/json" },
  body,
});
console.log(await res.json());
```

### Reading the settings

`GET https://nymbot.ai/api/v1/nwc-auto-topup` — signed.

Returns the same object as connecting, with `connected: false` and the other fields `null` when no wallet is connected. The connection string itself is never returned.

cURL

```
URL=https://nymbot.ai/api/v1/nwc-auto-topup
curl "$URL" -H "Authorization: $(nostr_auth GET "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/nwc-auto-topup"
print(requests.get(url, headers={"Authorization": nostr_auth("GET", url)}).json())
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/nwc-auto-topup";
console.log(await (await fetch(url, { headers: { "Authorization": nostrAuth("GET", url) } })).json());
```

### Disconnecting

`DELETE https://nymbot.ai/api/v1/nwc-auto-topup/connection` — signed.

Deletes the stored connection string. No more top-ups are made. To be sure, you can also revoke the connection in your wallet.

Response

```
{ "data": { "connected": false, "threshold_sats": null, "topup_sats": null, "tier": null, "last_topup_at": null, "last_topup_sats": null, "last_error": null } }
```

cURL

```
URL=https://nymbot.ai/api/v1/nwc-auto-topup/connection
curl -X DELETE "$URL" -H "Authorization: $(nostr_auth DELETE "$URL")"
```

Python

```
import requests

url = "https://nymbot.ai/api/v1/nwc-auto-topup/connection"
print(requests.delete(url, headers={"Authorization": nostr_auth("DELETE", url)}).json())
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/nwc-auto-topup/connection";
const res = await fetch(url, { method: "DELETE", headers: { "Authorization": nostrAuth("DELETE", url) } });
console.log(await res.json());
```

## Paying per request without a key

The fixed-price endpoints can be paid for one request at a time over Lightning, with no key, no account and no balance: `POST /images/generations`, `POST /images/edits`, `POST /videos`, `POST /audio/speech`, `POST /audio/transcriptions`, `POST /audio/translations` and `POST /embeddings`. Chat, Responses and Messages always need a key. A request that carries a key is billed to the balance as usual; the payment flow only starts when no key is sent.

Nymbot speaks two versions of the same idea, from one backend: Lightning Labs' **L402** (also accepted under its old name, `LSAT`) and the IETF draft **Payment** HTTP authentication scheme with the `lightning` method and `charge` intent. Use whichever your client understands.

> **Running your own server**
>
> Paying without a key is on only when `API_L402_SECRET` holds at least 32 random bytes, as hex (64 characters) or base64 (44). Make one with `openssl rand -hex 32`. A shorter or guessable value turns the feature off and logs why. To rotate it, move the old value to `API_L402_SECRET_PREVIOUS` for a day: credentials, status URLs and challenges made under it keep working until they expire.

### The challenge

Send the request with no `Authorization` header. If it is valid, nothing runs, and you get `402 Payment Required` with an invoice for exactly what that request costs: the same price a key would pay, converted at 10 sats a standard credit or 100 sats a Pro credit and rounded up to a whole sat (at least 1 sat, and at least the 0.05 credit minimum). The response carries three `WWW-Authenticate` challenges for the same invoice:

```
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json; charset=utf-8
Cache-Control: no-store
WWW-Authenticate: L402 macaroon="AgJC...", invoice="lnbc2370n1..."
WWW-Authenticate: LSAT macaroon="AgJC...", invoice="lnbc2370n1..."
WWW-Authenticate: Payment id="kM9x...", realm="nymbot", method="lightning", intent="charge",
    request="eyJhbW91bnQiOiIyMzciLC...", description="Nymbot API POST /images/generations (237 sats)",
    digest="sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", expires="2026-09-30T12:15:00.000Z",
    opaque="eyJlbmRwb2ludCI6IlBPU1QgL2ltYWdlcy9nZW5lcmF0aW9ucyJ9"

{
  "type": "https://paymentauth.org/problems/payment-required",
  "title": "Payment Required",
  "status": 402,
  "detail": "This request costs 237 sats. Pay the Lightning invoice, then send the identical request again ...",
  "challengeId": "kM9x...",
  "amount_sats": 237,
  "invoice": "lnbc2370n1...",
  "payment_hash": "9db1370f...",
  "expires_at": "2026-09-30T12:15:00.000Z",
  "error": { "message": "This request costs 237 sats. ...", "type": "payment_required", "code": "payment_required", "param": null }
}
```

The Payment `request` parameter is base64url JSON: `{"amount":"237","currency":"sat","methodDetails":{"invoice":"lnbc...","network":"mainnet","paymentHash":"..."}}`.

A challenge is bound to the endpoint, to the `Content-Type` (its media type and, for multipart, its boundary) and to the SHA-256 of the exact body bytes you sent, and lasts 15 minutes. After paying, send the **identical** request again: the same `Content-Type` and the same JSON bytes, or for the multipart endpoints (`/images/edits`, `/audio/transcriptions`, `/audio/translations`) the same multipart body with the same boundary. Most HTTP libraries pick a new boundary each time they encode a form, so encode it once and send those bytes twice.

Each address can ask for 30 challenges a minute (an IPv6 address counts as its whole /64). Requests whose address is not known share a stricter 10 a minute, and there is an overall cap on the challenges Nymbot issues across all addresses; a request refused before a challenge is made (for example with a body that is not valid JSON) does not count toward it. Beyond that the answer is `429` with `Retry-After`, sent before the body is read. Paid endpoints called without a key or credential also count toward the general limit of 120 unauthenticated requests a minute per address. A credential is checked before the body is read, and a request whose `Content-Type` is not `application/json` (or `multipart/form-data` for uploads) is refused with `415` and never gets an invoice.

Embeddings are priced from an estimate of the tokens in the input, with a 1.5× margin, since the real count is only known afterwards. You pay for the tokens actually used, and the unused part of the payment comes back as a [refund token](#l402-refunds).

### Sending the payment

Pay the invoice with any Lightning wallet. The wallet gives you the preimage, 64 hex characters. Then send the same request with one of these:

| Scheme | Header |
| --- | --- |
| L402 | `Authorization: L402 <macaroon>:<preimage>` (`LSAT` works too) |
| Payment | `Authorization: Payment <base64url JSON>`, where the JSON is `{"challenge": {every parameter of the challenge, as sent}, "payload": {"preimage": "<hex>"}}` |

A paid request answers exactly like one made with a key, except that the `nymbot` object has no balance fields: `{"payment": "l402", "tier": "pro", "paid_sats": 237, "charged_sats": 237}`, and there is no `X-Nymbot-Balance-Sats` header. A request paid with the Payment scheme also gets a `Payment-Receipt` header (base64url JSON with the challenge id, the payment hash as `reference`, `status` and `timestamp`). Paid requests are not tied to any nym, so they do not show up in the query history.

| Status | When |
| --- | --- |
| `402` `payment_already_used` | Every payment pays for one request. The response is a fresh challenge for this request, so a client that caches its last credential (as `lnget` does) simply pays again. |
| `402` `payment_mismatch` | The credential was issued for another endpoint, `Content-Type` or body, or pays less than this request now costs. A fresh challenge for this request comes with it; if the payment was too small, what you paid comes back as a [refund token](#l402-refunds) (`refund_token` and `refund_sats` in the body). |
| `402` `payment_expired` | More than 15 minutes passed since the challenge. A fresh challenge comes with it. If the preimage shows you paid, what you paid comes back as a [refund token](#l402-refunds) (`refund_token` and `refund_sats` in the body), once; the credential is then used up. |
| `401` `invalid_preimage` | The preimage does not hash to the invoice's payment hash. The payment is not used up. |
| `401` `invalid_payment_credential` | The credential is malformed, was changed after Nymbot issued it, or names a payment hash Nymbot never issued an invoice for. A macaroon with a caveat Nymbot does not know, or with conflicting caveats, is refused. |
| `429` `rate_limit_exceeded` | More than 30 credentials or keys that failed to verify came from this address in a minute, or one refund token was sent more than 60 times in a minute. Wait for `Retry-After`. |

cURL

```
BODY='{"model":"nano-banana","prompt":"a lighthouse at dusk","response_format":"b64_json"}'
URL=https://nymbot.ai/api/v1/images/generations

# 1. Get the challenge
CH=$(curl -s -D - -o /dev/null "$URL" -H "Content-Type: application/json" -d "$BODY" | grep -i '^www-authenticate: L402')
MAC=$(echo "$CH" | sed -E 's/.*macaroon="([^"]+)".*/\1/')
INVOICE=$(echo "$CH" | sed -E 's/.*invoice="([^"]+)".*/\1/')

# 2. Pay $INVOICE with your wallet and copy the preimage
PREIMAGE=...

# 3. Send the identical request with the credential
curl "$URL" -H "Content-Type: application/json" -H "Authorization: L402 $MAC:$PREIMAGE" -d "$BODY"
```

lnget

```
# lnget (Lightning Labs) pays L402 challenges from your own lnd node and retries for you
lnget -X POST -H "Content-Type: application/json" \
  -d '{"model":"nano-banana","prompt":"a lighthouse at dusk","response_format":"b64_json"}' \
  https://nymbot.ai/api/v1/images/generations
```

Python

```
import base64, json, re
import requests

url = "https://nymbot.ai/api/v1/images/generations"
body = json.dumps({"model": "nano-banana", "prompt": "a lighthouse at dusk", "response_format": "b64_json"}).encode()
headers = {"Content-Type": "application/json"}

challenge = requests.post(url, data=body, headers=headers)
assert challenge.status_code == 402
info = challenge.json()
print("Pay", info["amount_sats"], "sats:", info["invoice"])

preimage = pay_with_your_wallet(info["invoice"])  # 64 hex characters

# L402
mac = re.search(r'L402 macaroon="([^"]+)"', challenge.headers["WWW-Authenticate"]).group(1)
res = requests.post(url, data=body, headers={**headers, "Authorization": f"L402 {mac}:{preimage}"})
print(res.json()["nymbot"])
```

JavaScript

```
const url = "https://nymbot.ai/api/v1/images/generations";
const body = JSON.stringify({ model: "nano-banana", prompt: "a lighthouse at dusk", response_format: "b64_json" });
const headers = { "Content-Type": "application/json" };

const challenge = await fetch(url, { method: "POST", headers, body });
const www = challenge.headers.get("www-authenticate");

// The Payment scheme: echo every challenge parameter back with the preimage
const start = www.indexOf("Payment ");
const params = Object.fromEntries([...www.slice(start + 8).matchAll(/(\w+)="((?:[^"\\]|\\.)*)"/g)].map((m) => [m[1], m[2]]));
const { invoice } = JSON.parse(Buffer.from(params.request, "base64url").toString()).methodDetails;
const preimage = await payWithYourWallet(invoice);

const credential = Buffer.from(JSON.stringify({ challenge: params, payload: { preimage } })).toString("base64url");
const res = await fetch(url, { method: "POST", headers: { ...headers, Authorization: `Payment ${credential}` }, body });
console.log((await res.json()).nymbot, res.headers.get("payment-receipt"));
```

Clients built on `mppx` with a Lightning method handle the Payment challenge themselves; point them at the endpoint and let them pay.

### Videos

A paid `POST /videos` answers `202` like a keyed one, plus a `status_url`: `GET` it without any key to follow the job. It is signed and works for 24 hours, as long as the job is kept.

```
{
  "id": "vid_...",
  "status": "in_progress",
  "status_url": "https://nymbot.ai/api/v1/videos/vid_...?exp=1790000000&sig=...",
  "nymbot": {
    "payment": "l402", "tier": "pro", "paid_sats": 4800, "charged_sats": 4800,
    "refund_token": "REFUND-5E0B..."
  }
}
```

Keep the `refund_token` from this response: it is shown only here. It is empty while the video renders (`GET /api/v1/l402/refunds` answers `"status": "pending"`); if the render fails unbilled, the payment lands on it. The status URL shows `refund_sats` for a refunded job but never the token, so sharing the status URL does not share the refund. Checking the token also settles a failed job that nobody polled.

### Refunds

If a paid request fails and the provider billed Nymbot for the attempt, the payment is kept and the error says so, with `charged_sats`, exactly as for a keyed request. If it fails without being billed, the error carries a **refund token** worth what you paid:

```
{
  "error": {
    "message": "The image generator failed. Nothing was charged. Please try again. The 237 sats you paid are on refund token REFUND-...",
    "type": "api_error",
    "code": "upstream_error",
    "refund_token": "REFUND-0C15DBED145D59131BA70298A413ADEB3D9B9AB082C476EA70A5BD38FCA5DE6D",
    "refund_sats": 237,
    "refund_expires_at": "2026-10-30T12:00:00.000Z"
  }
}
```

Unused parts come back the same way: if you asked for two pictures and one failed unbilled, the success response's `nymbot` object carries a refund token for the missing one; a transcription whose length could not be read up front is priced for the longest the file could be (never more than 30 minutes), and the difference to the real length comes back as a refund token; if it turns out to be longer than 30 minutes, it is refused with `413` and the whole payment comes back. Embeddings return what the estimate held back. A failed video refunds to the token its submission returned.

A refund token is a random 256-bit code. Nymbot stores only its hash, and it expires after 30 days. It holds a sat balance, and you can:

- **Pay with it.** Send `Authorization: Bearer REFUND-…` on any of the endpoints above (an OpenAI SDK takes it as its API key). The price comes off the token and what is left stays on it (`refund_token_sats` in the `nymbot` object). A token worth less than the request answers `402` `refund_insufficient`; an unbilled failure puts the sats back on the same token.
- **Check it.** `GET /api/v1/l402/refunds` with the same header returns `{"sats": 237, "status": "open", "expires_at": "..."}`.
- One token can be used at most 60 times a minute.
- **Move it to a nym.** Paste it into **Redeem a gift** in the Nymbot app, or call `POST /api/v1/l402/refunds/redeem` with a [signed request](#nip98) and `{"refund_token": "REFUND-...", "balance": "standard"}` (or `"pro"`). Whole credits go to the balance (10 sats each on standard, 100 on Pro); sats that do not make a whole credit stay on the token for API requests.

cURL

```
URL=https://nymbot.ai/api/v1/l402/refunds/redeem
BODY='{"refund_token":"REFUND-...","balance":"standard"}'
curl "$URL" \
  -H "Authorization: $(nostr_auth POST "$URL" "$BODY")" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

Response

```
{ "data": { "credited": 23, "tier": "standard", "balance_credits": 123, "remaining_sats": 7 } }
```
