Skip to content

HTTP API

Verified against v0.10.0 · internal/api/router.go, internal/api/spaces.go, internal/api/dispatch.go, internal/api/me.go, internal/poker/routes.go, internal/standup/routes.go, internal/api/links.go, internal/api/plugins.go, internal/api/pluginpanels.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.10.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. Cross-org, so it stays un-prefixed. Never the passcode.
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 passcode. 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 passcode: visibility governs discovery, not entry.
GET/api/orgs/{org}/spaces/{slug}publicMembers get roster, sessions, passcode, and the kinds a new session may use. Each session carries here: how many people have a socket open on it right now, 0 once it has ended. 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, so it names none and 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 passcode. 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 passcode, 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, at most a hundred: id, fromUserId, toUserId, text, sessionId, createdAt. 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.
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.
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 — orgprivate is allowed here, privateorg 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.
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 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, enabled, described grants, session kinds provided, any pending upgrade as a diff, and health. Also reports whether a plugin host is running at all (PLUGIN_DIR) and whether plugin secrets are available.
POST/api/orgs/{org}/admin/plugins/previeworg adminDescribe what an uploaded package would be permitted, without installing anything. Every fetch allowlist entry is expanded into worked examples produced by the guard's own matching. package.kinds is echoed as kinds so the consent screen can name the session ceremonies the package declares. For a package 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 {package, grantsAccepted}; grantsAccepted must be true, so a client that never showed a consent screen cannot install by omission. package.kinds is the session kinds the plugin provides (kind, display, actions); they are written on first install and on upgrade in the same transaction as the version bump, and 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 and the grants in force are unchanged and the request parks as pending.
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. 404 for an install this org does not own, or one that does not exist — the two are deliberately indistinguishable.
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 404 rather than a guess.

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.

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 PLUGIN_DIR is unset or the plugin ships no UI bundle.

/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 authorised 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.