Repository navigation
HTTP API
The BBS has an HTTP API for programs and web front ends: REST for accounts, message areas, mail and files; server-sent events; and WebSockets for chat, in JSON or protobuf. A web client is a node like any other: it shows in Who's online, chats with terminal callers, gets node messages and can page the sysop.
It is on when bbs.conf has an [http] section:
[http]
address = :8443
cert_file = /etc/bbs/tls/cert.pem
key_file = /etc/bbs/tls/key.pem
;cors = https://www.example.org
;metrics = 127.0.0.1:9100| Setting | Default | What it sets |
|---|---|---|
address |
:8443 |
where it listens |
tls |
yes |
TLS needs cert_file and key_file. tls = no is for 127.0.0.1 behind a proxy that has TLS; bbs check warns about it on any other address |
trusted_proxies |
(none) | reverse proxies in front (addresses or CIDR ranges, by spaces or commas), believed when they say whose a request is: below |
secure_cookies |
as tls
|
the forum's and image boards' login cookies go over HTTPS only; say yes when a proxy in front has the TLS |
anonymous |
yes |
/system and /who without a token |
cors |
(none) | one browser origin allowed to call the API (no *); WebSockets are accepted only from it and the API's own host |
metrics |
(none) | an address for Prometheus metrics, /metrics (keep it private) |
max_per_ip |
20 |
connections at once from one address |
rate, burst
|
10, 20
|
requests a second per token (per address without one), and how many may come at once |
session_hours |
24 |
how long a login's token lasts |
terminal |
yes |
the web terminal page (below) |
forum, forum_guests, forum_theme, forum_flags
|
yes, yes, ipb3, yes
|
the web forum, guests reading it, and its theme (ipb3, ipb5, ipb4, ipb2, ipb1, ikon, vb6, vb5, vb4, vb3, vb2, vblite, xf23, xf2, xf1, mybb18, mybb16, mybb12, pun14, flux15, pun12, yabb25, yabb2, yabb1, smf30, smf21, smf20, smf1, phpbb33, phpbb32, phpbb31, phpbb30, phpbb2 or phpbb1) |
chan |
yes |
the image boards at /chan/ (set up in [chan]) |
The OpenAPI 3.1 description is at /api/v1/openapi.json.
The server can sit behind a reverse proxy (nginx, Caddy, HAProxy, a load
balancer) that handles TLS. Every request then comes from the proxy's
address, so list the proxy in trusted_proxies, and the server will
trust it to say which caller each request is from:
[http]
address = 127.0.0.1:8080
tls = no
trusted_proxies = 127.0.0.1, ::1
secure_cookies = yeslocation / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1; # the WebSockets: streams
proxy_set_header Upgrade $http_upgrade; # and the web terminal
proxy_set_header Connection "upgrade";
client_max_body_size 8m; # the image boards' images
}The caller's address is the last address in X-Forwarded-For (or else
Forwarded's for=, or else X-Real-IP) that is not a trusted proxy.
Any addresses before it are whatever the caller sent, and are not
trusted. That address is then used for the request everywhere: rate
limits, logins and the logs, the forum, the web terminal's throttling, the
image boards' flood control and bans, and the
[security] deny lists and bans (a refused caller gets 403). Connections
from a trusted proxy are not held to max_per_ip, because they carry
many callers. Requests from anywhere else are taken as they come, and
their forwarding headers are ignored.
Every request but /login, /openapi.json and (when anonymous = yes)
/system and /who needs a token: Authorization: Bearer bbs_....
- A session:
POST /api/v1/loginwith{"name": ..., "password": ...}(and"scopes", to have fewer) returns{"token": ..., "expires": ...}.POST /api/v1/logoutends it. - An API key, for a program:
bbs token add alice -scope read,post -expires 90 -name "my bot"prints it once.bbs token list [USER]andbbs token revoke IDmanage them, and users see and revoke their own at/api/v1/me/tokens.
Only a hash of a token is kept. A locked user's tokens stop working.
| Scope | Allows |
|---|---|
read |
the account, users, message areas and messages, last read |
post |
posting |
mail |
reading, sending and deleting mail |
chat |
the event stream, WebSockets, node messages, paging |
files |
file areas, uploading and downloading |
sysop |
/admin/... (only for a sysop's tokens) |
Every endpoint is under /api/v1 and takes and returns JSON (except
uploads and downloads).
Errors are {"error": "...", "code": "..."}.
| Request | What it does | Scope |
|---|---|---|
POST /login, POST /logout
|
a session token; ending it | (none), any |
GET /system |
the name, sysop, counts, features | anonymous |
GET /who |
who is online (with each terminal, as it said what it is, and country, ISO 3166, where it is known) |
anonymous |
GET /callers?limit= |
the last callers, newest first | read |
GET, PATCH /me
|
your account, access and settings; change your location, a line about yourself (about), password or settings ({"prefs": {"ansi": "off"}}; "" forgets one) |
read |
GET /me/tokens, DELETE /me/tokens/{id}
|
your tokens; revoke one | any |
GET /me/keys |
your SSH keys | read |
POST /me/keys, DELETE /me/keys/{id}
|
add an SSH key ({"key": "ssh-ed25519 ..."}, from a login's session only); delete one |
any |
GET /users, /users/{name}
|
users | read |
GET /areas, /areas/{id}
|
message areas, with your unread | read |
GET /areas/{id}/messages?after=&limit=&body=no |
messages after an id (200 at most) | read |
POST /areas/{id}/messages |
post: {"subject", "body", "reply_to", "alias": true, "poll": ["answer", ...]}
|
post |
PUT /areas/{id}/lastread |
mark read up to a message | read |
GET /messages/{id} |
a message | read |
GET /mail, GET, DELETE /mail/{id}
|
your inbox; a mail (marked read); delete it | |
POST /mail |
send: to a user, or Name@zone:net/node as netmail |
|
GET /qwk?mark=no |
your QWK packet (X-QWK-Messages: how many); its messages then count as read, unless mark=no
|
|
POST /qwk/rep |
a REP packet (the body): {"posted", "mailed", "duplicates", "errors"}
|
mail and post |
GET /fileareas, /fileareas/{id}/files, /files/{id}
|
file areas and files | files |
POST /fileareas/{id}/files?name=&description= |
upload: the body is the file | files |
GET /files/{id}/content |
download (ranges work) | files |
GET /messages/{id}/thread |
the message's thread from its start, with each one's depth | read |
POST /messages/{id}/vote, POST /messages/{id}/answer
|
vote ({"value": 1}, -1, 0); answer a poll ({"answer": 0}) |
post |
GET /files?q= or ?since=
|
files by words in their names and descriptions, or added since a time (RFC 3339), newest first | files |
GET /admin/bans, DELETE /admin/bans/{ip}
|
addresses banned for failed logins; lift a ban | sysop |
GET /admin/hacklog?lines= |
the hack log's last lines | sysop |
GET /admin/events, POST /admin/events/{name}/run
|
the scheduled events; run one now | sysop |
PUT /admin/available |
{"available": false}: pages reach no sysop |
sysop |
GET /files/{id}/listing |
what is in an archive (ZIP, tar, tar.gz); 422 for another file | files |
GET /chat/rooms |
chat rooms in use | chat |
POST /nodes/{n}/message, POST /page
|
a line to a node; page the sysop (both need an open stream) | chat |
GET /events |
server-sent events | chat or read |
GET /ws, GET /ws/proto
|
WebSockets | chat |
GET /admin/pages, POST /admin/pages/{node}/answer
|
pages waiting; answer one (node 0: the longest waiting) | sysop |
POST /admin/users, PATCH /admin/users/{name}
|
add a user; lock, unlock, make sysop, set password, level, flags, qwk_node
|
sysop |
POST /admin/areas, PATCH /admin/areas/{id}
|
add an area; set its newsgroup, echo tag, read_require, post_require
|
sysop |
POST /admin/nodes/{n}/kick |
throw a caller off | sysop |
GET /admin/fido, POST /admin/fido/poll
|
FidoNet links and outbound; call them now | sysop |
GET /admin/news, POST /admin/news/sync
|
news peers; sync now | sysop |
GET /admin/qwknet, POST /admin/qwknet/call?net=
|
QWK networks; call their hubs now | sysop |
TOKEN=$(curl -s https://bbs.example.org:8443/api/v1/login \
-d '{"name":"alice","password":"..."}' | jq -r .token)
curl -s -H "Authorization: Bearer $TOKEN" https://bbs.example.org:8443/api/v1/areas
curl -s -H "Authorization: Bearer $TOKEN" https://bbs.example.org:8443/api/v1/areas/1/messages \
-d '{"subject":"Hello","body":"From curl."}'The event stream and each WebSocket take a node, just as a caller does
(Via is web). The node counts against max_nodes, shows in Who's
online, and receives the node's events: lines said in its chat room,
node messages, pages (for sysops), new mail and notices. The node ends
when the connection does. POST /nodes/{n}/message, /page and answering a
page are done from the token's open stream (409 without one). A page
that reaches no sysop is 503 (unavailable), as the WebSocket's Page
request answers it, and is not left waiting.
GET /api/v1/events (with the Authorization header; a browser does
this with fetch, as EventSource cannot send headers) starts with
event: welcome (its node and user), then sends each event as
event: said (emoted, joined, left, private, paged, notice,
mail) with the event as JSON in data:, and a comment every 25
seconds to keep it open.
Two endpoints speak the same protocol, bbs.v1, defined in
proto/bbs.proto:
| Endpoint | Subprotocol | Frames |
|---|---|---|
/api/v1/ws |
bbs.v1.json |
text: protobuf's JSON mapping (lowerCamelCase fields, enums by name, RFC 3339 times) |
/api/v1/ws/proto |
bbs.v1.proto |
binary: protobuf |
The client sends ClientMessages with an id of its choosing; each is
answered by ServerMessages with that id: ok (with room when the
client's room changed), error (code and message), or what was asked
for. Events and the welcome come with id 0.
A browser cannot set headers on a WebSocket, so unless the upgrade had an
Authorization header the first message must be auth, within 10
seconds. The token needs the chat scope.
| Request | Answer |
|---|---|
auth {token} |
welcome {bbs, user, node, scopes} |
join {room} |
lines (the room's last lines), then ok {room}
|
leave {} |
ok |
say {text, emote} |
ok; the room gets the line |
nodeMessage {node, text} |
ok |
page {reason} |
ok {room}: the private room to wait in (the client is put in it) |
answer {node} |
sysops: lines, ok {room}
|
command {line} |
a line as typed in chat (/who, /rooms, /msg, /join ...): lines, ok
|
who {} |
nodes {nodes: [{node, user, via, since, doing}]} |
rooms {} |
rooms {rooms: [{name, users}]} |
ping {} |
pong |
A session in JSON (> from the client):
> {"id":1,"auth":{"token":"bbs_..."}}
{"welcome":{"bbs":"The BBS","user":"alice","node":3,"scopes":["read","post","mail","chat","files"]}}
> {"id":2,"join":{"room":"main"}}
{"id":2,"lines":{"lines":["bob: hi all"]}}
{"id":2,"ok":{"room":"main"}}
> {"id":3,"say":{"text":"hello from the web"}}
{"id":3,"ok":{}}
{"event":{"kind":"SAID","room":"main","from":"alice","node":3,"text":"hello from the web","time":"2026-10-11T12:00:00Z","line":"alice: hello from the web"}}
{"event":{"kind":"SAID","room":"main","from":"bob","node":1,"text":"welcome!","time":"2026-10-11T12:00:04Z","line":"bob: welcome!"}}
Clients of the protobuf endpoint generate code from proto/bbs.proto
(protoc --go_out=..., --python_out=... and so on). In JavaScript:
const ws = new WebSocket("wss://bbs.example.org:8443/api/v1/ws", "bbs.v1.json");
ws.onopen = () => ws.send(JSON.stringify({id: 1, auth: {token}}));
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.welcome) ws.send(JSON.stringify({id: 2, join: {room: "main"}}));
if (msg.event) console.log(msg.event.line);
};The server pings every 30 seconds. Frames are at most 16 KB; a frame that does not read is answered by an error, and the same limits as on a terminal apply (200 characters a line, 8 lines in 10 seconds).
/terminal (and /, which goes there when the web forum is off) is the BBS in a browser: a page
with xterm.js (5.5, MIT licensed, built into the
bbs binary: nothing is loaded from elsewhere) whose terminal is joined
to the text BBS by a WebSocket, /terminal/ws. The caller is a node like
a telnet caller (Who's online says web terminal) and logs in at the
BBS's own prompt, as on telnet: no token. They get ANSI colour and UTF-8
(ANSI art's CP437 shown as Unicode), menus, messages, mail, chat, paging
and doors; not file transfers (download files by the API or FTP). Its
xterm.js has the image addon, so the image boards'
pictures (sixel) show in it.
| Path | What it serves |
|---|---|
GET /terminal |
the page |
GET /terminal/xterm.js, addon-fit.js, addon-image.js, terminal.js, xterm.css, terminal.css, LICENSE.xterm
|
its files |
GET /terminal/ws?cols=&rows= |
the WebSocket, subprotocol bbs.terminal: binary frames, the terminal's bytes both ways; the size the page fits its window to (40-255 columns, 10-200 rows), kept for the call |
The page allows scripts only from the BBS itself, cannot be framed, and
accepts WebSockets only from its own host (and the cors origin).
Connections count against max_per_ip and rate, and logins count
against the throttle as a terminal's do. Behind a proxy on 127.0.0.1
without trusted_proxies, throttling is up to the proxy, because the BBS
sees every caller as the proxy. terminal = no turns the page off.
With metrics = 127.0.0.1:9100, /metrics there has, in Prometheus's
text format: bbs_nodes, bbs_nodes_by_via{via}, bbs_max_nodes,
bbs_users, bbs_messages, bbs_logins_total,
bbs_login_failures_total, bbs_posts_total, bbs_mail_total,
bbs_pages_total, bbs_chat_lines_total, bbs_pages_waiting and
bbs_fido_outbound_files{link}.
- TLS by default; tokens never go in URLs (only in the
Authorizationheader or a WebSocket's first message). - Tokens are 32 random bytes; only their SHA-256 is stored. A wrong token waits as a wrong password does, and failed logins are logged and counted like a terminal's.
- Rate limits per token (per address without one), connections per address, JSON bodies at most 64 KB with unknown fields refused, uploads limited as on the BBS.
- No CORS unless one origin is named; WebSockets from origins other than
it and the API's own host are refused. Responses carry
nosniffandno-store. - What web clients say and post is cleaned as a terminal caller's is.
See also: Chat and paging, Security, Configuration
These pages live in the repository's docs/wiki: change them there, not here.
Getting going
- Building
- Running
- Administration
- Sysop tools
- Configuration
- Configuration reference
- Databases
- Backups
- Customization
- Scripting
- Access levels
Callers
Features
- Message areas
- Writing messages
- Offline mail (QWK)
- Files and FTP
- File transfers
- Doors
- Newsgroups and NNTP
- FidoNet
- Chat and paging
- IRC
- IRC server linking
- AIM and ICQ
- AOL clients
- MSN Messenger
- MSN Classic (1995)
- Yahoo! Messenger
- MRC
- News, weather and quotes
- Finger, gopher and Gemini
- Time: NTP, NTS and PTP
- Web forum
- Image boards
- HTTP API
More