HTTP API
Verified against v0.21.0 · internal/api/custody/custody.go, internal/api/custody/store.go, internal/api/router.go, internal/api/spaces.go, internal/store/spaces.go, internal/store/sessions.go, internal/api/dispatch.go, internal/api/me.go, internal/poker/routes.go, internal/poker/splits.go, internal/poker/rounds.go, internal/poker/state.go, internal/poker/parent_estimate.go, internal/poker/dependencies.go, internal/standup/routes.go, internal/api/links.go, internal/api/kudos.go, internal/api/plugins.go, internal/api/catalog.go, internal/api/pluginsettings.go, internal/plugin/settings.go, internal/plugin/grants.go, internal/api/pluginpanels.go, internal/api/pluginframe.go, cmd/parley/main.go, internal/api/plugindev.go, internal/api/standup_schedule.go, internal/api/standup_webhook.go, internal/api/ics.go, internal/api/standup_mentions.go, internal/standup/mentions.go, internal/api/standup_away.go, internal/standup/away.go, internal/standup/trend.go, internal/store/standup_trend.go
There is no API token scheme. Everything authenticates with the parley_session
cookie, so the API is usable from a script only by reusing a browser session’s
cookie.
Requests under /api that carry a body must send Content-Type: application/json,
and non-GET requests are rejected with 403 if their Origin does not match
BASE_URL or Sec-Fetch-Site says cross-site.
Every /api request body is capped at 64 KiB before route dispatch, including a
body sent to an endpoint that does not consume one. Some optional-body routes
apply a smaller 4 KiB decoder cap.
Health
Section titled “Health”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /healthz | public | Liveness. Always 200, never touches the database. |
| GET | /readyz | public | Readiness. Pings Postgres with a 3s timeout, then checks this replica's notify listener; 503 if the database is unreachable or the listener is down. |
| GET | /version | public | The build this instance is running. Always 200, never touches the database. |
/version answers with the build string and nothing else:
{ "version": "0.21.0" }A build that was not stamped at link time reports "dev" rather than a blank,
so the answer is always honest about what it does and does not know.
Like /healthz, it is unauthenticated and never touches Postgres — deliberately,
not by oversight. The question it exists to answer, “is this instance on the
version I think it is?”, has to be answerable while the database is down and
before anyone can sign in. The trade-off is that the version is readable by
anyone who can reach the port; that is accepted, because the tags are public
anyway and an operator unable to confirm a patch landed is worse off than an
attacker who can read a number. See
the runbook for
how to use it.
Sign-in
Section titled “Sign-in”These live outside /api on purpose: they are browser navigations arriving from
the identity provider’s domain, so the cross-site and JSON-body guards would
reject them. Their own protection is the state value in the sign-in cookie.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /auth/login | public | Begin the authorization code + PKCE flow. oidc mode only. |
| GET | /auth/callback | public | Provider redirect. Verifies state, nonce, signature, audience and expiry, then issues a session. |
Identity
Section titled “Identity”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/auth | public | Report which auth mode this instance runs — open or oidc. The frontend uses it to pick a sign-in path. |
| GET | /api/me | session | Current identity: id, name, avatar hue, chosen avatar icon, and notificationSounds. A link guest always receives notificationSounds: false; it also receives linkSessionId and linkExpiresAt. |
| POST | /api/me | public | Create or rename an anonymous identity, rotating the session token. Refused with 403 in oidc mode. A rename whose cookie was already rotated away answers 401 {error: session ended} and clears the cookie — the same as GET /api/me for a cookie that no longer resolves — rather than 500 or a new identity. |
| PATCH | /api/me/avatar | session | Set the caller's chosen avatar. Permitted in both auth modes. |
| PATCH | /api/me/settings | session | Set notificationSounds for the caller without rotating the session token. Ordinary open-mode and OIDC users only; signed-link guests receive 403. |
| DELETE | /api/me | session | Sign out. Deletes the caller's own token row, synchronously closes its WebSockets with code 1008, and clears the cookie. Open to a link guest — it is how a guest leaves the room and stops the cookie outliving the visit. |
| GET | /api/me/ics | session | Whether the caller has a personal standup calendar, as {active} and, when active, remindMinutes. Never the token or the URL. A link guest gets 403. |
| POST | /api/me/ics | session | Mint or replace the personal calendar feed: {remindMinutes} from 0 to 1440. Answers {url, remindMinutes}. The url holds the token and is returned only here. A link guest gets 403. |
| DELETE | /api/me/ics | session | Revoke the personal calendar feed. 204. The previous URL then answers 404. A link guest gets 403. |
| GET | /api/me/away | session | The caller's own away days as <code>{ranges: [{id, startsOn, endsOn}]}</code>, dates as YYYY-MM-DD, earliest first. A link guest gets 403. |
| POST | /api/me/away | session | Add an inclusive away range: <code>{startsOn, endsOn}</code>. 201 with the range. 400 for an unknown field, a date that is not YYYY-MM-DD, a last day before the first, a range longer than 90 days, or a start more than 30 days ago or more than a year ahead. 409 when the caller already holds 20 ranges. A link guest gets 403. |
| DELETE | /api/me/away/{id} | session | Remove one of the caller's own ranges. 204. Anybody else's id, or an id that exists nowhere, is 404. A link guest gets 403. |
| GET | /ics/{token} | token | The caller's standup calendar (text/calendar). No cookie. A missing or revoked token is 404. Lists async windows that are still open and scheduled slots for the next 14 days, each with one alarm at the minted offset. The description is only the link. |
PATCH /api/me/settings requires an explicit boolean and answers with the same
identity body as GET /api/me:
{ "notificationSounds": true }Missing, null, and non-boolean values are 400. The setting is private to the identity response: it is not included in member rosters, participant lists, or plugin session payloads.
Choosing an avatar
Section titled “Choosing an avatar”PATCH /api/me/avatar takes one id and replaces the choice:
{ "icon": "fox" }It answers 200 with the same body as GET /api/me. An omitted or empty icon
clears the choice, so {} removes the avatar entirely.
avatarHue is unrelated and is not settable: it stays derived from the user id,
and is always an integer on every surface that carries a person.
The server never enumerates icon ids — it validates their shape only, so the
picker can grow without a server release. The id must match
^[a-z0-9-]{1,32}$ when present.
| Status | When |
|---|---|
| 400 | An id is present and does not match the pattern; nothing is written. |
| 400 | The body is not valid JSON. |
| 401 | No valid session cookie. Unlike POST /api/me, this never creates a user. |
| 415 | The body is not declared application/json. |
Unlike POST /api/me, this does not rotate the session token, and it is not
refused under AUTH_MODE=oidc: the identity provider owns names, not avatars.
Nothing is pushed to other participants — they see the change on their next
session envelope.
Spaces
Section titled “Spaces”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs | user | The orgs the caller belongs to: slug, name, role. Cross-org and un-prefixed by design — it is what a client asks before it knows an org to ask within. An empty array is a real answer: an account whose identity-provider claims matched no org belongs nowhere. |
| GET | /api/spaces | user | The caller's own memberships, most recently active first: slug, orgSlug, name, whether it is protected, open (how many of its rounds have not ended) and here (how many distinct people are present across those rounds right now, by the same presence window as a session's here). kudoWaiting is true when a kudo to the caller sits unread in the space — a yes/no, never a count. Cross-org, so it stays un-prefixed. Never the room code. |
| GET | /api/orgs/{org}/spaces | member | The org directory: every space in this org with visibility 'org', plus every space the caller belongs to — slug, name, visibility, whether it is protected, and whether the caller is already a member. Never the room code. Paged: see below. Someone outside the org gets 404, not an empty list. |
| PATCH | /api/orgs/{org}/spaces/{slug}/visibility | owner | Set {visibility} to 'org' to list the space in the org directory, or 'private' to take it back out. 204. On an instance without sign-in configured, 'org' is refused with 403 — every visitor would be in the org. It never touches the room code: visibility governs discovery, not entry. |
| GET | /api/orgs/{org}/spaces/{slug} | public | Members get roster, sessions, room code, and the kinds a new session may use: kinds lists their ids, and kindOptions the same kinds as {kind, display, plugin}, where plugin is true for a kind an enabled install of this org provides. Each session carries kindDisplay, its kind's display name resolved in the space's own org (the id when no row in reach names it, so a retired plugin kind keeps its name), and here: how many people have a socket open on it right now, 0 once it has ended. It also carries present: up to five of those people who are members of the space, as {id, name, facilitator}, facilitator first and then by name — a link guest is counted in here but never named, and an ended room has none. progress is {kind: 'poker', settled, total} (stories with a saved estimate, of all stories), {kind: 'standup', answered, total} (queue entries with a non-blank today or blockers, of those not skipped — yesterday alone does not count, since it is prefilled from the previous standup), or null for a plugin kind. lastActivityAt is the close for an ended session; for an open one, the latest of its creation, a story added, a standup entry edited, the turn moving on, and any presence heartbeat — votes carry no timestamp of their own. Non-members get only the name and whether it is protected. Read-only: it never writes. |
| POST | /api/spaces | user | Create a space. Protected by default. Un-prefixed like the list above, and for the same reason — it is the landing page's route, called before an org is in the URL. An optional `org` (a slug) in the body names the org; the caller must be an active member, and an org they are outside answers the same 404 `no such org` as one that does not exist. Without it the space lands in the default org. |
| POST | /api/orgs/{org}/spaces/{slug}/join | user | Join, sending {passcode} — or {handle} from the invite route below — if the space is protected. Throttled at 8 attempts per 60s per address. |
| POST | /api/orgs/{org}/spaces/{slug}/invite | public | Trade {passcode} for {handle}: an opaque, single-use capability on this one space, good for five minutes, that a visitor can carry across an identity-provider sign-in instead of the room code. A wrong code is refused exactly as join refuses it, and spends from the same 8-per-60s budget. A miss answers the same 404 as the public space read, so it discloses no more than that route does. |
| POST | /api/orgs/{org}/spaces/{slug}/seen | user | Stamp the caller's membership as recently active, which is what orders the landing list. 204, and a silent no-op for a non-member — it never creates a membership. |
| POST | /api/orgs/{org}/spaces/{slug}/passcode | member | Mint a new room code, or with {open:true} remove it. Any member may do this. |
| POST | /api/orgs/{org}/spaces/{slug}/sessions | member | Create a session: kind, title, config. A poker config's deck is either a built-in name or a card list — {name, values, ordinal} of 2-15 cards, each 1-8 characters, no duplicates, none of them `?` or `coffee`, a name that does not reuse a built-in deck's unless its cards are exactly that deck's, and all of them finite numbers unless the deck is ordinal. Anything else is a 400, and the cards are stored with the session, so a deck cannot change under votes already cast. |
| GET | /api/orgs/{org}/spaces/{slug}/decks | member | This space's saved decks: id, name, cards, ordinal, createdAt. Membership of the space itself, not of the org around it — a non-member gets the same 404 a stranger gets, so deck names cannot be used to enumerate a private space. |
| POST | /api/orgs/{org}/spaces/{slug}/decks | owner | Save a deck: {name, cards, ordinal}. The cards follow exactly the rule the session-create route above applies, and it is the same code — a deck that saves is a deck a session can be created from. 409 over the per-space cap, and 409 if the space already has a deck by that name. |
| GET | /api/orgs/{org}/spaces/{slug}/kudos | member | This space's kudos, newest first, in pages of 100. Pass the last row's createdAt (verbatim) and id as ?before=…&beforeId=… for the next page; a page shorter than 100 is the end, and a malformed or half cursor is 400. Each row: id, fromUserId, toUserId, text, sessionId, createdAt — and unread, on rows addressed to the caller only; every other row has no unread key at all, so a sender never learns a kudo was read. ?waiting=1 instead answers with only the caller's own unread kudos in this space, newest first and at most 100, whichever page of the wall they sit on — the letters waiting for them; it takes no cursor, and any other value or a cursor alongside it is 400. There is no total, no count and no ranking in the response, by design. Membership of the space itself, not of the org around it — a non-member gets 404. |
| POST | /api/orgs/{org}/spaces/{slug}/kudos | member | Give a kudo: {to, text}. Text is 1 to 280 characters, counted in runes. 400 if the recipient is yourself, or is not a member of the space — a link guest holds no membership, so it neither sends nor receives. 409 over the per-space cap. |
| POST | /api/sessions/{id}/actions/answerKudo | member | Answer a kudo addressed to you with {id, text} (1–80 characters in runes), or withdraw your answer with {id, withdraw: true}. The kudo must be in this session's space — the space is read from the session, never the request — so any other kudo, or one that is not yours, is 404. 409 if it already has an answer; 403 for a link guest, by the same membership check as kudo. The answer rides on the kudo in the session state as answer. |
| DELETE | /api/orgs/{org}/spaces/{slug}/kudos/{id} | sender | Withdraw a kudo you sent. 204, 403 for a member who is not the sender, 404 if this space has no such kudo. Nobody can edit one: text somebody has already read is never rewritten underneath them. |
| PUT | /api/orgs/{org}/spaces/{slug}/kudos/{id}/answer | recipient | Answer a kudo addressed to you with {text}: one line, 1 to 80 characters counted in runes, trimmed. 204. A kudo has zero or one answer and it is never edited: 409 if it already has one, so changing it is DELETE then PUT. 403 for a member who is not the recipient, 404 if this space has no such kudo — including one its sender withdrew mid-request. The answer appears as answer on the kudo's row, for everybody who can see the kudo; a kudo with none has no answer key. |
| DELETE | /api/orgs/{org}/spaces/{slug}/kudos/{id}/answer | recipient | Withdraw your answer. 204, also when there was none; 403 for a member who is not the recipient, 404 if this space has no such kudo. Withdrawing the kudo itself takes its answer with it. |
| PATCH | /api/orgs/{org}/spaces/{slug}/decks/{deckId} | owner | Rewrite a saved deck, same body and same rules as creating one. Sessions already created from it keep the cards they copied. A deck belonging to another space is a 404, whatever the caller owns. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-schedule | member | The space's standup schedule as <code>{schedule}</code>, or <code>{schedule: null}</code> when it has none. A link guest gets 403. |
| PUT | /api/orgs/{org}/spaces/{slug}/standup-schedule | owner | Create or replace the schedule: <code>{weekdays, openTime, timezone, windowMinutes, enabled}</code>. weekdays are 0 (Sunday) to 6; openTime is "HH:MM"; timezone is an IANA name that both Parley and Postgres know, so <code>Local</code>, <code>localtime</code> and <code>posix/…</code> or <code>right/…</code> names are refused; windowMinutes is 1–1440. 400 for any invalid field, including an unknown time zone; 403 for a member who is not an owner. Changes reach future slots only. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-trend | member | The team participation trend: <code>{weeks: [{weekStart, ratio}]}</code> for the last 12 completed Monday-to-Sunday weeks, bucketed by each scheduled slot's local date, oldest first. Sums only the frozen per-day counts of scheduled standups; a day under four eligible people (non-spectator members, not link guests, not away) is dropped. ratio is rounded to one decimal. A week is <code>{weekStart, suppressed: true}</code> with no ratio when no day in it is left. Saves, once, any past scheduled day not yet frozen before reading. No parameters: any query string is ignored. Never carries names, ids or counts. 404 outside the space; a link guest gets 403. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | The space's standup webhook as <code>{url}</code>, or <code>{url: null}</code>. The secret is never returned here. 403 for a member who is not an owner, and for a link guest. |
| PUT | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | Set the webhook: <code>{url}</code>. Answers <code>{url, secret}</code>; the secret is shown in this response only, and every PUT mints a new one. 400 unless the url is https, names a host rather than an IP address, carries no credentials, and matches <code>STANDUP_WEBHOOK_HOSTS</code>. 503 without <code>PLUGIN_SECRET_KEY</code>. |
| DELETE | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | Remove the webhook. 204. Undelivered events are dropped on their next attempt. |
| DELETE | /api/orgs/{org}/spaces/{slug}/decks/{deckId} | owner | Delete a saved deck. 204, or 404 if this space has no such deck. Rooms created from it keep working: a session stores the cards it was created with, so nothing joins back to this row. |
| PATCH | /api/orgs/{org}/spaces/{slug} | owner | Rename the space: {name}, 1-64 characters. The slug never moves with it, so invites already sent keep working. |
| DELETE | /api/orgs/{org}/spaces/{slug} | owner | Delete the space and everything under it — sessions, stories, standups, memberships. Irreversible, and it closes the sockets on every open room. |
| PATCH | /api/orgs/{org}/spaces/{slug}/sessions/{id} | owner | Rename a session in this space: {title}, 1-200 characters. Bumps the version, so everyone in the room sees it without a reload. |
| DELETE | /api/orgs/{org}/spaces/{slug}/sessions/{id} | owner | Delete a session and its history. Irreversible, and unlike closing it is not idempotent: a second call is 404. Distinct from DELETE /api/sessions/{id}, which closes. |
| POST | /api/orgs/{org}/spaces/{slug}/members/{userId}/role | owner | Promote or demote: {role} is owner or member. 409 if it would leave the space with no owner. |
| DELETE | /api/orgs/{org}/spaces/{slug}/members/{userId} | owner | Remove a member. Access is revoked on their next request. 409 if they are the last owner. |
Paging the org directory
Section titled “Paging the org directory”GET /api/orgs/{org}/spaces answers with one page, never the whole org:
{ "spaces": [ { "slug": "platform-team", "name": "Platform Team", "visibility": "org", "protected": true, "member": false } ], "next": "UGxhdGZvcm0gVGVhbQBwbGF0Zm9ybS10ZWFt"}| Parameter | Default | Maximum | Meaning |
|---|---|---|---|
limit |
50 | 200 | Rows on this page. An ask above the maximum is clamped, not refused. |
after |
— | — | The next from the previous page. Omit it for the first page. |
next is present only while there is another page, so “no next” is the end
of the list. It is an opaque cursor: it encodes the sort key of the last row on
the page — the directory is ordered by name, with the slug breaking ties — and
a client must hand it back unread. A value this build did not issue is refused
with 400 rather than quietly restarting from the top, and a limit that is not
a positive whole number is 400 too.
Paging is by cursor and not by offset because the set moves while somebody is reading it. An offset re-counts the list on every request, so a space created ahead of the reader pushes a row across the page boundary and it is never seen, and one archived behind them shows a row twice. A cursor names a position, so neither can happen.
The scope rule holds page by page and is not a filter the client applies: a private space the caller is not a member of never appears on any page, and archived spaces appear on none.
Org custody
Section titled “Org custody”An org admin manages the spaces in their org — including private ones they are
not a member of — and can read nothing said inside them. Custody responses
carry an explicit allow-list of fields (id, slug, name, ownerIds,
visibility, memberCount, archivedAt) and nothing else: no roster, no
presence, no sessions, no votes, no standup entries. The handlers behind them
live in a package that does not link the session, presence, hub or store
packages at all, so the limit is a build-time fact rather than a convention.
Two rules make custody stop short of access. Custody may only make a space
more private — org → private is allowed here, private → org is
refused with 403 and remains the space owner’s alone. And ownership is
granted, never transferred: an admin may promote somebody who is already a
member of the space, may not name themself or any non-member, and never
demotes an incumbent owner.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs/{org}/admin/spaces | org admin | Every space in the org, private and archived ones included, as metadata only: id, slug, name, ownerIds (all of them — a space can have several), visibility, memberCount, archivedAt. |
| PATCH | /api/orgs/{org}/admin/spaces/{slug} | org admin | Rename with {name}, archive or restore with {archived}, and narrow with {visibility:'private'}. 403 on {visibility:'org'}: custody can only make a space more private. Archiving takes a space out of the org directory and does nothing else — its members, history and URL are untouched. |
| DELETE | /api/orgs/{org}/admin/spaces/{slug} | org admin | Delete the space and everything under it. Irreversible, and audit-logged. |
| POST | /api/orgs/{org}/admin/spaces/{slug}/owners | org admin | Grant ownership to {userId}, who must already be a member of that space. Additive: existing owners keep their role. 403 if the admin names themself or anybody who is not a member — that would be a way into a space they cannot read, through the ordinary member routes. |
| POST | /api/orgs/{org}/admin/spaces/{slug}/claim | org admin | Take ownership of a space nobody is left in. 409 while any member remains. This is the one action that makes an admin a member of a space they were not in, and it writes an audit record every time it succeeds. |
| GET | /api/orgs/{org}/admin/members | org admin | The org's membership, revoked rows included: userId, name, role, revokedAt. |
| POST | /api/orgs/{org}/admin/members/{userId}/role | org admin | Set {role} to admin or member. 409 if it would leave the org with no admin. |
| DELETE | /api/orgs/{org}/admin/members/{userId} | org admin | Revoke somebody from the org and from every space in it, in one transaction. Where they were a space's sole owner it promotes the most recently active remaining member; where there is nobody to promote it refuses with 409 and names those spaces, and writes nothing at all. The tombstone is an upsert, so somebody with no membership row yet can be revoked before their first sign-in. Their open sockets close on every replica. |
| POST | /api/orgs/{org}/admin/members/{userId}/restore | org admin | Lift a revocation, so the next sign-in enrols them again if their claim still matches. Without it a mis-click would be permanent. |
| GET | /api/orgs/{org}/admin/audit | org admin | This org's audit log, newest first: id, createdAt, actorName, action, spaceSlug, detail. 50 records by default, limit up to 100. To page back, send the last entry's createdAt as before and its id as beforeId, both or neither. actorName is null when the actor's account is gone, was never in this org, or there was no actor. Every custody mutation is recorded: space.rename (detail "<old> to <new>"), space.visibility (new value), space.archive, space.unarchive, space.claim, space.delete, space.add_owner, member.role ("<user id> to <role>"), member.revoke and member.restore (user id), org.purge; refusals write nothing. plugin.action details never carry a session id. 400 for a bad limit or cursor. |
| GET | /api/orgs/{org}/admin/audit/settings | org admin | This org's audit retention: {retentionDays}, null for forever (the default). |
| PATCH | /api/orgs/{org}/admin/audit/settings | org admin | Set {retentionDays}: null (forever), 365, 90 or 30; anything else is 400. Writes an audit.retention_changed record naming the old and new values. An hourly prune deletes this org's records older than the window. |
| DELETE | /api/orgs/{org} | org admin | Purge the org: every space, every session, and the org row itself, in one transaction. Send {confirm} matching the org's own slug; without it the call is 400 and reports the exact space and session counts it would destroy, read inside the transaction that would have done it. Irreversible, and the audit records survive it. The default org cannot be purged. |
Plugin catalog
Section titled “Plugin catalog”The instance’s shelf of signed bundles, shared by every org. Reading it takes membership of at least one org; uploading takes an admin of the default org — an admin of any other org is refused 403. A link guest is 401 on both, and an embedded session is refused.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/catalog | org member | Every stored bundle, grouped by plugin name: for each version its digest, key_id (empty for an unsigned bundle), the grants it asks for described by the server, the display names of the session kinds it provides, when it was published, and its settings schema if it declares one. It names no uploader, no org and no install count. can_upload says whether the caller may upload. |
| GET | /api/catalog/loaded | default-org admin | {loaded: [...]}: the plugin bundles the replica answering has compiled, as digest/key_id (a loose PLUGIN_DIR file shows as file:name@version). Ask each replica to confirm they run the same bytes. |
| POST | /api/catalog/bundles | default-org admin | Upload a .parley bundle as the raw request body with Content-Type application/vnd.parley.bundle — no JSON, no multipart. The body is capped at 16 MiB plus framing. 201 with the stored version; the identical bundle again is 200 with the same body, and adds nothing. 409 when a different bundle already holds that name and version. 400 for a malformed bundle or a settings schema outside the subset, 422 for a signature that does not verify against a trusted key. Uploads and signature refusals are written to the org audit log. |
The upload is the one /api write that does not take JSON. The exemption is
that exact path and that exact type, and the type is not one a browser may
send cross-site without a preflight, so the same-origin check still holds.
Stored bundles are never deleted, so there is no route to remove one.
Plugin administration
Section titled “Plugin administration”The operator’s surface over what is installed and what it is allowed to do. It sits behind the same three-deep gate as custody — signed in, a member of the org, and an admin of it — so an ordinary member gets 403 on every route below, whatever the frontend chose to draw.
Consent copy is written by the server, not the client: permits, allows and
refuses on every described grant come from internal/plugin, beside the guard
that enforces them, so the sentence an operator agrees to and the rule the host
applies cannot drift apart.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs/{org}/admin/plugins | org admin | Everything installed in this org — and only this org: version, the pinned bundle ({digest, key_id}, null when the install is not in the catalog and still runs from PLUGIN_DIR; inCatalog says which), history (every bundle it has run, newest first, with its version), enabled, described grants, session kinds provided, any pending upgrade as a diff, and health. Also reports whether a plugin host is running (it runs on every boot) and whether plugin secrets are available. |
| POST | /api/orgs/{org}/admin/plugins/preview | org admin | Describe what a catalog bundle, named by {digest, key_id}, would be permitted, without installing anything. Every fetch allowlist entry is expanded into worked examples produced by the guard's own matching. The manifest's kinds are echoed so the consent screen can name the session ceremonies the bundle declares, and bundle names the {digest, key_id} described. For a plugin whose name is already installed in this org it also returns the diff against the grants in force. |
| POST | /api/orgs/{org}/admin/plugins | org admin | Install, or request an upgrade. Body is {digest, key_id, grantsAccepted}, naming a version from GET /api/catalog; the grants, kinds and consent copy come from that verified bundle's manifest and the install is pinned to it, so it runs exactly those bytes. 404 for a digest not in the catalog or not trusted now. An older version than the one installed is 409: going back is a rollback. grantsAccepted must be true, so a client that never showed a consent screen cannot install by omission. The manifest's kinds are the session kinds the plugin provides (kind, display, actions); they are written on first install and on an upgrade within the grants already approved, in the same transaction as the version bump; a widening upgrade stages them with its grants and they are written only on approval, which is refused if another org took a staged name meanwhile. A declaration the host will not accept is 400 and writes nothing. A kind name already taken on the instance is also 400. 201 for a new install, 200 for an upgrade within the grants already approved, and 202 when the upgrade asks for more — in which case the version, the pin and the grants in force are unchanged and the request, with its target bundle, parks as pending. An upgrade whose settings schema a saved value does not fit is 409, naming the fields; nothing is dropped. A settings schema outside the subset is 400. |
| POST | /api/orgs/{org}/admin/plugins/{id}/rollback | org admin | Move to any trusted catalog version of the same plugin, older included, with {digest, key_id}; it is the only route that goes to an older version. 404 for a digest not in the catalog, 409 for a bundle of a different plugin. It goes through the upgrade path: 200 when the bundle asks for nothing beyond the grants in force, 202 and pending approval when it asks for more. Audited as plugin.rollback or plugin.rollback_requested, naming the version and bundle moved from and to. 404 for an install this org does not own. Like an upgrade, 409 naming the fields when a saved setting does not fit the target's settings schema. |
| GET | /api/orgs/{org}/admin/plugins/{id}/settings | org admin | The settings schema the pinned bundle declares (null when none), the saved non-secret values, and for each secret field only {set, undecryptable} — never a value. 404 for an install this org does not own. |
| PUT | /api/orgs/{org}/admin/plugins/{id}/settings | org admin | Replace the non-secret values with the body's; a value left out or null falls back to its default. For a secret field, a string sets it, null clears it, and leaving it out keeps it. Body capped at 16 KiB (413). 400 with a fields map naming each value that does not fit the schema, and each required field missing. Audited as plugin.settings, naming fields, never values. 404 for an install this org does not own. |
| POST | /api/orgs/{org}/admin/plugins/{id}/upgrade | org admin | Approve a pending upgrade. Body must be {approve:true}; an empty body is 400 and changes nothing. This is the only thing that puts a wider grant set into force, and it applies the session kinds the upgrade staged in the same transaction. 404 for an install this org does not own, or one that does not exist — the two are deliberately indistinguishable. 409 naming the fields when a setting saved while it waited does not fit the new schema. |
| POST | /api/orgs/{org}/admin/plugins/{id}/enabled | org admin | Switch a plugin off or back on with {enabled}. Reversible, and it destroys nothing; disabling also evicts the compiled module rather than leaving it resident. 404 for an install this org does not own. |
| DELETE | /api/orgs/{org}/admin/plugins/{id} | org admin | Uninstall. Irreversible: it cascades to the plugin's grants, key-value store, deliveries, jobs and encrypted secrets. 409 while any session of a kind the plugin provides still exists, and the refusal names those kinds and their counts. The check, the retirement of the kinds it provided, the delete and the audit row are one transaction, so a refusal changes nothing and a completed uninstall cannot be unlogged. 404 for an install this org does not own. |
| POST | /api/orgs/{org}/admin/plugins/themes | org admin | Record that a theme pack was applied. A pack never reaches the server — it is a value map applied in the browser — so this writes the audit row and nothing else, including whether the contrast gate was overridden. |
| DELETE | /api/orgs/{org}/admin/plugins/themes | org admin | Record a reset to the built-in palette. |
POST /api/orgs/{org}/admin/plugins/dev-register exists only in a binary built
with -tags plugindev. It is absent from the default build — not hidden behind
an environment variable — so it cannot become a verification-bypassing install
path in production. See Plugin SDK.
Every install, upgrade request, upgrade approval, disable, re-enable, uninstall
and theme change writes a row to the same org_audit_log an admin’s custody
actions land in, naming the acting user.
Links shared before spaces carried an org
Section titled “Links shared before spaces carried an org”Space URLs used to be /s/{slug}, with no org in them. That path is still
served, as a redirect, so links shared before the change keep working.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /s/{slug} | public | 302 to /o/{org}/s/{slug} when exactly one of the caller's own orgs holds a space with this slug. Anonymous visitors and guests holding a signed link get the app itself instead, so the client can put them through sign-in. A signed-in caller whose orgs hold no such space — or more than one, which repeating slugs across orgs now makes possible — gets a 404 rather than a guess, carrying the app's own not-found page, identical whether or not another org holds the slug. |
The org is resolved against the caller’s own memberships, never by looking the slug up across the instance, so a space created later in another org cannot change where an existing link goes, and a slug held in an org the caller is outside answers exactly as one that exists nowhere. The 302 is deliberate: an org membership can be revoked, and a permanently-cached 301 would keep sending someone to a space they no longer reach.
Sessions
Section titled “Sessions”Every route below requires membership of the session’s space, and answers 404 rather than 403 otherwise — including for anonymous callers — so a session’s existence is never disclosed.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/sessions/{id} | member | The session envelope, redacted for the caller. |
| GET | /api/sessions/{id}/export.csv | member | CSV of the session, rendered from the same redacted view. A plugin kind exports that view's state fields; a kind with no exporter is 404. |
| POST | /api/sessions/{id}/facilitator/claim | member | Claim the facilitator role after the 60s grace period. Any member. |
| POST | /api/sessions/{id}/facilitator | facilitator | Hand the role to {userId}. Distinct from the claim route above. |
| POST | /api/sessions/{id}/participants/{userId}/remove | facilitator | Remove {userId} from this session only — their presence row goes and their sockets for this room close with code 4001. Space membership is untouched, so they stay in the space roster and may rejoin. An open round stops waiting for them; a vote they had already cast stays and still counts. Optional body {message}, shown to them on the way out and truncated to 123 bytes on a rune boundary. Removing yourself or the facilitator is 400. |
| DELETE | /api/sessions/{id} | facilitator | Close the session — it ends, its history stays, and it can be reopened. Idempotent. To discard one, see DELETE /api/orgs/{org}/spaces/{slug}/sessions/{id}, which is owner-only. |
| POST | /api/sessions/{id}/reopen | facilitator | Reopen a closed session. |
| POST | /api/sessions/{id}/spectator | member | Toggle your own spectator seat with {on}. A property of a member, not of a kind, so it is a core route rather than an action. |
| POST | /api/sessions/{id}/links | facilitator | Mint a signed link for this room. Refused once the room is closed. The reply carries the plain token exactly once — nothing can read it again. |
| GET | /api/sessions/{id}/links | member | List this room's links: ids, expiry, revocation and redemption counts. Never tokens. |
| DELETE | /api/sessions/{id}/links/{linkId} | facilitator | Revoke a link. Idempotent — a second call is another 204. The row stays, marked revoked, and everyone who joined through it loses their session immediately. |
Redeeming a signed link
Section titled “Redeeming a signed link”| Method | Path | Who | Purpose |
|---|---|---|---|
| POST | /api/links/redeem | public | Trade a link token for a guest identity in the one room it names: {token, name}. 201 with {sessionId, expiresAt, me} and a session-scoped cookie: the browser drops it when the browsing session ends, and the token behind it expires with the link. |
This is the only /api route open to a caller with no identity that is not
POST /api/me, and the only one a link guest may call.
It carries no org prefix, and neither does anything under /api/sessions/{id}
or the /session/{id} and /link pages that reach them. That is deliberate and
permanent: a link guest belongs to no org and no space, so it has no org slug to
put in a URL and no membership to derive one from. Adding a prefix here would
404 every signed link ever issued.
| Status | Meaning |
|---|---|
| 201 | Redeemed. The cookie carries no Max-Age, so closing the tab ends the seat; its token expires exactly when the link does. |
| 400 | name is missing or longer than 64 characters. |
| 404 | The link is wrong, expired, revoked, or has been redeemed its maximum number of times — the four are one answer, and the reply never repeats the submitted token back. |
| 429 | Either 8 wrong tokens from this address in 60s, or the hourly identity-creation limit. |
What redemption grants. An ordinary users row flagged link-bound, and
nothing but participate access to the one room the link names:
GET /api/sessions/{id} and the session actions that are not
facilitator-only — voting in poker, and in a standup, writing an update and
holding a place in the round. start, next and skip stay facilitator-only,
so a guest takes a turn but never runs the round. Every other route answers
401, 403 or 404 — the identity routes, the space view, the CSV export, the
facilitator controls, and the link routes themselves. A link grants no space
membership and no sight of any other room.
When it ends. The link’s expiry (24 hours) lives on the minted session token, so the socket is severed mid-session when it lapses. Revoking the link deletes those tokens outright and closes the sockets at once. Nothing is swept: the guest and their votes stay in the meeting’s history.
Session actions
Section titled “Session actions”Everything a session kind does goes through one dispatcher:
<VERB> /api/sessions/{id}/actions/{action}Each action declares its own verb, and the dispatcher routes on the pair.
Most actions are transitions on the session and take POST. Three are not: a
standup entry is an upsert of the caller’s own row, so it is
PUT /api/sessions/{id}/actions/standup; a standup readiness signal is an
upsert of the caller’s own row too, so it is
PUT /api/sessions/{id}/actions/ready; and a story edit is a partial update, so
it is PATCH /api/sessions/{id}/actions/story carrying {storyId} in the
body. The verb is listed against every action in the tables below.
The case for collapsing all of them onto POST is a fair one — every action is
a state transition on the session rather than a replacement of a resource at
that URL, and one verb spares clients this table. It was weighed and not taken:
an upsert is exactly what PUT is for, and folding it into POST throws away
the idempotency signal a client acts on, since a retried PUT is safe to send
and a retried POST is not.
A real action reached with the wrong verb is 405, with Allow naming
the verb that works. A name this kind has no action for is 404 under every
verb — the dispatcher decides both itself, because the action name is a path
parameter and the router cannot know which names are real.
No action may declare GET or HEAD; registering a kind that does is refused
at startup. Every action is a write, and the cross-site guard exempts GET on
the promise that a GET changes nothing.
The action name is resolved against this session’s own kind, so an action
belonging to another kind is a 404 rather than a route two kinds share. The
{id} in the path is the binding for everything the action touches: a storyId
in the body that belongs to a different session answers 404.
Membership is resolved before dispatch. Mutable facilitator and ended-session state is revalidated inside the transaction that performs each write. Any action against a closed session is 409; reopen it first.
Planning poker
Section titled “Planning poker”Split mutations use the existing session actions route and facilitator authority. The current facilitator, active room, space membership and unrevoked org membership are checked again under the session lock. Link guests and ordinary participants cannot make structural writes. Embedded sessions keep the same facilitator/participant rules as the existing Poker actions.
POST .../actions/child accepts {parentId, title, notes?, ref?, operationId, expectedSplitRevision}. Only title is a required content field. Title is at most
200 characters, notes 2000, and ref 40. An operation id has 1–100 letters, digits,
underscores or hyphens. A new creation returns 201 with {storyId, contentRevision, splitRevision}; retrying the same operation and original content
returns 200 with the same identity, even after revision/quota changes. Reusing
the operation for different parent/content returns 409. State carries
creationOperation so a refreshed client can reconcile an unknown response.
POST .../actions/adopt accepts {parentId, expectedRevision, expectedSplitRevision, children, coverage, remainderId?}. children maps every
active child id to its acknowledged content revision. coverage is full, or
remainder with an explicitly named active remainder child. Adoption requires two
active children, makes them planning units and the parent context in one
transaction, preserves all estimates and parent notes, and clears a selected
parent without moving its votes to a child. Notes are never copied to children.
There is no implicit remainder or estimate rollup.
An adopt that only confirms coverage again for a split that is already
committed, with no draft to commit, never touches the table, the round or any
vote, and needs no round fields; that is what a client sends after removing or
restoring a committed story. Committing new drafts while the parent itself is
on the table follows the rule below and ends the parent’s round only with its
acknowledgment.
A selected live parent or a revealed parent without an acceptance for that round
requires switchToChildren: true, expectedCurrentStoryId and
expectedRoundVersion on adoption. Omitting review returns 409 and leaves
selection, votes and roles intact. The same guard applies when dealing an active
child away from its parent.
POST .../actions/undo-split accepts {parentId, expectedRevision, expectedSplitRevision, children} and reverses an adoption in one transaction.
children maps every non-removed child id to its content revision, as adopt
does; a missing or stale entry returns 409, so a captured request cannot be
replayed over a child that has since been retitled or estimated. The parent
becomes a planning story again with no coverage; every child, removed ones
included, becomes proposed and loses isRemainder. Removed children stay
removed, and sibling dependencies are kept. An accepted estimate stays on its
child and that child’s scopeRevision advances, so estimateNeedsReview is true
and a later adoption cannot present the old value as current. The children’s
votes are not moved or cleared. A parent with no accepted estimate has its own
votes and recorded roster from before the split deleted in the same
transaction, so it comes back as a story with no round and dealing it starts a
clean one. A parent with an accepted estimate keeps both the estimate and the
votes behind it. The split revision advances; a stale expectedRevision or
expectedSplitRevision returns the usual 409. A parent that is not an
adopted split returns 409.
Undo is refused 409 while any create publication step for the split is
unknown or confirmed: the story exists in the tracker, or its creation was
sent without a definite answer, and Parley never deletes a remote issue.
pending, failed, local-only and waiting-for-issues steps do not block,
and neither does a step of any other kind. A pending creation was approved
but never dispatched; the undo changes the parent and every child revision, so
that approval no longer matches the split and a later run of it pauses without
contacting the provider, also after the split is made again. When one of the
split’s children is on the table, undo ends that round: selection and reveal
are cleared, the round version advances and nothing is selected in its place.
Unless that round is revealed with an acceptance saved for it, the request must
also carry endRound: true, expectedCurrentStoryId and
expectedRoundVersion; omitting or mismatching them returns 409 and changes
nothing.
Every select involving a split-related current or target story requires both
expectedCurrentStoryId (empty string when no story is selected) and
expectedRoundVersion, including unvoted child Deal/Next and switches to a flat
story. Missing, partial or stale snapshots are refused 409.
Child select with retained votes additionally requires freshRound: true and the reviewed
expectedCurrentStoryId/expectedRoundVersion. It clears only that child’s
votes and recorded roster, then starts a fresh round. Plain selection cannot
silently reuse old child results. Split-related vote, reveal and reset
require storyId plus expectedRoundVersion, checked under the session lock.
A delayed vote or control request from an earlier child/scope is refused 409.
A top-level story that has ever been split (splitRevision above 0), whether
its split is committed, drafted or undone, follows the same rule as a child:
select over votes it still holds requires freshRound: true and clears them.
A story that was never split keeps the legacy behavior, so selecting it away
and back mid-round keeps its votes.
PATCH .../actions/story accepts acceptanceMode: "facilitator-set" | "poker".
The compatibility default is facilitator-set; reveal status never decides
provenance. Every nonempty estimate must be a non-special card in the room’s
copied deck. Poker acceptance additionally requires a revealed round with votes,
the current storyId, expectedRevision, expectedScopeRevision and
expectedRoundVersion. A direct acceptance may guard expectedScopeRevision;
child edits already require the current content revision. Empty clears local
acceptance metadata and does not publish anything.
A facilitator-set estimate on the story that is on the table overrides its
round. While that round is unrevealed the request must carry endRound: true,
expectedCurrentStoryId and expectedRoundVersion; omitting or mismatching
them returns 409 and changes nothing. With them the estimate is recorded
and, in the same transaction, that round’s votes and recorded roster are
deleted, selection is cleared and the round version advances, so a later round
on the story cannot reveal a vote cast before the override. This holds for flat
stories as well as children. Once the round is revealed no acknowledgment is
needed and the round stays on the table, as before. Clearing an estimate and
estimating a story that is not on the table are unaffected.
Actual title/notes changes on split-related stories advance scopeRevision.
For the active story they additionally require scopeRestart: true and the
reviewed expectedCurrentStoryId/expectedRoundVersion; scope edits and estimate
acceptance are separate actions there. A restart clears only affected votes,
refreshes the open-voting roster and retains the previous estimate. State exposes
scopeRevision, acceptedScopeRevision, acceptedRoundVersion,
estimateProvenance (historical, facilitator-set, poker, story-total, or null) and
estimateNeedsReview. Needs-review values do not count as settled progress and
must be deliberately accepted again before any future publication approval.
Dependency-only saves do not change these fields. No publication service exists
on this local surface; any future approval must bind the accepted content/scope.
POST .../actions/dependencies accepts {parentId, expectedRevision, expectedDependencyRevision, children, edges, retireIds?}. children maps every
retained child id (including removed children) to its content revision; parent
content uses expectedRevision. edges is the complete proposed array of
{blockerId, dependentId} pairs between active siblings, including drafts.
The current facilitator saves under the room lock. Self, duplicate, opposite,
and transitive cycles fail atomically with 409; cycle responses include a named
cycle chain. Stale graph or content revisions also return 409 and require
reviewing latest state and the unsaved proposal.
Each parent exposes dependencyRevision. State exposes dependencies with stable
local id, parentId, blockerId, dependentId, reviewState, and retiredAt.
reviewState is planning, blocker-removed-review, dependent-removed-review,
review-needed, or retired; none asserts readiness or completion. The state
notice is Planning dependency · Completion not checked. Removing an active
pair retires it but keeps its id. Unavailable or review-needed links remain until
explicitly replaced or retired using their ids in retireIds. These are local
intent references, not remote publication receipts. No completion or provider
endpoint is fetched.
Each story carries draftsParked. It is true on a top-level story whose draft
children are set aside behind an ordinary round, and false everywhere else. The
server sets it on undo-split, and on any select of a top-level story
according to whether that story has active proposed children at that moment;
adopt clears it. Drafting children under the story already on the table
leaves it false, and discarding the last active draft clears it.
POST .../actions/park-drafts accepts {parentId, parked} from the facilitator
and sets it for everyone; a child or a context parent returns 409, as
does parked: true on a story with no active draft; a removed or unknown
story returns 404 and a missing parked 400.
POST .../actions/leftover accepts {parentId, storyId, expectedSplitRevision}
from the facilitator and records which active draft holds the scope that is
left over, as isRemainder on that proposed child; an empty storyId clears
it. At most one draft is named, the split revision advances, and a stale
revision or a story that is not an active draft of that parent returns 409,
as does naming a draft while a committed child is already the remainder.
Discarding the named draft and undo-split clear it. While a draft is named,
adopt must send coverage: "remainder" with that remainderId; anything
else returns 409, so a client that lost track of the leftover cannot commit
the split as full coverage. The flag
governs presentation only: it grants nothing and changes no round, vote or
estimate.
A committed split’s parent stays context, and can still be given an estimate
in two ways. POST .../actions/parent-estimate accepts, from the facilitator,
{parentId, answer: "total", expectedTotal, expectedRevision, expectedSplitRevision, children}: the server adds up the accepted estimates of
the active planning children inside the transaction and records the result
as the parent’s estimate with estimateProvenance: "story-total". The sum is
exact (the ½ card counts as 0.5) and is written as a plain decimal such as
16 or 8.5; it need not be a card in the deck, which no other path may
store. A sum that differs from expectedTotal, or a stale revision or
children map, returns 409. If any active child is unestimated, has an
estimate that needs review, or the deck has no numbers, the total is refused
409 a total needs every story in the split to have a current numeric estimate. {parentId, answer: "none", expectedSplitRevision} records only
that the facilitator declined. Either answer, and dealing the parent, sets the
parent’s parentEstimateAnsweredRevision to its splitRevision, which is how
a client knows not to ask again until the split’s membership changes.
The parent can also be dealt: select accepts a context parent with the usual
reviewed identity, and freshRound: true when it still holds votes. Votes,
reveal, reset and leave-unestimated work on it, and PATCH story accepts
acceptanceMode: "poker" for it after a revealed round; a facilitator-set
estimate on a context parent is still refused 409. Its role and its
children are not changed by any of this.
A total is refused 409 while the parent itself is on the table, so it
cannot be recorded over the parent’s own round. No split action ends a round
that is live on the parent without that round’s acknowledgment: removing or
restoring a story, naming a leftover, declining a parent estimate and
undo-split all leave it in place.
A story-total estimate is flagged estimateNeedsReview when a child’s
estimate changes to a different value or its scope changes, when a committed child is removed or restored, when
the split is committed again, and on undo-split, which keeps the value. Send
the total again to refresh it. A parent estimate accepted by Poker is not
flagged by changes to the children. undo-split leaves a round that is live on
the parent alone. Recording any parent estimate advances the parent’s content
revision, so a GitHub publication approved before it pauses and must be
reviewed again; the parent’s estimate is never part of what is published.
POST .../actions/remove-child and restore-child accept {storyId, expectedRevision, expectedSplitRevision} and retain identity/content/estimate.
Removing the child that is on the table ends its round the same way undo does,
under the same rule: unless the round is revealed with an acceptance saved for
it, the request must carry endRound: true, expectedCurrentStoryId and
expectedRoundVersion, and omitting or mismatching them returns 409 and
changes nothing. Votes stay on the removed row.
A parent holds at most 10 children (409 a split can hold at most 10 stories; removed stories that were committed still count, discarded drafts do not). A removed child still counts if it was ever
adopted and is still planning; a removed proposed child is a discarded draft
and does not, so undoing a split and discarding its drafts leaves room to split
again. Restoring a discarded draft into a parent that already holds 10 returns
409, as does an eleventh child. Discarded drafts are retained rows: they
keep counting against the per-session story quota for the life of the room.
When nonretired links touch a removed/restored child, also supply
expectedDependencyRevision; removal requires affectedDependencies to enumerate
exactly those link ids acknowledged for review. A missing/stale acknowledgment
returns 409. Removal keeps unavailable endpoints and intent. Undo validates the
available graph; unsafe restored links stay review-needed, preserving their ids.
An adopted split cannot lose its last two planning children or its named remainder;
undo-split is the way back to one story.
Removal/restoration of an adopted child requires coverage review again. No remote
issue is deleted or rolled back. Dependency editing and receipt delivery are
separate contracts and are not implemented by these actions.
PATCH .../actions/story accepts expectedRevision. It is required for a child
or a parent with a drafted split; flat historical callers may omit it. Stale
content or split writes return 409 without overwriting either input. Clients
must retain their authorized unsent text, fetch latest state and offer deliberate
latest/mine review before submitting a new revision; quota refusals likewise
must not discard input. Loss of facilitation pauses writes; loss of reading
authority redacts the draft. This is a client contract for the forthcoming tree
editor, not an offline write queue.
The wire adds parentId, planningRole, contentRevision, splitRevision,
creationOperation, removedAt, isRemainder and coverage to each story.
Only unremoved planning stories count or can be dealt. Draft children are
proposed; an adopted parent is context with its historical Previous estimate.
Closed rooms reject all mutations with 409 and use the normal reopen path.
| Method | Path | Who | Purpose |
|---|---|---|---|
| POST | /api/sessions/{id}/actions/stories | facilitator | Add a story. Needs a ref or a title, either alone is enough; title ≤200, notes ≤2000, ref ≤40. |
| PATCH | /api/sessions/{id}/actions/story | facilitator | Edit {storyId}: title, notes, ref, position or estimate. An edit that would leave a story with neither a ref nor a title is refused. An estimate is validated against the session's deck; empty clears it. |
| POST | /api/sessions/{id}/actions/vote | member | Cast or change your vote on {storyId}. Refused for spectators. When session config autoReveal is true, triggers the auto-reveal check. |
| POST | /api/sessions/{id}/actions/select | facilitator | Put a story on the table and clear the reveal. |
| POST | /api/sessions/{id}/actions/reveal | facilitator | Open the round. |
| POST | /api/sessions/{id}/actions/reset | facilitator | Clear votes on the current story. |
| PATCH | /api/sessions/{id}/actions/config | facilitator | Partial update of {autoReveal, openVoting}, both default false. A key the body leaves out keeps its value, an empty body changes nothing, an unknown key is 400. Turning autoReveal on mid-round with a full table runs the check immediately; turning it off never un-reveals. Turning openVoting on mid-round records the expected voters for the story already on the table. |
| POST | /api/sessions/{id}/spectator | member | Toggle your own spectator seat with {on}. |
Daily standup
Section titled “Daily standup”| Method | Path | Who | Purpose |
|---|---|---|---|
| PUT | /api/sessions/{id}/actions/standup | member | Upsert your own yesterday / today / blockers. Each ≤2000 characters. |
| PUT | /api/sessions/{id}/actions/ready | member | Set your own readiness with {ready}. Advisory only — nothing gates on it, and it does not affect the speaking order or the export. |
| POST | /api/sessions/{id}/actions/add | member | Open a commitment of your own with {text}, 1–500 characters. It carries across standups in this space until you answer it. |
| POST | /api/sessions/{id}/actions/answer | member | Answer one of your own open commitments with {id, done}. done true closes it and it leaves the list; false increments its carry count and it stays. An id that is not yours, or is already closed, is 404. |
| POST | /api/sessions/{id}/actions/remove | member | Withdraw one of your own open commitments with {id}. An id that is not yours, or is already closed, is 404. |
| POST | /api/sessions/{id}/actions/drop | member | Close one of your own open commitments as dropped with {id}. It leaves the list like a landed one, its carry count is untouched, and it is recorded as dropped, never landed. An id that is not yours, or is already closed, is 404. |
| PUT | /api/sessions/{id}/actions/mention | member | Ask another member of the space for help with a blocker, with {to, needed}: to is a user id, needed true records the mention and false withdraws it. Idempotent: a repeat, or withdrawing a mention that does not exist, is 204 and changes nothing, not even the session version. 403 for a link guest caller; 409 in a sync standup, which never shows a mention; 400 when to is yourself (in any letter case), not a current member of the space, a link guest, or not a user id in 8-4-4-4-12 hex form. The mention never enters the session state. |
| POST | /api/sessions/{id}/actions/kudo | member | Thank somebody in the room with {to, text}, 1–280 characters counted in runes. The kudo lands on the space's kudos wall carrying this session, and in the session state as {id, fromUserId, toUserId, text}. 403 for a link guest — the action checks the space's membership itself, because the speaking roster deliberately includes guests and nothing before dispatch does. 400 for yourself or a non-member recipient, 409 over the per-space cap. |
| GET | /api/sessions/{id}/mentions | member | The caller's own mentions in this room, as {needsYou, asked}: the user ids who asked the caller for help, and the user ids the caller asked. Served per caller because the session state is broadcast to the whole room. 403 for a link guest. Empty lists for a room with none, including a poker room. |
| POST | /api/sessions/{id}/actions/start | facilitator | Snapshot the roster into a speaking order and prefill yesterday from the last standup. |
| POST | /api/sessions/{id}/actions/next | facilitator | Advance to the next speaker. |
| POST | /api/sessions/{id}/actions/skip | facilitator | Mark the current speaker skipped and advance. |
Plugin UI
Section titled “Plugin UI”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/sessions/{id}/plugins/panels | member | The room’s org’s enabled installs that ship UI, with their grants and declared slots (panel, room, toolbar, nav, export-menu). A missing slots sidecar is the nested panel. A link guest never sees nav; export-menu is included only when this kind already exports. Scoped to the room’s own org. Empty on an instance with no plugins. |
| GET | /api/orgs/{org}/plugins/panels | member | Nav-slot plugin UI for this org. RequireUser then requireOrgMember — a link guest is 401. Only installs that declared nav are listed. |
| GET | /plugin-ui/{name}/{version} | public | The sandboxed document a plugin's UI runs in. 404 when the plugin ships no UI bundle, stored or in PLUGIN_DIR. |
/plugin-ui/… is the one route that does not send X-Frame-Options: DENY —
being framed is its whole purpose. It lives in its own route group with its own
header profile: frame-ancestors 'self' bounds who may embed it, and
default-src 'none'; connect-src 'none' means the document cannot make a
network request of any kind. See
the plugin sandbox.
Session actions accept an optional X-Parley-Plugin-Route header naming the
plugin panel an action was proposed from. It grants nothing — the request is
authorized as the signed-in user exactly as any other — and is recorded in
org_audit_log only when it names a plugin this instance actually runs.
WebSocket
Section titled “WebSocket”| Method | Path | Who | Purpose |
|---|---|---|---|
| WS | /ws?session={id} | member | Live session state. Membership is checked before the upgrade; a non-member gets 404. |
The socket is push-only in practice: clients send effectively nothing (frames are capped at 4096 bytes) and make changes over HTTP. On attach, and after every mutation, the server pushes the whole session envelope, redacted for that viewer — there are no incremental patches, and an unrevealed vote is absent from the payload rather than hidden by the client.
Redaction is per viewer, not per room. A guest joining by
link is served a copy with spaceSlug empty and
participants cut to the people taking part in that meeting — plus the guest’s
own seat, which is always present even on a first GET made before their socket
connects — on the socket and on GET /api/sessions/{id} alike. A space member’s envelope is the full one.
Every participants entry carries guest: true for a seat held by a signed
link, false for a space member. A guest may pick any display name, a member’s
included, so this is the only reliable way to tell the two apart.
The Origin header must match BASE_URL. An empty Origin is allowed, so
that non-browser clients can connect.
The authenticated token is checked in the shared store at least every 30
seconds without refreshing its idle lifetime. Revocation, expiry, or validation
failure closes the socket with WebSocket policy code 1008; logout disconnects
the token’s sockets synchronously.
A facilitator removing someone from a room closes only that person’s sockets for
that room, with the application close code 4001 and the facilitator’s message
as the close reason. It is a distinct code from 1008 on purpose: 1008 means
the space is gone for them, 4001 means this meeting is, and they may rejoin.
Anything else
Section titled “Anything else”An unmatched path under /api/ returns a plain-text 404 page not found,
not JSON — worth knowing if your client parses every response body as JSON.
Everything else falls through to the single-page app, so an unknown path outside
/api/ returns the frontend with a 200 rather than a 404.