API reference
crowbot speaks the OpenAI chat-completions dialect. Anything that can talk to OpenAI can talk to crowbot by changing two settings: the base URL and the API key.
base URL https://api.crowbot.sh/v1
auth Authorization: Bearer <your 16-digit account number>Your account number is your API key. There is no separate key to generate. Spaces are accepted, so you can paste it exactly as it was shown to you.
Where things live
Three origins, each serving only its own paths; a path asked of the wrong one is a plain 404.
| origin | serves |
|---|---|
https://api.crowbot.sh |
/v1/*, /api/me and /api/plans — what an agent configures and reads |
https://chat.crowbot.sh |
the web chat, signup, funding (/api/funding, /checkout), plans, pairing |
https://crowbot.sh |
these docs, /llms.txt, the installer |
GET https://api.crowbot.sh/api/me returns site and api, so a client given one origin can find the others.
Endpoints
| method | path | origin | auth | what |
|---|---|---|---|---|
POST |
/v1/chat/completions |
https://api.crowbot.sh | key, paid balance | chat completions — streaming, tool calling, reasoning_effort |
GET |
/v1/models |
https://api.crowbot.sh | none | live models with limits and per-direction rates |
POST |
/v1/tools/codesearch |
https://api.crowbot.sh | key, funded account | run a codesearch tool call server-side; see Code search |
GET |
/api/me |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | balance, spend, request count, key hint, can_spend, the active pass (plan, scope, ends_at, allowance_bp, refills) or null, the balance-fallback switch, and the site/api origins |
POST |
/api/me/balance-fallback |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | body {"on": bool}: whether the balance pays once a plan's allowance is spent |
GET |
/api/me/usage |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | this account's own spend, by model and by day, over days (default 30, 0 is all time): {days, models: [{model, label, requests, prompt_tokens, completion_tokens, cached_tokens, spent_usd}], series: [{day, requests, spent_usd}]} |
GET |
/api/me/usage/log |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | one row per request, paged 50 at a time. page, sort (ts/model/tokens/cost), dir (asc/desc), days. totals covers the whole window, never the page |
GET |
/api/me/payments |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | this account's payments, newest first {payments: [{at, usd, kind}]} |
GET |
/api/me/keys |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | the keys on this account (paired machines) |
POST |
/api/me/keys/{ref}/revoke |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | revoke one key by its ref; the key you are using is refused (409) |
POST |
/api/me/delete |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | close this account for good; body {account_number}; any balance is forfeited |
POST |
/api/pair/start |
https://api.crowbot.sh, https://chat.crowbot.sh | none (per-address cap) | open a pairing: {device_code, user_code, ttl}; body {"hostname"} optional |
GET |
/api/pair/{device_code} |
https://api.crowbot.sh, https://chat.crowbot.sh | none (per-address cap) | poll: 202 pending, 200 {status: ready, api_key} once, 410 expired |
GET |
/pair |
https://chat.crowbot.sh | none | the page a person types the user_code into |
POST |
/pair |
https://chat.crowbot.sh | chat cookie | the same page, showing Approve for a typed code |
POST |
/api/pair/approve |
https://api.crowbot.sh, https://chat.crowbot.sh | chat cookie | approve {user_code} (mints a key for that machine) or {user_code, deny: true} |
POST |
/api/signup |
https://chat.crowbot.sh | none + X-Pow (kind signup) |
mint an account: {account_number, formatted, api_base} |
GET |
/api/pow |
https://chat.crowbot.sh | none for ?kind=signup; key or cookie for chat |
a proof-of-work challenge {challenge, bits, ttl} |
POST |
/api/chat |
https://chat.crowbot.sh | key or chat cookie + X-Pow |
the web chat's own streaming door; agents use /v1/chat/completions |
GET |
/api/models |
https://chat.crowbot.sh | none | the chat picker's model list (same rows as /v1/models) |
POST |
/api/me/billing-portal |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | a session in the processor's portal for the cards it keeps for you {url} |
GET |
/api/live |
https://chat.crowbot.sh | none | server-sent events: each live topic (?topics=tokens) now, then on every change |
GET |
/api/live/{topic} |
https://chat.crowbot.sh, https://crowbot.sh | none | a live topic's latest payload; tokens is {total}, every token served, all time |
POST |
/api/tools/codesearch |
https://chat.crowbot.sh | key, funded account | /v1/tools/codesearch, reachable from the chat origin |
GET |
/api/funding |
https://chat.crowbot.sh | none | what a top-up may be and how to pay for it {min_usd, max_usd, pay_with, rails} |
GET |
/api/plans |
https://api.crowbot.sh, https://chat.crowbot.sh | none, or key or chat cookie | the plans on sale to this account; {plans: []} while none is |
POST |
/api/plans/{plan_id}/buy |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | buy that plan's pass with credit already in the wallet; {ok, pass_id}, 402 when short |
GET |
/api/me/subscription |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | the renewing agreement this account holds, or {subscription: null} |
POST |
/api/me/subscription/cancel |
https://api.crowbot.sh, https://chat.crowbot.sh | key or chat cookie | stop renewing; the pass already paid for runs to its end |
GET |
/checkout |
https://chat.crowbot.sh | key or chat cookie | ?usd=N for credit, ?plan=ID for a pass, &renew=1 for one that renews → {checkout_url} (JSON) or a 303 to the processor (browser) |
GET |
/paid |
https://chat.crowbot.sh | none | where the processor sends a person back after paying |
GET |
/ |
https://chat.crowbot.sh, https://crowbot.sh | none | the landing page on the site origin, the web chat on the chat origin |
GET |
/install.sh |
https://crowbot.sh | none | the opencode installer (bash) |
GET |
/install.ps1 |
https://crowbot.sh | none | the opencode installer (PowerShell, Windows) |
GET |
/llms.txt |
https://crowbot.sh | none | this document |
GET |
/llms-full.txt |
https://crowbot.sh | none | every docs page inlined |
GET |
/docs |
https://crowbot.sh | none | 301 to the first docs page |
GET |
/docs/{name} |
https://crowbot.sh | none | one docs page, rendered for people and search engines |
GET |
/robots.txt |
https://crowbot.sh | none | what search engines may crawl, and where the sitemap is |
GET |
/sitemap.xml |
https://crowbot.sh | none | the public pages, from content/pages.yaml |
GET |
/docs/{name}.md |
https://crowbot.sh | none | one docs page as markdown |
GET |
/docs/opencode.json |
https://api.crowbot.sh, https://crowbot.sh | none | generated opencode provider config |
GET |
/docs/opencode-plugin.js |
https://api.crowbot.sh, https://crowbot.sh | none | the plugin the installer drops in |
POST |
/webhooks/payment |
https://crowbot.sh | processor HMAC | the crypto processor's callback |
POST |
/webhooks/stripe |
https://crowbot.sh | Stripe signature | the card processor's callback |
GET |
/healthz |
every origin | none | liveness, on every host |
GET |
/privacy |
https://crowbot.sh | none | the Privacy page |
GET |
/terms |
https://crowbot.sh | none | the Terms page |
GET |
/refunds |
https://crowbot.sh | none | the Refunds page |
GET |
/why |
https://crowbot.sh | none | the Why it's cheap page |
/v1/chat/completions is a passthrough: your system message is sent to the model unchanged and stays first, and so are your tools, tool_choice and response_format. Your system prompt is never replaced, edited, reordered or moved behind anything of ours.
Some models add a short instruction block of their own alongside it — formatting or refusal style. Your prompt keeps its exact bytes and its position; ours goes after it.
Request
json{
"model": "gpt-6-astra",
"messages": [
{"role": "system", "content": "You are a careful assistant."},
{"role": "user", "content": "Hello"}
],
"max_tokens": 1024,
"stream": true
}An unrecognised model falls back to the default rather than erroring, because agentic clients often send names from other providers.
A model id we do recognise but are not serving returns 404 model_not_found and lists the live ids: falling back there would bill you for a model you did not ask for.
Sampling parameters are forwarded as you send them, including temperature and top_p. Which of them a model honours is up to its vendor.
reasoning_effort we do interpret. Send low, medium, high or max: it overrides the model's configured default, a model without that tier gets its own ceiling (max reaches a vendor with three tiers as high), and a value none can honour is a 400 naming what the model accepts. Reasoning tokens bill as output, so this is a spend control, not a quality slider.
Streaming
Set stream: true and read Server-Sent Events. Chunks are chat.completion.chunk objects terminated by data: [DONE], exactly as OpenAI emits them. Usage arrives on a final chunk with an empty choices array — you do not need to ask for it.
Reasoning models put their thinking trace in choices[].delta.reasoning_content, alongside the usual content. Clients that don't know the field simply ignore it.
Tool calling
Standard OpenAI shape in both directions:
json{
"tools": [{"type": "function", "function": {
"name": "bash",
"description": "Run a shell command",
"parameters": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}
}}],
"tool_choice": "auto"
}Replies carry message.tool_calls[] with function.arguments as a JSON string, and finish_reason: "tool_calls". Return results as {"role": "tool", "tool_call_id": …, "content": …} messages — one per call, as OpenAI expects. Parallel calls work; send every result before the next turn.
Streaming tool calls arrive as delta.tool_calls[] fragments. Concatenate function.arguments per index to rebuild the JSON.
Code search
codesearch runs on our side: give it a query, get matching code from public repositories. No search account of your own.
POST https://api.crowbot.sh/v1/tools/codesearch
Authorization: Bearer <your account number>query is required; lang, repo and count are optional. query may itself carry search filters (repo:, file:, lang:, patterntype:regexp, patterntype:structural, …), which compose with the fields; count is capped.
json{"query": "epoll_wait", "lang": "c", "count": 5}The reply is {"query", "results", "truncated"}, each result carrying repo, path, the first matching line, a url to the source on its own host, and a snippet (for a type:symbol query, the symbol's kind, name and line):
json{"results": [{"repo": "github.com/x/y", "path": "loop.c", "line": 42,
"url": "https://github.com/x/y/blob/<ref>/loop.c#L42",
"snippet": "int n = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);"}],
"truncated": false}Write queries the way code is written: exact identifiers find code, sentences rarely do. lang narrows by language; repo as org/name is anchored to that one repository, which turns a pile of forks into the canonical file. A backend rate limit comes back as 429 rate_limited with Retry-After.
Wiring it into an agent. Register the tool below; when the model calls it, POST the arguments to the endpoint above and feed the reply back as the tool result.
json{
"type": "function",
"function": {
"name": "codesearch",
"description": "Search public source code and get back matching snippets with repo, path and line. Use it when a real implementation answers better than a description. Write the query the way code is written, not the way a question is asked: two or three exact identifiers (function, class, option or constant names) find code; sentences almost never do. Set lang when you know the language. Set repo when you know the project - org/name is enough - which turns a pile of forks into the one canonical file. Quote an exact phrase \"like this\" to match it verbatim; add type:symbol to find where a name is defined rather than used (slower, but finds the defining file even when the canonical repo is not indexed); add patterntype:regexp for a pattern. If nothing comes back, or the repository is reported as not found, drop the repo filter or a term and search again.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Identifiers and exact phrases, optionally with file:/type:symbol/patterntype:regexp filters"
},
"lang": {
"type": "string",
"description": "Language filter, e.g. python, c++, typescript, go"
},
"repo": {
"type": "string",
"description": "Repository: org/name (anchored to that repo) or a regexp over full names like github.com/org/name"
},
"count": {
"type": "integer",
"description": "Maximum results, capped by the server"
}
},
"required": [
"query"
]
}
}
}Snippets bill as ordinary input tokens on your next turn; there is no search fee. Calls are rate-limited. This is the same definition the web chat gives the model.
Errors
Errors are {"error": {"message": …, "type": …}}. The type is the field to branch on — not the status, because a status can be ambiguous.
| status | type | meaning |
|---|---|---|
| 400 | bad_request |
the body was not a JSON object, or a parameter is invalid |
| 400 | bad_amount |
/checkout: not an amount to the cent within min_usd..max_usd |
| 400 | bad_ref |
/api/me/keys/{ref}/revoke: not a key reference |
| 400 | number_mismatch |
/api/me/delete: the account number typed is not this account's |
| 400 | cannot_renew |
/checkout?renew=1: the chosen payment method cannot hold a card to charge again |
| 401 | invalid_api_key |
account number unknown or revoked |
| 403 | account_disabled |
the account is frozen (a card chargeback or refund); it can read its status but not spend or add funds |
| 402 | insufficient_balance |
out of funds, or this request's input alone costs more than the balance — top up |
| 402 | plan_allowance_spent |
the plan's allowance is spent and this account is set not to fall back on its balance |
| 404 | model_not_found |
a model we know but are not serving; the message lists the live ids |
| 404 | not_found |
no such page, key or route on this host |
| 404 | no_such_plan |
the plan named is not on sale to this account |
| 404 | no_subscription |
/api/me/subscription/cancel: this account has no renewing plan |
| 409 | cannot_revoke_current |
that key authenticated this request; revoke it from another |
| 409 | subscription_active |
/api/me/delete: a renewing plan is still active; cancel it first |
| 410 | pair_expired |
the pairing code expired or was already used |
| 413 | body_too_large |
over the 16 MiB request-body ceiling |
| 428 | pow_required |
proof of work missing or expired (chat and signup only, never /v1) |
| 429 | rate_limited |
back off; honour Retry-After when present |
| 502 | tool_error |
a tool (e.g. codesearch) failed upstream; the message says how |
| 502 | checkout_failed |
the payment processor did not answer |
| 502 | cancel_failed |
the payment processor did not answer a cancellation; retry shortly |
| 404 | no_customer |
/api/me/billing-portal: no card is saved yet; pay by card once |
| 502 | portal_failed |
/api/me/billing-portal: the payment processor did not answer; retry shortly |
| 503 | live_full |
/api/live: every live stream is taken; poll /api/live/{topic} and retry after Retry-After |
| 503 | live_warming |
/api/live/{topic}: the topic has not been read since the app started; retry shortly |
| 503 | tool_unavailable |
a tool is not configured or is temporarily off |
| 503 | checkout_unconfigured |
payments are not configured on this deployment |
| 503 | no_model |
nothing is live right now |
| 503 | no_provider |
that model's upstream is not configured |
| 503 | no_price |
a feed-priced model with no quote yet — retry shortly |
| 503 | price_fault |
a feed-priced model whose quote moved past our ceiling; off sale until a sane one lands |
| 503 | upstream_unavailable |
our problem, not yours — try another model |
| vendor's | upstream_error |
the upstream's own reason for refusing, with its Retry-After if it sent one |
Every /v1/chat/completions answer, error or not, carries an X-Request-Id header. Quote it when reporting a failure: it names the one record we keep of that call.
A streamed reply may open with an SSE comment line (:) and carry one during any pause: a keepalive, which every SSE client ignores. A failure after the stream has opened arrives as that same {"error": {"message": …, "type": …}} envelope in a data: frame, followed by data: [DONE].
An upstream vendor's own 401/402/403 is never relayed to you as-is. If our account with a vendor lapses you get a 503 upstream_unavailable: the fault is ours, not your key's or balance's.
Billing
Input, cached input and output bill at separate per-million rates — see Models. Every token sent upstream is billed, and cached input is discounted at the same ratio the upstream gives us.
Two things can pay for a request, and they are asked in a fixed order. A pass — what buying a plan gives you, see Plans — pays first, for any model in its scope. Your balance pays for everything else: a model the pass does not cover, or every request when you hold no pass. The two never mix inside one request; whichever said yes at the door settles it.
A pass does not bill at the rates above. It carries an allowance, sized from what the plan cost, that requests draw down at what the model costs us and that refills continuously at the plan's rate — so it absorbs a burst and comes back over the day. Once the allowance cannot cover a request, that request is the balance's, unless you have said otherwise: POST /api/me/balance-fallback with {"on": false} caps you at the plan, and a request that would then need the balance is refused with 402 plan_allowance_spent and a message saying roughly when the allowance is back. GET /api/me shows the pass while you hold one: allowance_bp is how much of the allowance is left, in basis points of the whole, and refills says roughly when it is full again.
Non-streamed responses carry X-Balance-Microdollars with your remaining paid balance in microdollars (1e-6 USD); a request the pass paid for leaves it where it was. A streamed response does not carry it — headers are sent before the body starts, so the figure would be stale before the first token. Read /api/me after a stream, or use the usage block on the final chunk.
Every request is sized to whatever is paying for it before it is sent upstream: max_tokens (or max_completion_tokens) is capped to what the pass's remaining allowance or the balance can pay for after the estimated input, and a request whose input alone costs more than that is refused with 402 insufficient_balance before any vendor is called. Concurrency follows the payer too — a balance gets as many parallel requests as it covers in worst-case requests, a pass at most the slots its plan sold, one at minimum either way — and past that a 429 rate_limited with Retry-After.
Plans
A plan is one price for a fixed number of days. Buying one gives the account a pass: an allowance that pays first for every model in the plan's scope, the way Billing describes, while the balance goes on paying for everything else. What is on sale is public:
GET https://chat.crowbot.sh/api/plans
Authorization: Bearer <your account number> (optional)The reply is {"plans": [...]}, one entry per plan you may buy:
json{"id": "starter", "label": "Starter", "tagline": "Hard working or hardly working?",
"price_usd": 15, "days": 30, "concurrency": 2, "scope": ["frontier"],
"features": ["Every frontier model", "Refills every day", "Cancel any time"], "badge": null}price_usd buys days days. concurrency is how many requests the pass runs at once; scope names the directions on the Models page it covers (frontier is the resold frontier models, crow our own). features and badge are the card's own words. A pass fixes its allowance in money when it is bought, so nothing on a plan moves when a vendor reprices.
The list is what your account may buy. Signed out, it is whatever is on general sale; while plans are in a closed beta it is empty unless your account is in it. {"plans": []} means nothing is on sale to you, not that the call failed.
Funding, programmatically
The chat UI does all of this with buttons; an agent can do it with four calls, all on https://chat.crowbot.sh except the last.
- Create an account.
GET https://chat.crowbot.sh/api/pow?kind=signupreturns{"challenge", "bits", "ttl"}. Find anonce(≤ 40 chars) such thatsha256("<challenge>:<nonce>")has at leastbitsleading zero bits, thenPOST https://chat.crowbot.sh/api/signupwith headerX-Pow: <challenge>.<nonce>. The reply is{"account_number", "formatted", "api_base"}— the only time the number is ever shown. Each signup from an address raises the next one'sbitsfor a day, and signups are capped per address; a428means the work grew meanwhile: fetch again. - Pick an amount.
GET https://chat.crowbot.sh/api/funding→{"min_usd", "max_usd", "pay_with", "rails"}(rails:[{"name", "label", "renews"}]). Any amount in range is accepted, to the cent (usd=3.50), credited 1:1. - Get an invoice.
GET https://chat.crowbot.sh/checkout?usd=<amount>withAuthorization: Bearer <number>returns{"checkout_url"}(a browserAccept: text/htmlrequest is redirected there instead). Payment is by crypto or card; crypto settles in stablecoin. - Confirm. There is no payment-status endpoint. The processor's callback credits the wallet; poll
GET https://api.crowbot.sh/api/meuntilbalance_microdollarsrises. Balances never expire.
Device pairing and keys
A machine can hold its own key, so a laptop can be revoked later without changing the account number. This is what the installer does; the raw flow:
POST https://chat.crowbot.sh/api/pair/start(optional body{"hostname": "…"}) →{"device_code", "user_code", "ttl"}. Unauthenticated, capped per address.- Show the person the
user_codeand send them tohttps://chat.crowbot.sh/pair, where they type it and press Approve while signed in (programmatically:POST https://chat.crowbot.sh/api/pair/approvewith the chat cookie and{"user_code"}, or{"user_code", "deny": true}). - Poll
GET https://chat.crowbot.sh/api/pair/<device_code>:202 {"status": "pending"}while waiting, then200 {"status": "ready", "api_key"}exactly once, then410 pair_expired. Codes last 15 minutes.
GET /api/me/keys lists the account's live keys with a ref, name, hint, created_at and whether each is the one making the request; POST /api/me/keys/<ref>/revoke revokes one. The key that authenticated the request cannot be revoked (409 cannot_revoke_current): there is no recovery, so that click would be a permanent lockout.