JSON API
Calling capabilities over HTTP, and the other routes.
This page starts with docs/api.md, the API's own guide, as it stands in the repository. The reference after it is generated from the handler (apps/web/lib/api/handler.ts), the list of what is offered (apps/web/lib/api/exposed.ts) and the route files, so it cannot disagree with them; a test checks the guide's status table against the handler.
The backend's public surface for a native app (or any client that is not the web app's own pages). It runs the same capabilities the pages run, through the same executor, so every permission, refusal, audit entry and idempotency rule is the same.
- Route:
apps/web/app/api/v1/capabilities/[name]/route.ts, handling inapps/web/lib/api/handler.ts. - Which capabilities are offered:
apps/web/lib/api/exposed.ts. A test fails when a new capability is neither offered nor kept off with a reason. - Tests:
apps/web/lib/api/handler.test.ts(fake platform) andapps/web/lib/api/flow.test.ts(the real platform over SQLite: sign-in, apply and approve through the route's handlers).
Calling a capability
POST /api/v1/capabilities/<name>
Content-Type: application/json
Authorization: Bearer <session token> (once signed in)
Idempotency-Key: <8 to 128 characters> (for a command)
<the capability's input, as a JSON object>A query (a read) that takes no input may also be fetched with GET /api/v1/capabilities/<name>. Input never goes in the URL: request URLs are kept in Workers invocation logs and in proxy and client logs, so a GET with ?input= is refused with 400 api_input_in_url, and a query with input is sent by POST like a command (without an Idempotency-Key). GET on a command is refused with 405 and Allow: POST. An empty POST body means {}.
Every answer is JSON and is sent with Cache-Control: no-store:
{ "ok": true, "value": { "...": "..." } }
{ "ok": false, "refusal": { "code": "already_applied", "message": "…" } }The message is the catalogue's text for the code (the API's own wording from apiErrors in apps/web/messages/*/api.json where the page's wording would not fit an app, else the shared errors text), in the language of the Accept-Language header (Chinese when neither language is asked for). Show message to the person and branch on code. Inputs and values are as defined in each capability in packages/core/src/capabilities/.
Status codes
| Status | Codes |
|---|---|
| 200 | the capability ran |
| 400 | invalid_input, operation_id_required (no Idempotency-Key on a command), api_body_invalid, api_input_in_url |
| 401 | unauthenticated (sign in), api_session_invalid (the bearer token is unknown, revoked, expired or malformed) |
| 403 | forbidden, challenge_required, api_cross_origin, api_consent_required, api_operator_web_only |
| 404 | not_found, api_unknown_capability (not a capability, or not offered on the API) |
| 405 | api_method_not_allowed |
| 409 | operation_conflict (the key was used for a different request), operation_already_completed (a repeated secret-holding command) |
| 422 | every other refusal: the capability's own rule, such as already_applied or branch_required |
| 429 | rate_limited, upload_rate_limited (wait the Retry-After seconds, or a minute) |
Who is asking
- Bearer token:
Authorization: Bearer <token>, the token fromauth.verifyCode. No origin check is made: a browser never sends this header on its own, so another site cannot borrow it. A bearer header that is present must be valid; a lapsed one is answered401 api_session_invalid, never treated as a visitor. - Session cookie: the web app's own
sessioncookie, for the app's pages. A cookie request must come from this app's own origin: anOriginheader naming another origin, orSec-Fetch-Site: cross-site, is refused with403 api_cross_origin, the same check the other API routes make. A stale cookie counts as no session. - Neither: a visitor; public capabilities run, others answer
401 unauthenticated. - Platform operators are turned away. A session belonging to an operator (bearer or cookie) is refused with
403 api_operator_web_onlyfor everything exceptauth.signOut, and when an operator signs in throughauth.verifyCodethe new session is revoked at once and no token is returned. Many member and admin capabilities give an operator platform-wide reach (completing an account erasure, appointing admins of any chamber, branding, moderation); that reach stays in the web admin.
The API never sets or clears a cookie, and sends no CORS headers: it is for native apps and the app's own pages, so a page on another origin cannot read its answers. (In pnpm dev, vinext's own development origin check answers a cross-origin request with a plain 403 Forbidden before the API sees it.)
The privacy notice
As on the pages, a signed-in person must have accepted the current privacy notice before member and admin capabilities run. Until then they are refused with 403 api_consent_required, and the refusal carries the version to accept:
{ "ok": false, "refusal": { "code": "api_consent_required", "message": "…", "noticeVersion": "2026-11-draft-4",
"notice": { "ms": { "language": "Bahasa Malaysia", "title": "…", "paragraphs": ["…"] },
"en": { "language": "English", "title": "…", "paragraphs": ["…"] } } } }Show notice (both languages, whatever the interface language, as on /zh/consent), then call consent.accept with {"locale": "zh"|"en"}. The server records acceptance of the version it serves; a noticeVersion the app sends is ignored, so an old build cannot record a version that was never shown. Allowed before acceptance: auth.*, consent.accept, dataRequest.create, dataRequest.listMine, export.mine, and every public capability. auth.whoami returns consentedNoticeVersion.
Idempotency
A command (or job) needs an Idempotency-Key, which becomes the operation id. Generate one per intended action (a UUID) and reuse it when retrying that action after a timeout: the retry is answered with the recorded result and nothing happens twice. The same key with a different input or person is refused with 409 operation_conflict. A missing key is 400 operation_id_required. Queries ignore the header.
Commands whose result holds a secret (auth.verifyCode, auth.rotateSession, export.mine) never store their result: a retry under the same key is refused with 409 operation_already_completed and never returns the token again. If that happens after a lost response, start again with a new code (or, for rotation, sign in again).
Signing in
Ask for a code. The email carries the brand of the address the API is called on.
POST /api/v1/capabilities/auth.requestCode Idempotency-Key: 6f1c… {"email": "mei@example.com", "locale": "zh"} → 200 {"ok": true, "value": {"sent": true}}Check the code; the value is the session token, to keep in the device's secure storage.
POST /api/v1/capabilities/auth.verifyCode Idempotency-Key: 0b9e… {"email": "mei@example.com", "code": "123456"} → 200 {"ok": true, "value": {"token": "…"}}Who am I (also tells the app when to rotate):
GET /api/v1/capabilities/auth.whoami Authorization: Bearer <token> → 200 {"ok": true, "value": {"userId": "…", "email": "mei@example.com", "consentedNoticeVersion": null, "rotationDue": false, …}}auth.whoamiandauth.rotateSessiontake theirtokenfrom the bearer header when the body leaves it out. Never put a token in a URL.When
rotationDueis true, swap the token:POST auth.rotateSession {}with the current token as the bearer header returns{"token": "<new>"}, and the old one stops working.Sign out:
POST auth.signOut {"everywhere": false}with the bearer token revokes this device's session (and its push subscriptions);{"everywhere": true}revokes every session. The token then answers401 api_session_invalid.
Sign-in is guarded as the sign-in page is (apps/web/lib/security/limits.ts): per network and per address each minute, and, once a network has made several attempts in fifteen minutes, a Turnstile challenge. Without a token the call is refused with 403 challenge_required, and the refusal carries the Turnstile siteKey. The app shows the Turnstile widget with that key in a WebView whose page is loaded with the app's host as its base URL (the widget only runs on hostnames the site key allows), takes the token the widget's callback gives, and repeats the call with it in the CF-Turnstile-Response header. The core still limits codes per address and guesses per code.
Applying and approving
POST /api/v1/capabilities/consent.accept
Authorization: Bearer <token>
Idempotency-Key: …
{"locale": "en"}
GET /api/v1/capabilities/committee.listOpen
POST /api/v1/capabilities/application.submit
{"committeeId": "…", "branchId": "…", "fullName": "Tan Mei Ling", "phone": "+60 12-345 6789",
"company": "Sample Sdn Bhd", "position": "Director", "industry": "Trading", "locale": "en"}
→ 200 {"ok": true, "value": {"id": "…", "status": "submitted", …}}The committee's Exco admin (or a branch or chamber admin above it):
POST /api/v1/capabilities/application.queue {"unitId": "<committee id>"}
POST /api/v1/capabilities/application.approve {"applicationId": "…", "note": "Welcome."}
POST /api/v1/capabilities/application.decline {"applicationId": "…", "note": "…"}Anyone else is answered 403 forbidden.
Which capabilities are offered
Every member and admin capability, and sign-in, for anyone who is not a platform operator (operator sessions are refused, above). Kept off, with the reason in exposed.ts:
- Platform-operator capabilities (
chamber.create,chamber.list,audit.list,operations.health,news.*sources and jobs,host.*,dataRequest.listAll,support.viewChamber,outbox.deliver,digest.assemble,digest.sendEmails,eventNotice.send): operator work stays in the web admin, where support access asks for and shows its reason. Operator sessions are kept off long-lived app tokens by refusing them, not by this list alone. - Raw image bytes (
profile.setPhoto,brand.setLogo,eventPhoto.upload, and the readersprofile.photo,brand.logo,eventPhoto.file,eventPhoto.showcaseFile): JSON cannot carry them. Uploads go through/api/uploads/*and photos are served from/photos/…,/event-photos/…and/showcase-photos/…. These use the session cookie today; a native app needs bearer support added there.
Limits
- Every call draws on the edge's per-network allowance,
API_LIMIT(worker.ts), answered429 rate_limitedwithRetry-After: 60. auth.requestCodeandauth.verifyCodeare guarded as above.import.prepare, which takes a CSV file, also draws on the network's upload allowance (UPLOAD_LIMIT), answered429 upload_rate_limited.
Examples
API=https://chambers.example.com/api/v1/capabilities
curl -s "$API/auth.requestCode" -H 'content-type: application/json' \
-H "idempotency-key: $(uuidgen)" -d '{"email":"mei@example.com","locale":"en"}'
TOKEN=$(curl -s "$API/auth.verifyCode" -H 'content-type: application/json' \
-H "idempotency-key: $(uuidgen)" -d '{"email":"mei@example.com","code":"123456"}' | jq -r .value.token)
curl -s "$API/membership.mine" -H "authorization: Bearer $TOKEN" -H 'accept-language: zh'
curl -s "$API/auth.signOut" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-H "idempotency-key: $(uuidgen)" -d '{"everywhere":false}'
Worked examples
API is the capabilities address, such as https://chambers.example.com/api/v1/capabilities (a chamber's own address; production is served at chambers.whymelabs.com), and TOKEN the session token from auth.verifyCode.
A query with input, by POST
Input never travels in the URL. A query that takes input is sent by POST, without an Idempotency-Key.
curl -s "$API/event.view" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"eventId":"<event id>"}'
Retrying a command safely
Make one key per intended action and reuse it on retry. The second call is answered from the operation record; nothing happens twice.
KEY=$(uuidgen)
curl -s "$API/registration.register" -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -H "idempotency-key: $KEY" -d '{"eventId":"<event id>","guests":[],"locale":"en"}'
# The connection dropped? Send the same request with the same key:
curl -s "$API/registration.register" -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -H "idempotency-key: $KEY" -d '{"eventId":"<event id>","guests":[],"locale":"en"}'
The privacy notice gate
A member capability called before the current notice is accepted answers 403 with the notice to show:
curl -s "$API/membership.mine" -H "authorization: Bearer $TOKEN"
# → {"ok":false,"refusal":{"code":"api_consent_required","message":"Read and accept the privacy notice before continuing.","noticeVersion":"…","notice":{…}}}
curl -s "$API/consent.accept" -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" -d '{"locale":"en"}'
An error, as a client sees it
HTTP/1.1 422
cache-control: no-store
content-type: application/json
{"ok":false,"refusal":{"code":"already_applied","message":"You already have an application waiting for this committee."}}
Status codes, from the handler
| Status | Codes |
|---|---|
| 400 | invalid_input operation_id_required api_body_invalid api_input_in_url |
| 401 | unauthenticated api_session_invalid |
| 403 | forbidden challenge_required api_cross_origin api_consent_required api_operator_web_only |
| 404 | not_found api_unknown_capability |
| 405 | api_method_not_allowed |
| 409 | operation_conflict operation_already_completed |
| 422 | Every other refusal: the capability's own rule |
| 429 | rate_limited upload_rate_limited |
The API's refusal codes and messages
The English text an app receives for each general code (Chinese is sent when the request asks for it). A capability's own codes, with their messages, are listed with each capability in the reference.
| Code | Status | Message |
|---|---|---|
invalid_input | 400 | Check what you entered and try again. |
operation_id_required | 400 | Send a new Idempotency-Key header with this action, then try again. |
api_body_invalid | 400 | The request could not be read. Send its details as a JSON object. |
api_input_in_url | 400 | The request's details cannot travel in its address. Send them in the body instead. |
unauthenticated | 401 | Sign in to continue. |
api_session_invalid | 401 | Your sign-in has ended. Sign in again to continue. |
forbidden | 403 | You are not allowed to do this. |
challenge_required | 403 | Complete the check to show you are a person, then try again. |
api_cross_origin | 403 | This request did not come from the app, so it was not carried out. |
api_consent_required | 403 | Read and accept the privacy notice before continuing. |
api_operator_web_only | 403 | Platform operators work in the web admin. Sign in there instead. |
not_found | 404 | That could not be found. |
api_unknown_capability | 404 | There is no such action in the app's interface. |
api_method_not_allowed | 405 | This action changes something, so it must be sent, not fetched. |
operation_conflict | 409 | This Idempotency-Key was already used for a different request. Send a new one with these details. |
operation_already_completed | 409 | This was already done, and its result is not kept. Start again. |
rate_limited | 429 | Too many attempts from your network or for this address. Wait a minute, then try again. |
upload_rate_limited | 429 | Too many uploads from your network. Wait a minute, then try again. |
Offered capabilities
154 capabilities, every member and admin capability and sign-in. A query may be fetched by GET when it takes no input; every other call is a POST.
Kept off the API
| Capability | Why |
|---|---|
chamber.create | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
chamber.list | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
audit.list | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
outbox.deliver | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
support.viewChamber | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
operations.health | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
news.sources | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
news.addSource | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
news.updateSource | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
news.recentItems | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
news.fetch | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
dataRequest.listAll | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
host.list | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
host.set | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
host.remove | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
brand.setLogo | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
brand.logo | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
news.analyse | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
profile.setPhoto | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
profile.photo | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
digest.assemble | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
digest.sendEmails | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
eventPhoto.upload | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
eventPhoto.file | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
eventPhoto.showcaseFile | Carries or returns raw image bytes, which JSON cannot hold: uploads go through /api/uploads/*, and photos are served from their own addresses. |
eventNotice.send | Platform-operator work: it stays in the web admin, where support access shows its reason, and keeps operator sessions off long-lived app tokens. |
Every route under /api
Besides the capability endpoint, these routes serve the web app's own pages; the JSON API's bearer tokens do not reach them yet. Who each serves is read from what its handler checks: public routes check neither the session nor the origin; cookie routes read the session cookie and answer by the viewer's rights; the uploads, push, check-in and export routes also refuse a request from another origin.
| Route | Methods | Access | What it does |
|---|---|---|---|
/api/auth/refresh | POST | Reads the session cookie | Swaps the session token for a fresh one. Called by the page when the session says rotation is due.
|
/api/chamber | GET | Public | Saves which chamber the user is looking at and returns them to the same page. The choice is checked against their chambers on every request, so nothing is trusted here.
|
/api/check-in | GET POST | Session cookie, same origin only | Check-in for the pages at the door, which work from a phone that may drop its connection: GET ?what=card&chamber=… the member's card code, fetched afresh before it runs out. Only for this app's own pages and the signed-in person; never stored by the browser or the service worker.
|
/api/export | POST | Session cookie, same origin only | Downloads a copy of everything held about the signed-in person, as one JSON file. A POST from the "Your data" form, so a link or prefetch never makes an export; each export is a new entry in the platform's log. Each click is a new operation, since the copy is never stored for replay. Like a deletion request, it does not wait for the privacy notice: a person may always see what is held about them. A failure returns the member to a page in their language that says what went wrong.
|
/api/health | GET | Public | Runs system.health with no actor and answers 200 with the database's state, or 503 when it cannot be reached. The deploy workflow's smoke test calls it.
|
/api/locale | GET | Public | Saves the visitor's language choice and shows them the same page in it.
|
/api/push | POST PATCH DELETE | Session cookie, same origin only | This device's push subscription, sent by the page once the person allows notifications (POST), refreshed with the language and session they are using as the app is used (PATCH), or removed when they turn push off on this device (DELETE). Only from this app's own pages, for the signed-in person.
|
/api/uploads/brand-logo | POST | Session cookie, same origin only | A chamber's new logo, posted by the plain form on its brand page as multipart (`logo`, with the chamber id, operation id and language), sent through the Worker rather than to R2 on a presigned address (see docs/operations/deploy.md), and answered by going back to the brand page. Nothing of the body is read before the origin, the session and the network's upload allowance (UPLOAD_LIMIT) are checked, and then only up to the limit, whatever length the request declares. The platform checks who may change the chamber's logo, and the logo's type and size from its bytes.
|
/api/uploads/event-photo | POST | Session cookie, same origin only | A photo for an event, posted by the gallery's form (multipart, so it works without script) with the event, an operation id, the page's language and the page to return to. It goes through the Worker, not to R2 on a presigned address (see packages/core/src/media.ts): the Worker reads at most the limit, whatever length the request declares, and the platform checks the bytes, not the name or declared type, and whether the person may add photos to this event. Each upload takes one from the network's per-minute allowance (UPLOAD_LIMIT), checked before the body is read.
|
/api/uploads/profile-photo | POST | Session cookie, same origin only | The member's new profile photo, sent through the Worker (not to R2 on a presigned address; see docs/operations/deploy.md). The edge limits uploads per network before the request gets here. The page's crop panel posts the cropped image as the raw body (`content-type: image/jpeg`) with its operation id and language in headers, and is answered in JSON. Without script, the same form posts the chosen file as multipart (its language in the address) and is sent back to the profile page. Either way the platform checks the bytes, not the name or the declared type. Nothing of the body is read before the origin and the session are checked, and then only up to the limit, whatever length the request declares: a larger body is answered photo_too_large.
|
/api/v1/capabilities/{name} | GET POST | Bearer token, or the session cookie from the same origin | The JSON API for a native app (docs/api.md): POST runs an offered capability, GET runs a query. The handling lives in lib/api/handler.ts, free of Workers APIs; this binds it to the Worker.
|
Other routes
Routes outside /api: images served with the viewer's rights checked on every request, the chamber's logo and icon, the manifest, and the bare address.
| Route | Methods | Access | What it does |
|---|---|---|---|
/ | GET | Public | The bare address sends the visitor to the home page in their language.
|
/{locale}/manifest.webmanifest | GET | Public | The installable app's manifest in the page's language, branded for the chamber the address is mapped to.
|
/brand-icon.png | GET | Public | The PNG icon of the chamber this address shows, or of the neutral brand, drawn like the SVG (app/brand-icon.svg): its mark in white inside a thin white rule, on its colour. For the installed app on a platform that takes only a PNG, and the apple-touch-icon.
|
/brand-icon.svg | GET | Public | The icon of the chamber this address shows, or of the neutral brand: the favicon and the installed app's icon.
|
/brand-logo | GET | Public | One height of a chamber's uploaded logo, from the MEDIA bucket (see lib/brand-logo.ts). A logo is public, like the rest of the brand. - `/brand-logo?id=<media id>&h=128`, as pages load it: the media id changes with every upload, so the response may be cached anywhere for good. A replaced or removed logo, or any id that is not a chamber's current logo, is not found. - `/brand-logo?chamber=<chamber id>&h=128`, as emails load it: whatever the chamber's logo is now, so an email already delivered keeps its header after the logo is replaced. Cached for an hour.
|
/event-photos/{photoId}/{width} | GET | Reads the session cookie | One width of an event photo, for the signed-in viewer, by the gallery's rules (eventPhoto.file): the uploader and the event's admins always; others only an approved photo, where the chamber shows event galleries to them. A photo waiting or rejected is never sent to other members. Anything not to be shown is answered as not found. Private and revalidated on every use, like profile photos: a browser may keep its copy but asks again each time, so a photo rejected or taken down stops being shown. No shared cache holds it.
|
/photos/{userId}/{size} | GET | Reads the session cookie | One size of a person's photo, for the signed-in viewer. The member's own needs no chamber; anyone else's is served only by the rules of their card in the chamber asked about (profile.photo), so a hidden photo is never sent to other members. Anything not to be shown is answered as not found. The response is private and revalidated on every use: a browser may keep its copy, but asks again each time, and a viewer who may no longer see the photo gets nothing. No shared cache holds it.
|
/showcase-photos/{photoId}/{width} | GET | Reads the session cookie | One width of a photo picked for the showcase of past events (eventPhoto.showcaseFile). Only showcase photos from events that have ended are served here, to anyone the chamber shows past events to, signed in or not. The showcase is the only place photos may be kept by a shared cache: when the chamber shows past events to the public, the response is public for a few minutes, keyed by its address, which names the photo's media id and width and nothing about the viewer, so the cookie is not read. Otherwise it is private and revalidated on every use, like a member's photo.
|
The pages themselves are listed in the page map.