Skip to content

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.

MethodPathWhoPurpose
GET/healthzpublicLiveness. Always 200, never touches the database.
GET/readyzpublicReadiness. 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/versionpublicThe 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.

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.

MethodPathWhoPurpose
GET/auth/loginpublicBegin the authorization code + PKCE flow. oidc mode only.
GET/auth/callbackpublicProvider redirect. Verifies state, nonce, signature, audience and expiry, then issues a session.
MethodPathWhoPurpose
GET/api/authpublicReport which auth mode this instance runs — open or oidc. The frontend uses it to pick a sign-in path.
GET/api/mesessionCurrent 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/mepublicCreate 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/avatarsessionSet the caller's chosen avatar. Permitted in both auth modes.
PATCH/api/me/settingssessionSet notificationSounds for the caller without rotating the session token. Ordinary open-mode and OIDC users only; signed-link guests receive 403.
DELETE/api/mesessionSign 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/icssessionWhether 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/icssessionMint 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/icssessionRevoke the personal calendar feed. 204. The previous URL then answers 404. A link guest gets 403.
GET/api/me/awaysessionThe 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/awaysessionAdd 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}sessionRemove 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}tokenThe 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.

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.

MethodPathWhoPurpose
GET/api/orgsuserThe 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/spacesuserThe 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}/spacesmemberThe 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}/visibilityownerSet {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}publicMembers 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/spacesuserCreate 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}/joinuserJoin, 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}/invitepublicTrade {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}/seenuserStamp 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}/passcodememberMint a new room code, or with {open:true} remove it. Any member may do this.
POST/api/orgs/{org}/spaces/{slug}/sessionsmemberCreate 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}/decksmemberThis 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}/decksownerSave 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}/kudosmemberThis 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}/kudosmemberGive 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/answerKudomemberAnswer 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}senderWithdraw 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}/answerrecipientAnswer 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}/answerrecipientWithdraw 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}ownerRewrite 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-schedulememberThe 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-scheduleownerCreate 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-trendmemberThe 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-webhookownerThe 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-webhookownerSet 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-webhookownerRemove the webhook. 204. Undelivered events are dropped on their next attempt.
DELETE/api/orgs/{org}/spaces/{slug}/decks/{deckId}ownerDelete 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}ownerRename the space: {name}, 1-64 characters. The slug never moves with it, so invites already sent keep working.
DELETE/api/orgs/{org}/spaces/{slug}ownerDelete 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}ownerRename 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}ownerDelete 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}/roleownerPromote 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}ownerRemove a member. Access is revoked on their next request. 409 if they are the last owner.

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.

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.

MethodPathWhoPurpose
GET/api/orgs/{org}/admin/spacesorg adminEvery 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 adminRename 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 adminDelete the space and everything under it. Irreversible, and audit-logged.
POST/api/orgs/{org}/admin/spaces/{slug}/ownersorg adminGrant 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}/claimorg adminTake 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/membersorg adminThe org's membership, revoked rows included: userId, name, role, revokedAt.
POST/api/orgs/{org}/admin/members/{userId}/roleorg adminSet {role} to admin or member. 409 if it would leave the org with no admin.
DELETE/api/orgs/{org}/admin/members/{userId}org adminRevoke 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}/restoreorg adminLift 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/auditorg adminThis 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/settingsorg adminThis org's audit retention: {retentionDays}, null for forever (the default).
PATCH/api/orgs/{org}/admin/audit/settingsorg adminSet {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 adminPurge 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.

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.

MethodPathWhoPurpose
GET/api/catalogorg memberEvery 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/loadeddefault-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/bundlesdefault-org adminUpload 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.

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.

MethodPathWhoPurpose
GET/api/orgs/{org}/admin/pluginsorg adminEverything 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/previeworg adminDescribe 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/pluginsorg adminInstall, 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}/rollbackorg adminMove 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}/settingsorg adminThe 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}/settingsorg adminReplace 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}/upgradeorg adminApprove 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}/enabledorg adminSwitch 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 adminUninstall. 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/themesorg adminRecord 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/themesorg adminRecord 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.

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.

MethodPathWhoPurpose
GET/s/{slug}public302 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.

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.

MethodPathWhoPurpose
GET/api/sessions/{id}memberThe session envelope, redacted for the caller.
GET/api/sessions/{id}/export.csvmemberCSV 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/claimmemberClaim the facilitator role after the 60s grace period. Any member.
POST/api/sessions/{id}/facilitatorfacilitatorHand the role to {userId}. Distinct from the claim route above.
POST/api/sessions/{id}/participants/{userId}/removefacilitatorRemove {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}facilitatorClose 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}/reopenfacilitatorReopen a closed session.
POST/api/sessions/{id}/spectatormemberToggle 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}/linksfacilitatorMint 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}/linksmemberList this room's links: ids, expiry, revocation and redemption counts. Never tokens.
DELETE/api/sessions/{id}/links/{linkId}facilitatorRevoke a link. Idempotent — a second call is another 204. The row stays, marked revoked, and everyone who joined through it loses their session immediately.
MethodPathWhoPurpose
POST/api/links/redeempublicTrade 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.

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.

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.

MethodPathWhoPurpose
POST/api/sessions/{id}/actions/storiesfacilitatorAdd a story. Needs a ref or a title, either alone is enough; title ≤200, notes ≤2000, ref ≤40.
PATCH/api/sessions/{id}/actions/storyfacilitatorEdit {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/votememberCast 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/selectfacilitatorPut a story on the table and clear the reveal.
POST/api/sessions/{id}/actions/revealfacilitatorOpen the round.
POST/api/sessions/{id}/actions/resetfacilitatorClear votes on the current story.
PATCH/api/sessions/{id}/actions/configfacilitatorPartial 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}/spectatormemberToggle your own spectator seat with {on}.
MethodPathWhoPurpose
PUT/api/sessions/{id}/actions/standupmemberUpsert your own yesterday / today / blockers. Each ≤2000 characters.
PUT/api/sessions/{id}/actions/readymemberSet 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/addmemberOpen 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/answermemberAnswer 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/removememberWithdraw 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/dropmemberClose 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/mentionmemberAsk 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/kudomemberThank 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}/mentionsmemberThe 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/startfacilitatorSnapshot the roster into a speaking order and prefill yesterday from the last standup.
POST/api/sessions/{id}/actions/nextfacilitatorAdvance to the next speaker.
POST/api/sessions/{id}/actions/skipfacilitatorMark the current speaker skipped and advance.
MethodPathWhoPurpose
GET/api/sessions/{id}/plugins/panelsmemberThe 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/panelsmemberNav-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}publicThe 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.

MethodPathWhoPurpose
WS/ws?session={id}memberLive 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.

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.