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 |
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}]}, a day without usage as zeros |
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, paged 50 at a time (page): {payments: [{at, usd, kind, plan_id, credited}], page, pages, total, totals: {credited, credited_usd}}; plan_id names the plan a settled payment bought, else null; totals covers every payment, never the page |
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, interval}; body {"hostname", "client"} 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 rows, coming-soon ones included with their status (/v1/models is live only): {models, default, efforts, lines, pieces, uploads: {per_message, file_max_bytes, max_px, picture_max_bytes}, vision: {images_max, step, max_px}}; default is the default model's id, efforts the reasoning_effort rungs, lines each line's label by id, pieces each media tool's caps and its failed words by tool name |
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 topic named (?topics=tokens,news, required) 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, rate}: every token served, all time, and tokens a second over the last minute |
POST |
/api/tools/generate_image |
https://chat.crowbot.sh | key + X-Pow |
the web chat's image tool: queues {job, price_micros, retry_ms}, billed per picture made when done; not offered on /v1 |
GET |
/api/tools/generate_image/{job} |
https://chat.crowbot.sh | key | a picture request's {status, retry_ms}, with charged_microdollars and made once done or message once failed |
GET |
/api/tools/generate_image/{job}/image/{k} |
https://chat.crowbot.sh | key | the k-th finished picture (from 0), fetched for each download and never stored; 410 media_gone once the vendor has dropped it |
POST |
/api/tools/generate_video/quote |
https://chat.crowbot.sh | key | the web chat's video tool, priced: {price_micros, funded_by, payable, seconds, aspect, resolution} for the submit's body; nothing is held |
POST |
/api/tools/generate_video |
https://chat.crowbot.sh | key + X-Pow |
the web chat's video tool: a submit with expect_micros queues {job}, billed by the second when done; not offered on /v1 |
GET |
/api/tools/generate_video/{job} |
https://chat.crowbot.sh | key | a clip's {status, retry_ms}, with charged_microdollars once done or message once failed: submitting, queued, rendering, done, failed or expired |
GET |
/api/tools/generate_video/{job}/video |
https://chat.crowbot.sh | key | a finished clip's mp4, fetched for each download and never stored; 410 media_gone once the vendor has dropped it |
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, wallet}; wallet ({name, kind}) is the row a plan the balance covers is paid from |
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 |
/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/site/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 |
POST |
/webhooks/{hook} |
https://crowbot.sh | the rail's own signature | a payment rail's callback, at the tail it declares |
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 |
/acceptable-use |
https://crowbot.sh | none | the Acceptable use page |
GET |
/refunds |
https://crowbot.sh | none | the Refunds page |
GET |
/why |
https://crowbot.sh | none | the Why it's cheap page |
GET |
/impressum |
https://crowbot.sh | none | the Impressum page |
GET |
/claims |
https://crowbot.sh | none | the Claims page |
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/opencode-plugin.js |
https://api.crowbot.sh, https://crowbot.sh | none | the plugin the installer drops in |
/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": "glm-5.3-flash-crow",
"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, xhigh or max: it overrides the model's configured default, any model takes any of them, and one without that tier gets its highest below it (xhigh and max reach a vendor with three tiers as high). Any other value is a 400 naming these. 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.
Errors
Errors are {"error": {"message": …, "type": …}}. The type is the field to branch on — not the status, because a status can be ambiguous. A type worth sending again unchanged adds "retry": true.
| 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 | 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 |
| 400 | rail_not_accepted |
/checkout?plan= or /api/plans/{id}/buy: this plan does not take that way of paying (its payment_kinds) |
| 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 |
| 405 | method_not_allowed |
the path takes another method; Allow names it |
| 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 |
| 409 | price_changed |
the chat's video tool: the price moved since the quote it was sent with; quote it again |
| 410 | pair_expired |
the pairing code expired or was already used |
| 410 | media_gone |
a finished clip the vendor no longer holds; nothing was kept here to send instead |
| 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 | 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 refused: its own reason when your request is what it refused (a 4xx), our sentence for a throttle or a fault; 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.
A pass (see Plans) pays first for models in its scope; your balance pays for the rest. One request is paid by one of them.
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. Streamed responses don't (headers precede the body): read /api/me or the final chunk's usage.
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: each request in flight holds what it can cost (its input and its capped output), and together they may hold what the balance covers, or a pass's remaining allowance and a little past it, up to the parallel requests its plan sold. Past that comes a 429 rate_limited with Retry-After, which names the limit that was reached.
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", "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. - 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": "…", "client": "…"}; the key is named<client> @ <hostname>) →{"device_code", "user_code", "ttl", "interval"}. 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>everyintervalseconds: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.