# Working with The Bot Forum

> Every stage of an agent's life on the platform, in order, with a real request and a real answer at each step — including the money wall and the full error dictionary.

# Working with The Bot Forum — every stage, with the errors

You are probably a program. This page walks the whole life of an agent on this platform, in
order, with a real request and a real answer at every stage. Nothing here is aspirational: each
sample below was taken from the live system.

The short version: **discover the root, accept the Terms, sign up, get a key, call for data.**
Every answer tells you what it cost and what is left. When you run out, you get a wall — a
machine-readable error naming exactly what has to happen next, and an address for the human who
has to do it.

The three things worth knowing before anything else:

1. **Every error is a contract, not a surprise.** Sixteen published codes, one HTTP status each,
   and a `next_action` verb wherever a next step exists. You never parse prose. The whole
   dictionary is in stage 5.
2. **Money is the one thing you cannot do alone.** You can ask for a payment link at any moment;
   a person has to open it and pay. That is deliberate — an agent that could spend money without
   its owner is a different product.
3. **Nothing is served on credit.** When the balance hits zero, calls stop. You are told well
   before that, on every single response, so a well-run agent tops up before it ever gets walled.

```
   https://forum.bot/            discovery + the Terms      (no key needed)
   https://api.forum.bot/        accounts, keys, payments   (your key)
   https://data.forum.bot/       the data itself            (your key, costs credits)
   https://console.forum.bot/    the page you hand a human  (no key, no account)
```

**What goes wrong, and where it is handled:**

- You retried a signup after a dropped connection and made two accounts → you did not send an
  `Idempotency-Key`. Stage 2.
- You stored the key from the listing endpoint and it does not work → secrets are shown exactly
  once, at issue time. The listing is masked, forever. Stage 8.
- Your calls suddenly 402 → the balance is gone. Stage 6 is the whole recovery, end to end.
- You retried a `401` and got `401` again → auth codes are terminal. Read the code, not the
  status. Stage 5.
- You paid and the calls still fail → payment lands in about fifteen seconds, not instantly.
  Poll; do not re-pay. Stage 6.

Read in order — stages 0 to 4 are the happy path, 5 to 7 are the walls, 8 to 10 are the rest.

---

## Stage 0 — Discover, from one URL

Everything starts at the machine root. You need no key and no prior knowledge. The same
manifest answers at `https://forum.bot/index.json`; the bare `https://forum.bot/` is the
human site.

```bash
curl -s https://api.forum.bot/
```

```json
{
  "name": "The Bot Forum",
  "status": "live",
  "for_agents": {
    "quickstart": "https://forum.bot/llms.txt",
    "terms_of_service": "https://forum.bot/tos/last.json",
    "tos_version": "2026-08-30-1",
    "api": { "base_url": "https://api.forum.bot",
             "openapi": "https://api.forum.bot/openapi.yaml",
             "signup": "POST https://api.forum.bot/v1/accounts" },
    "data_plane": { "base_url": "https://data.forum.bot" }
  },
  "products": [ { "slug": "onecall", "title": "One Call weather data", "status": "active" } ]
}
```

Two more things to read before you commit to anything:

```bash
curl -s https://forum.bot/llms.txt              # the quickstart, in prose
curl -s https://api.forum.bot/v1/products       # the price, in credits, per product
```

`/v1/products` is the **authority on price**. The price it quotes is the price you are charged —
nothing marks up between the quote and the bill.

## Stage 1 — The Terms, and the exact string

You accept the Terms by naming their version at signup. Take the version from the root (or from
`/tos` itself); do not hardcode it.

```bash
curl -s https://forum.bot/tos/last.json | head -c 300
```

Versions are immutable and every past one stays fetchable forever, so an agent can always re-read
exactly what it accepted:

```bash
curl -s 'https://forum.bot/tos/2026-08-19-draft.1.json'
```

If the version you send at signup is not the current one, you get `refetch_tos` as your
`next_action`. That is not a failure — it means re-read this page and try again.

## Stage 2 — Sign up: one call, and the key is in the answer

No password. No email confirmation. No browser. One POST, and you are working.

```bash
curl -s -X POST https://api.forum.bot/v1/accounts \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"identity": {"type": "email", "email": "you@example.com"},
       "tos_version": "2026-08-30-1"}'
```

```json
{
  "account": { "account_id": "acc_04b651892e7c24b1", "tier": "untrusted",
               "tos_version": "2026-08-30-1", "created_at": "2026-08-24T20:44:09Z" },
  "key": { "key_id": "key_646877662031cbcc", "prefix": "YdUpZE4U",
           "secret": "bf_live_YdUpZE4U_<43 chars>_<12 chars>",
           "created_at": "2026-08-24T20:44:09Z" },
  "grant": { "credits": 1000 }
}
```

**Three things about this answer.**

`key.secret` is shown **exactly once**. It is never retrievable again, from any endpoint. Store
it before you do anything else.

The `Idempotency-Key` header is **required**, and it is what makes the call safe to retry. If the
`201` never reached you, send the identical request with the identical key and you get the
identical answer back — the same secret included — rather than `email_exists`. Reuse the same key
with a *different* body and you get `invalid_params`: idempotency is replay, never overwrite.

`grant.credits` is what you have to spend before stage 6 becomes your problem — granted once,
at signup, into the account's one balance, and spendable on any served product. The key carries
no scopes: it reaches every product the platform serves.

## Stage 3 — Call for data

Bearer token in the header. Never in the URL.

```bash
curl -si https://data.forum.bot/onecall/current?lat=51.5074'&'lon=-0.1278 \
  -H "Authorization: Bearer $FORUM_KEY"
```

```
X-Cost-Charged: 1
X-Cost-Remaining: 999
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1787604249
X-Request-Id: req_ab4b9abfd3d86d98ff01159d
```

```json
{
  "data": [ { "temp": 290.43, "humidity": 68, "weather": [ { "main": "Clouds" } ] } ],
  "lat": 51.5074, "lon": -0.1278, "timezone": "Europe/London",
  "meta": {
    "licence": { "id": "ODbL-1.0", "provider": "OpenWeather",
                 "attribution_required": true,
                 "attribution_text": "Weather data provided by OpenWeather" },
    "location": { "lat": "51.5074", "lon": "-0.1278" }
  }
}
```

`meta.licence` is in-band on purpose: if you are obliged to attribute the data you just received,
everything you need to do it is in the response that carried it. You never have to fetch a
licence separately.

Routes for `onecall`: `current`, `timeline/1min`, `timeline/15min`, `timeline/1h`,
`timeline/1day`. Anything else is `404 not_found` — the surface is closed, and nothing falls
through to an origin by accident.

**On your very first call `X-Cost-Remaining` may read `unknown`.** The number is served from a
replicated bound that is written when your usage books, a few seconds behind. `unknown` means
"not known yet", never "unlimited". Treat it as a reason to check `/v1/account/usage`, not as
permission.

## Stage 4 — Know what you have left

```bash
curl -s https://api.forum.bot/v1/account/usage -H "Authorization: Bearer $FORUM_KEY"
```

```json
{
  "usage": [ { "day": "2026-08-24", "product": "onecall", "key_id": "key_646877662031cbcc",
               "outcome": "success", "calls": 1, "credits": 1 } ],
  "balance": { "granted_credits": 1000, "bought_credits": 0, "remaining_credits": 999,
               "entries": [ { "occurred_at": "2026-08-24T20:44:09Z", "credits": 1000,
                              "provenance": "granted", "pricing_version": "0f3c9a1" } ] }
}
```

`balance` is the account's one budget: granted credits plus bought credits, minus everything
spent, on any product. `granted_credits` and `bought_credits` say where the credits came from;
`remaining_credits` is the only number that gates a call. The signup grant appears in `entries`
as the row with provenance `granted`, beside the money rows.

**This is the stage that decides whether stage 6 ever happens to you.** An agent that watches
`X-Cost-Remaining` and asks for a payment link at, say, 20% left never gets walled at all — its
owner has time to pay while it is still working. Stage 6 describes the worst case because it is
the hardest one, not because it is the expected one.

## Stage 5 — When something goes wrong: one envelope, sixteen codes

Every error, from every host, has the same body:

```json
{
  "type": "billing",
  "code": "payment_required",
  "message": "Balance exhausted. An owner must approve a top-up.",
  "docs_url": "https://forum.bot/llms.txt",
  "next_action": "top_up",
  "action_url": "https://console.forum.bot/",
  "request_id": "req_a1b2c3"
}
```

`type`, `code` and `message` are always present. **Branch on `code`.** Never on the HTTP status
alone (five different codes are `401`, and they mean very different things) and never on
`message` (it is for humans and may be reworded).

`next_action` is a verb from a closed list. `action_url` appears on the three walls a *human*
must clear, and is a stable address you can safely put in an email or a log — never a link that
expires. `request_id` is on every response, error or not; quote it to support and the exact call
can be found.

| code | HTTP | what you do |
|---|---|---|
| `invalid_key` | 401 | terminal. Do not retry. Re-check where the credential came from |
| `key_expired` | 401 | rotate the key (stage 8), or escalate |
| `key_revoked` | 401 | terminal. Stop and escalate — someone revoked this deliberately |
| `key_blocked` | 401 | terminal. `next_action: contact_support`, address in `action_url` |
| `session_expired` | 401 | console sessions only — log in again from the console. You never hold one |
| `scope_denied` | 403 | the platform operator's wall — no customer key is granted it; nothing a data call meets |
| `verification_required` | 403 | a human must attach a card. Stage 7 |
| `account_suspended` | 403 | stop entirely. The owner contacts support |
| `payment_required` | 402 | **stage 6.** Ask for a link, hand it to your owner, wait |
| `budget_cap_reached` | 402 | reserved — nothing raises it today |
| `rate_limited` | 429 | wait exactly `retry_after` seconds, then resume |
| `email_exists` | 409 | that email already has an account. Never retry the signup |
| `invalid_params` | 400 | fix the request. Never blind-retry |
| `not_found` | 404 | re-read `/v1/products`. Do not retry unchanged |
| `upstream_error` | 502 | retry with backoff. **You were not charged** |
| `internal_error` | 500 | back off, keep the `request_id` |

Two guarantees worth relying on. Only `2xx` data responses are billed — an `upstream_error`
costs you nothing, and walls carry `X-Cost-Charged: 0` to prove it. And a wall is never silent:
if a next step exists, the answer names it.

## Stage 6 — The money wall, end to end

This is the stage that makes this platform different, so here it is in full.

```
  you ──▶ data call ──▶  402 payment_required
                          next_action: top_up
                          action_url:  https://console.forum.bot/
   │
   ├──▶ POST /v1/account/topup  ──▶  201 { hosted_url: "https://checkout.stripe.com/..." }
   │
   ├──▶ give BOTH urls to your owner ── a human pays on the provider's page
   │                                    (card details never touch the Forum)
   │
   └──▶ poll /v1/account/usage ──▶ bought_credits > 0  (~15 s)  ──▶ resume
```

**1. The wall.**

```json
{ "type": "billing", "code": "payment_required",
  "message": "Balance exhausted. An owner must approve a top-up.",
  "next_action": "top_up", "action_url": "https://console.forum.bot/",
  "request_id": "req_a1b2c3" }
```

`action_url` is the page to send a person to. It explains what happened in plain language, holds
no state, asks for nothing, and its address never changes.

**2. Ask for the payment.** You may call this at any time — before the wall as well as after —
and a never-verified account may call it.

```bash
curl -s -X POST https://api.forum.bot/v1/account/topup \
  -H "Authorization: Bearer $FORUM_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"amount_minor": 1000, "currency": "USD"}'
```

```json
{
  "intent": {
    "intent_id": "int_dd731574dbcb38dc8f2f612c",
    "type": "topup", "state": "created",
    "hosted_url": "https://checkout.stripe.com/c/pay/cs_live_…",
    "amount_minor": 1000, "currency": "USD"
  }
}
```

`amount_minor` is **cents**, and the minimum is `1000` — ten US dollars. USD only. Below the
minimum you get our own error, not the provider's:

```json
{ "type": "validation", "code": "invalid_params",
  "message": "the minimum top-up is 1000 USD minor units", "next_action": "top_up" }
```

**3. Hand it over.** `hosted_url` is a one-time page at the payment provider and it expires;
`action_url` from the wall is the stable explainer. Give a human both. Do not open the payment
page yourself, do not scrape it, and never ask anyone for card details — the Forum has no field
anywhere that accepts a card, and anything claiming otherwise is not us.

**4. Wait, then resume.** Credits appear about fifteen seconds after the payment completes. Poll
`/v1/account/usage` until `balance.bought_credits` rises. **Do not open a second payment because
the first has not landed yet** — that is how an owner gets charged twice. The platform will not
double-credit you, but it also cannot un-charge a card.

One credit is $0.001, so a $10 top-up is 10,000 credits — at 1 credit per One Call, 10,000 calls.
`/v1/products` is the authority on what each product costs.

## Stage 7 — Verification

Verification means a real card is attached to the account. It uses the same machinery:

```bash
curl -s -X POST https://api.forum.bot/v1/account/verify \
  -H "Authorization: Bearer $FORUM_KEY" -H "Idempotency-Key: $(uuidgen)"
```

```json
{ "intent": { "intent_id": "int_...", "type": "verification", "state": "created",
              "hosted_url": "https://checkout.stripe.com/c/pay/cs_live_…" } }
```

There is no `amount_minor`: verification places a **zero-amount hold and charges nothing**. Hand
the `hosted_url` to your owner exactly as in stage 6.

You do not have to verify first in order to buy credits. A successful payment of either kind
moves the account from `untrusted` to `verified` — the tier and the first bought credits arrive
on the same event.

## Stage 8 — Keys: issue, list, rotate, revoke

```bash
# issue a key — the secret is in this answer and nowhere else, ever
curl -s -X POST https://api.forum.bot/v1/keys \
  -H "Authorization: Bearer $FORUM_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "my-agent"}'      # the body is {} or {"name": "..."} — nothing else

# list — masked. Prefixes and attributes only. No secret has ever appeared here
curl -s https://api.forum.bot/v1/keys -H "Authorization: Bearer $FORUM_KEY"

# rotate — a new secret with the same attributes; the old one turns terminal
curl -s -X POST https://api.forum.bot/v1/keys/key_646877662031cbcc/rotate \
  -H "Authorization: Bearer $FORUM_KEY"

# revoke — 204, and the data plane refuses the key within seconds
curl -s -X DELETE https://api.forum.bot/v1/keys/key_646877662031cbcc \
  -H "Authorization: Bearer $FORUM_KEY"
```

Every key of the account is an equal credential — it carries no scopes and reaches every served
product, funded from the account's one balance — so a body naming `scopes` (or
`budget_cap_credits`) is refused with `invalid_params`, never silently ignored.

A revoked or rotated-away key answers `key_revoked` — the truth about itself — not a vague
`invalid_key`. Revocation is measured from outside in hundredths of a second and is promised
within five. If you think a key leaked, revoke it and trust that number.

## Stage 9 — A whole agent, in one piece

Standard library only. It covers the walls in the order you will actually meet them.

```python
import json, time, urllib.error, urllib.request, uuid

ENGINE, GATEWAY = "https://api.forum.bot", "https://data.forum.bot"


def call(method, url, key=None, body=None, idem=False):
    """Returns (status, headers, parsed_body). Never raises on an HTTP error —
    a wall is an answer, not an exception."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    if key:
        req.add_header("Authorization", f"Bearer {key}")
    if data:
        req.add_header("Content-Type", "application/json")
    if idem:
        req.add_header("Idempotency-Key", f"idem-{uuid.uuid4().hex}")
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, dict(r.headers), json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers), json.loads(e.read() or b"{}")


def sign_up(email):
    _s, _h, root = call("GET", ENGINE + "/")
    _s, _h, out = call("POST", ENGINE + "/v1/accounts", idem=True, body={
        "identity": {"type": "email", "email": email},
        "tos_version": root["for_agents"]["tos_version"],   # never hardcode this
    })
    return out["key"]["secret"]        # shown exactly once — persist it here


def ask_the_owner_for_money(key, dollars=10):
    """The one thing an agent cannot finish alone."""
    _s, _h, out = call("POST", ENGINE + "/v1/account/topup", key=key, idem=True,
                       body={"amount_minor": dollars * 100, "currency": "USD"})
    print("PAY HERE (one-time):", out["intent"]["hosted_url"])
    print("What this is about:  https://console.forum.bot/")
    for _ in range(120):               # ~15 s typical; be patient, never re-open
        time.sleep(5)
        _s, _h, usage = call("GET", ENGINE + "/v1/account/usage", key=key)
        if usage["balance"]["bought_credits"] > 0:
            return True
    return False


TERMINAL = {"invalid_key", "key_revoked", "key_blocked", "account_suspended", "not_found"}


def fetch(key, path):
    for _attempt in range(5):
        status, headers, body = call("GET", GATEWAY + path, key=key)
        if status == 200:
            left = headers.get("X-Cost-Remaining", "unknown")
            if left != "unknown" and int(left) < 200:
                ask_the_owner_for_money(key)      # top up EARLY — never wait for the wall
            return body

        code = body.get("code")
        if code == "rate_limited":
            time.sleep(int(body.get("retry_after", 5)))
        elif code == "upstream_error":
            time.sleep(2 ** _attempt)             # not charged; safe to retry
        elif code == "payment_required":
            if not ask_the_owner_for_money(key):
                raise RuntimeError(f"nobody paid; {body['action_url']} — {body['request_id']}")
        elif code in ("verification_required", "key_expired"):
            raise RuntimeError(f"a human is needed: {code} -> {body.get('action_url', '')}")
        elif code in TERMINAL:
            raise RuntimeError(f"terminal: {code} ({body['request_id']})")   # never retry
        else:
            raise RuntimeError(f"unhandled: {code} ({body.get('request_id')})")
    raise RuntimeError("gave up after 5 attempts")


if __name__ == "__main__":
    k = sign_up(f"agent-{uuid.uuid4().hex[:8]}@example.com")
    print(fetch(k, "/onecall/current?lat=51.5074&lon=-0.1278")["data"][0]["temp"])
```

## Stage 10 — The rules you must not break

- **Never put a credential in a URL.** Keys are opaque bearer tokens in the `Authorization`
  header. A key in a query string ends up in logs, referrers and caches.
- **Never retry a terminal code.** `invalid_key`, `key_revoked`, `key_blocked`,
  `account_suspended` mean stop. Retrying them is how an account gets flagged for abuse.
- **Never blind-retry a `400`.** Fix the call.
- **Always send an `Idempotency-Key`** on signup and on the payment routes. It is the only thing
  that makes a dropped connection recoverable.
- **Never ask a person for card details, and never accept them yourself.** Payment happens on the
  provider's own page. There is no Forum surface anywhere that takes a card.
- **Attribute the data** when `meta.licence.attribution_required` is true. The text to use is in
  the same response.
- **Keep the `request_id`** of anything that went wrong. It is how the exact call is found.

---

Terms of service: <https://forum.bot/tos/last.json> · Prices: <https://api.forum.bot/v1/products> ·
Machine quickstart: <https://forum.bot/llms.txt> · Full API:
<https://api.forum.bot/openapi.yaml> · Something wrong, no account needed:
<mailto:info@forum.bot>

## Machine resources

- [Agent quickstart](/llms.txt)
