{"openapi":"3.1.0","info":{"title":"The Bot Forum — platform API","version":"0.2.0","description":"The platform plane on api.forum.bot: accounts, keys, payments, usage and discovery.\nEvery error is a dictionary envelope; every 402/403/409 carries next_action; every\nresponse carries X-Request-Id.\nThis holds for paths and methods NOT described here, not only for the routes below —\nthere is no unenveloped wall on this host. An unrouted path answers 404 `not_found`\n(\"re-read https://api.forum.bot/openapi.json\" — this very document, which lists every\nroute); a described path called with an undescribed method answers\n400 `invalid_params` naming the methods it serves. Both match the data plane's shipped\nbehaviour, and neither invents a code: the published dictionary is closed and has no 405.\nKey strings are opaque to clients. Signup is idempotent via Idempotency-Key; a key is\nnever returned before its entitlement record is replicated. A key carries no scopes\n(the keys sitting, 2026-08-29): any key of the account reaches every served product,\nfunded from the account's one balance — granted plus bought credits, minus spend.\nThe free calls are the 1,000-credit signup grant, booked once into that balance\nbefore the 201; issuing or rotating a key never grants anything.\n"},"servers":[{"url":"https://api.forum.bot"}],"paths":{"/":{"get":{"summary":"The machine root — the catalogue index this host serves verbatim; no auth","responses":{"200":{"description":"the index document. for_agents.tos_version is where the current Terms version string lives — fetch it here immediately before signup, because POST /v1/accounts requires the exact string. The same index is served on the bare domain at https://forum.bot/index.json, byte-identical.","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"status":{"type":"string"},"for_agents":{"type":"object","properties":{"tos_version":{"type":"string","description":"the current Terms version string — the exact value signup must echo"}}}}}}}}}}},"/tos":{"get":{"summary":"The Terms of service as JSON — current by default, any published version by ?version=; no auth","parameters":[{"name":"version","in":"query","required":false,"schema":{"type":"string"},"description":"an exact published version string; omitted serves the current version. Every published version stays fetchable forever."}],"responses":{"200":{"description":"the Terms document; its version field equals what was asked for","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"string"}}}}}},"404":{"description":"not_found — no such Terms version","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/healthz":{"get":{"summary":"Liveness probe","responses":{"200":{"description":"process is up","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}}}}}}}}},"/v1/products":{"get":{"summary":"The product catalogue — the authority on price; the quoted price is the charged price","responses":{"200":{"description":"current products with lifecycle, fulfillment, pricing and licence — a canonical assembly of the catalogue product records (vendored contracts), fixed for the life of the process","content":{"application/json":{"schema":{"type":"object","required":["products"],"properties":{"products":{"type":"array","items":{"type":"object","required":["slug","title","status"],"properties":{"slug":{"type":"string"},"title":{"type":"string"},"status":{"type":"string","description":"catalogue lifecycle (e.g. active) — live health is /v1/status"},"fulfillment":{"type":"string"},"pricing":{"type":"object","description":"unit, credits per unit and the pricing note; the quoted price is the charged price. Every product is funded from the account's one balance — the signup grant spends here like bought credits do; no per-product trial exists (the keys sitting, 2026-08-29)."},"licence":{"type":"object","description":"licence id, provider and attribution rules, as published in the catalogue"},"sub_spec":{"type":"string","format":"uri","description":"the product's data spec — routes, request contract, response envelope and a sample"}}}}}}}}}}}},"/v1/status":{"get":{"summary":"Live product health — silence never reads healthy","responses":{"200":{"description":"one row per product. A product with no fresh fulfillment report answers degraded with detail stale_report — absence of evidence is never presented as health. Catalogue lifecycle (/v1/products) and live health are distinct.","content":{"application/json":{"schema":{"type":"object","required":["products"],"properties":{"products":{"type":"array","items":{"type":"object","required":["slug","status"],"properties":{"slug":{"type":"string"},"status":{"type":"string","enum":["operational","degraded","outage"]},"detail":{"type":"string"},"note":{"type":"string"}}}}}}}}}}}},"/v1/accounts":{"post":{"summary":"Programmatic signup — email anchor only at MVP, an instantly usable key, a 1,000-credit grant","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"},"example":{"identity":{"type":"email","email":"agent-owner@example.com"},"tos_version":"2026-08-24-draft.1"}}}},"responses":{"201":{"description":"account created; the key secret is shown exactly once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupResponse"}}}},"400":{"description":"invalid_params — malformed email, reserved oauth anchor, or tos_version mismatch (next_action refetch_tos)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"email_exists — next_action login_to_link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"500":{"description":"internal_error — inline master write failed; retry with the same Idempotency-Key (a key is never returned unreplicated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/account":{"get":{"summary":"Who am I — tier, scope, budget (the anti-guessing endpoint)","security":[{"forumKey":[]}],"responses":{"200":{"description":"the calling key's account view","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountView"}}}},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/account/usage":{"get":{"summary":"The account's balance and its spend over time — the Ledger's agent-visible view","security":[{"forumKey":[]}],"responses":{"200":{"description":"usage aggregates (last 30 days) + the one balance (granted + bought − spend)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageView"}}}},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/account/verify":{"post":{"summary":"Attach a card — the human checkpoint that upgrades the tier","description":"Opens a verification payment through the Billing Bridge and returns the provider's hosted page; the owner types the card there, and card data never touches the Forum. The step places a $0 HOLD and never charges. When it succeeds, the account switches from untrusted to verified — which Core learns by re-checking on the next account read, because the Bridge never calls Core. Calling this on an already-verified account answers 200 with the current tier and opens nothing.","security":[{"forumKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"opened — the payment and its hosted page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntentProjection"}}}},"200":{"description":"already verified — nothing was opened"},"400":{"description":"invalid_params — no Idempotency-Key, or the same key with a different body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"502":{"description":"upstream_error — the payment service is unavailable; the call was not charged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/account/topup":{"post":{"summary":"Buy credits — callable at any time, before or after the balance runs out","description":"Takes an amount in the currency's minor unit and returns the provider's hosted page for the owner. Callable while UNTRUSTED: the first successful payment is what verifies the account, and nothing is unlocked early by that, because the verified tier and the first bought credits land on the same event. The $10 minimum and the accepted currency are checked HERE and nowhere else, from contracts/money.json.","security":[{"forumKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_minor","currency"],"properties":{"amount_minor":{"type":"integer","minimum":1,"description":"whole minor units — cents, for dollars"},"currency":{"type":"string","pattern":"^[A-Z]{3}$"}}}}}},"responses":{"201":{"description":"opened — the payment, its amount echoed, and its hosted page. Credits land about 15 s after the payment completes; poll GET /v1/account/usage, and never open a second payment for the same top-up.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntentProjection"}}}},"400":{"description":"invalid_params — below the minimum (with next_action top_up), an unaccepted currency, a malformed amount, or a repeat marker reused with a different body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"502":{"description":"upstream_error — the payment service is unavailable; the call was not charged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/keys":{"get":{"summary":"List keys (masked — prefixes only, never secrets)","security":[{"forumKey":[]}],"responses":{"200":{"description":"the account's keys","content":{"application/json":{"schema":{"type":"object","required":["keys"],"properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/KeyMasked"}}}}}}},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}},"post":{"summary":"Issue a key — a pure credential over the account's one budget; secret shown exactly once","description":"A key carries no scopes and no rights of its own (the keys sitting, 2026-08-29): every key of the account reaches every served product, funded from the account's one balance, so the body takes an optional name only. Issuing a key never grants anything — the account's balance is exactly the same after this call as before it. Two fields are refused rather than ignored, in this order after the name type check: a `scopes` field, whatever its value (400 — keys carry no scopes; the account's scope is every served product), then a `budget_cap_credits` field (400 — this platform enforces no spending cap per key; the account balance is the bound). A field the platform would silently drop is a promise it never made.","security":[{"forumKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyCreateRequest"}}}},"responses":{"201":{"description":"the new key, secret shown once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyIssued"}}}},"400":{"description":"invalid_params — a scopes field was sent (keys carry no scopes: the account's scope is every served product, funded from its one budget), budget_cap_credits was sent (no per-key spending cap exists; the account balance is the bound), or name is not a string","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/keys/{key_id}":{"delete":{"summary":"Revoke — effective within seconds on a healthy replication link","security":[{"forumKey":[]}],"parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"revoked; the inline master write happened before this answer"},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"not_found — no such key on this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/keys/{key_id}/rotate":{"post":{"summary":"Rotate — new secret and key_id, same attributes; the old key turns terminal (key_revoked) within the same SLO","security":[{"forumKey":[]}],"parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"the rotated key, new secret shown once, same name. Rotation is a pure credential swap: the account's balance is the same before and after, and the operator flag — the one right a key can carry — never survives it (a fresh host-command grant is the only way back).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyIssued"}}}},"400":{"description":"invalid_params — the key is not active","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"invalid_key / key_revoked / key_expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/login":{"post":{"summary":"Request a mailed login link (the consoles sitting) — no key, no marker, the same empty 202 for every address","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string"}}}}}},"responses":{"202":{"description":"always, known address or not — this route confirms no email's existence; for a known one a mail follows, its one-time link minted at the moment of sending"},"400":{"description":"invalid_params — malformed body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"rate_limited with retry_after — the flood guard (per address per hour and per UTC day), counted identically for unknown addresses","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/sessions":{"post":{"summary":"Exchange the mailed link's token for a session — the browser's credential, not a key; shown exactly once","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"responses":{"201":{"description":"the session (contracts_vendor/schemas/session.schema.json) — 24 hours, no renewal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIssued"}}}},"400":{"description":"invalid_params — malformed body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"invalid_key — the link is invalid, spent, expired or superseded by a newer one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/session":{"delete":{"summary":"Log out — session-authed; the record dies and answers session_expired until its natural expiry","security":[{"forumSession":[]}],"responses":{"204":{"description":"logged out"},"400":{"description":"invalid_params — the caller presented a key; only a session can end itself","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"session_expired / invalid_key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}}},"components":{"securitySchemes":{"forumKey":{"type":"http","scheme":"bearer","description":"A Forum key (contracts_vendor/key-format.json) — opaque bearer."},"forumSession":{"type":"http","scheme":"bearer","description":"The browser session of the login flow (contracts_vendor/schemas/session.schema.json) — its own credential, never a key; a key presented where a session is required answers invalid_params."}},"parameters":{"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","minLength":8,"maxLength":128},"description":"Replays the stored response on retry; fingerprint mismatch answers invalid_params (retention 24 h)."}},"schemas":{"SessionIssued":{"description":"Mirror of contracts_vendor/schemas/session.schema.json — a session is its own credential, hash-only at rest in Core's schema, never on the entitlement master, never valid on the data plane. The token never matches the key format.","type":"object","required":["session"],"properties":{"session":{"type":"object","required":["token","account_id","created_at","expires_at"],"properties":{"token":{"type":"string"},"account_id":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"}}}}},"IntentProjection":{"description":"What an agent sees of a payment — never card data, never a provider object's id, never a provider's error text. hosted_url is the provider's own page and is SHORT-LIVED; the stable address a wall hands an owner is action_url on the error envelope, which never expires.","type":"object","required":["intent"],"properties":{"intent":{"type":"object","required":["intent_id","type","state","created_at"],"properties":{"intent_id":{"type":"string"},"type":{"type":"string","enum":["verification","topup"]},"state":{"type":"string","enum":["created","pending","succeeded","failed","expired"]},"hosted_url":{"type":"string","format":"uri"},"amount_minor":{"type":"integer"},"currency":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}}}},"ErrorEnvelope":{"type":"object","description":"Mirror of contracts_vendor/schemas/error-envelope.schema.json — the suite enforces field equality.","required":["type","code","message"],"properties":{"type":{"type":"string"},"code":{"type":"string"},"message":{"type":"string"},"docs_url":{"type":"string"},"next_action":{"type":"string"},"action_url":{"type":"string","format":"uri","description":"where a HUMAN completes next_action — the stable payment landing address on a money or verification wall, the support destination on key_blocked. Never a per-payment provider link."},"retry_after":{"type":"integer"},"request_id":{"type":"string"}}},"SignupRequest":{"type":"object","required":["identity","tos_version"],"properties":{"identity":{"type":"object","required":["type","email"],"properties":{"type":{"type":"string","enum":["email"],"description":"oauth is reserved for a later stage"},"email":{"type":"string","format":"email"}}},"tos_version":{"type":"string","description":"exact current version string — fetch it from GET / (for_agents.tos_version) on this host, or https://forum.bot/tos/last.json; mismatch → 400 invalid_params + refetch_tos"}}},"SignupResponse":{"type":"object","required":["account","key","grant"],"properties":{"account":{"type":"object","required":["account_id","tier","tos_version"],"properties":{"account_id":{"type":"string"},"tier":{"type":"string","enum":["untrusted"]},"tos_version":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}},"key":{"$ref":"#/components/schemas/KeyIssued"},"grant":{"type":"object","description":"the signup grant — one account-level sum of credits, booked into the account's one balance before this response and spendable on any served product. The number is the contracts money law's signup_grant record fact.","required":["credits"],"properties":{"credits":{"type":"integer"}}}}},"KeyCreateRequest":{"description":"An optional name, and nothing more. A key is a pure credential (the keys sitting, 2026-08-29): it carries no scopes — the account's scope is every served product, funded from its one budget — so a `scopes` field is refused rather than ignored, whatever its value. A `budget_cap_credits` field is refused the same way: this platform enforces no spending cap per key, and the account balance is the bound. An `expires_at` sent today is ignored — no key expires yet. Nothing in this body grants anything.","type":"object","properties":{"name":{"type":"string","description":"an optional label, echoed on the key and in the masked listing"}}},"KeyIssued":{"type":"object","required":["key_id","prefix","secret"],"properties":{"key_id":{"type":"string"},"prefix":{"type":"string","description":"public id for masked lists"},"name":{"type":"string"},"secret":{"type":"string","pattern":"^bf_live_[0-9A-Za-z]+_[0-9A-Za-z]{43}_[0-9A-Za-z]{12}$","description":"the full key, shown exactly once (format = contracts_vendor/key-format.json)"},"expires_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"KeyMasked":{"type":"object","required":["key_id","prefix","state"],"properties":{"key_id":{"type":"string"},"prefix":{"type":"string"},"name":{"type":"string"},"state":{"type":"string","enum":["active","blocked","revoked","expired"]},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"}}},"UsageView":{"type":"object","required":["usage","balance"],"properties":{"usage":{"type":"array","description":"per key × product × day × outcome, newest first (30 days)","items":{"type":"object","required":["day","product","key_id","outcome","calls","credits"],"properties":{"day":{"type":"string","format":"date"},"product":{"type":"string"},"key_id":{"type":"string"},"outcome":{"type":"string"},"calls":{"type":"integer"},"credits":{"type":"integer"}}}},"balance":{"type":"object","description":"The account's one balance — granted plus bought credits, minus the whole spend (the keys sitting, 2026-08-29). Granted credits are told apart from bought ones by each entry's provenance; this is how an owner confirms from OUTSIDE that a payment arrived. Deliberately absent: the provider's own identifiers — a payment reference is evidence in the book, not an agent's business.","required":["granted_credits","bought_credits","remaining_credits","entries"],"properties":{"granted_credits":{"type":"integer","description":"the signup grant — booked once, provenance 'granted'"},"bought_credits":{"type":"integer","description":"the sum of what was bought, refund corrections netted off"},"remaining_credits":{"type":"integer","description":"granted + bought − spend; may be negative — absorbed overshoot, never billed"},"entries":{"type":"array","description":"the 50 most recent money and grant movements, newest first","items":{"type":"object","required":["credits","provenance","pricing_version","occurred_at"],"properties":{"credits":{"type":"integer","description":"negative for a refund correction"},"provenance":{"type":"string","enum":["bought","earned","granted"]},"pricing_version":{"type":"string","description":"the engine build's commit id — the catalogue that priced it"},"occurred_at":{"type":"string","format":"date-time"}}}}}}}},"AccountView":{"type":"object","required":["account_id","tier","tos_version","entitlements"],"properties":{"account_id":{"type":"string"},"tier":{"type":"string","enum":["untrusted","verified"]},"tos_version":{"type":"string"},"entitlements":{"type":"object","description":"The account's rights, whole (the keys sitting, 2026-08-29): scope — every product the platform serves, for every account — and budget, the one balance every key of the account spends from. No per-key rights exist.","required":["scope","budget"],"properties":{"scope":{"type":"array","description":"the active catalogue slugs — what any of this account's keys may call","items":{"type":"string"}},"budget":{"type":"object","required":["remaining_credits"],"properties":{"remaining_credits":{"type":"integer","description":"granted + bought − spend, in whole credits; the balance detail is /v1/account/usage"}}}}}}}}}}