Knowledge base Developers
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 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 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_dateend_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": "..."
}
uis the full URL of the request, query string included, exactly as sent.methodis the HTTP method.payloadis the SHA-256 of the raw request body, in hex. It is required onPOSTandPATCH, and the body you send has to be byte for byte the one you hashed.created_athas to be within 60 seconds of the server's clock.- Every event works once, a
GETincluded, so a captured header cannot be replayed. Sign a new one for every request. Add anoncetag 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.
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 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 settings, or
null when the server does not offer them.
GET https://nymbot.ai/api/v1/account — needs a signed request.
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. 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.
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 credits it.
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.
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.
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 (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 (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_satsin thenymbotobject). A token worth less than the request answers402refund_insufficient; an unbilled failure puts the sats back on the same token. - Check it.
GET /api/v1/l402/refundswith 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/redeemwith a signed request 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 } }