Skip to the content
Back to Nymbot

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"
  }
}
StatusWhen
401The 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.

FieldTypeRequiredDescription
amountnumberYesHow much, in currency. A whole number for sats.
currencystringNoSATS (the default), USD or BTC. Dollars are converted at the current Bitcoin price.
tierstringNopro (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"
}
StatusWhen
400Another 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).
429More than 60 invoices for this nym, or 120 from this address, in an hour (rate_limit_exceeded, with Retry-After).
502No 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
}
StatusWhen
400The id is not the 64-character id from the create call.
404No 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.

FieldTypeRequiredDescription
pageinteger (query)NoDefault 1, at most 1,000; a higher page is 400 invalid_value.
page_countinteger (query)NoRows per page. Default 20, at most 100.
start_date
end_date
string (query)NoISO 8601 dates or times.
modelstring (query)NoOnly this model.
typestring (query)Nochat, responses, messages, image, video, speech, transcription or embedding.
all_keysboolean (query)NoWith a key: true includes every key of the same nym. Default false.
key_idstring (query)NoWith 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 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.

FieldTypeRequiredDescription
include_revokedboolean (query)NoInclude 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.

FieldTypeRequiredDescription
namestringYes1 to 40 characters, different from your other active keys (ignoring case).
limit_satsintegerNoThe spending cap in sats, at least 1. Leave it out for no cap.
reset_periodstringNodaily, weekly or monthly. Needs limit_sats. Leave it out for a cap that never resets.
expire_atstring or integerNoWhen 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"
  }
}
StatusWhen
400A 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).
429More 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.

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.

FieldTypeRequiredDescription
nwc_urlstringYesThe connection string, starting nostr+walletconnect://. Nymbot asks the wallet for get_info before saving it, and stores it encrypted.
threshold_satsintegerYesTop up when the balance falls below this many sats. At least 1,000.
topup_satsintegerYesHow much to add each time. 1,000 to 1,000,000 sats.
tierstringNopro (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
  }
}
StatusWhen
400Not 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.
501Automatic 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.

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:

SchemeHeader
L402Authorization: L402 <macaroon>:<preimage> (LSAT works too)
PaymentAuthorization: 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.

StatusWhen
402 payment_already_usedEvery 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_mismatchThe 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_expiredMore 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_preimageThe preimage does not hash to the invoice's payment hash. The payment is not used up.
401 invalid_payment_credentialThe 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_exceededMore 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 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 } }