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.
Health
Section titled “Health”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /healthz | public | Liveness. Always 200, never touches the database. |
| GET | /readyz | public | Readiness. Pings Postgres with a 3s timeout, then checks this replica's notify listener; 503 if the database is unreachable or the listener is down. |
| GET | /version | public | The build this instance is running. Always 200, never touches the database. |
/version answers with the build string and nothing else:
{ "version": "0.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.
Sign-in
Section titled “Sign-in”These live outside /api on purpose: they are browser navigations arriving from
the identity provider’s domain, so the cross-site and JSON-body guards would
reject them. Their own protection is the state value in the sign-in cookie.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /auth/login | public | Begin the authorization code + PKCE flow. oidc mode only. |
| GET | /auth/callback | public | Provider redirect. Verifies state, nonce, signature, audience and expiry, then issues a session. |
Identity
Section titled “Identity”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/auth | public | Report which auth mode this instance runs — open or oidc. The frontend uses it to pick a sign-in path. |
| GET | /api/me | session | Current identity: id, name, avatar hue, chosen avatar icon, and notificationSounds. A link guest always receives notificationSounds: false; it also receives linkSessionId and linkExpiresAt. |
| POST | /api/me | public | Create or rename an anonymous identity, rotating the session token. Refused with 403 in oidc mode. A rename whose cookie was already rotated away answers 401 {error: session ended} and clears the cookie — the same as GET /api/me for a cookie that no longer resolves — rather than 500 or a new identity. |
| PATCH | /api/me/avatar | session | Set the caller's chosen avatar. Permitted in both auth modes. |
| PATCH | /api/me/settings | session | Set notificationSounds for the caller without rotating the session token. Ordinary open-mode and OIDC users only; signed-link guests receive 403. |
| DELETE | /api/me | session | Sign out. Deletes the caller's own token row, synchronously closes its WebSockets with code 1008, and clears the cookie. Open to a link guest — it is how a guest leaves the room and stops the cookie outliving the visit. |
| GET | /api/me/ics | session | Whether the caller has a personal standup calendar, as {active} and, when active, remindMinutes. Never the token or the URL. A link guest gets 403. |
| POST | /api/me/ics | session | Mint or replace the personal calendar feed: {remindMinutes} from 0 to 1440. Answers {url, remindMinutes}. The url holds the token and is returned only here. A link guest gets 403. |
| DELETE | /api/me/ics | session | Revoke the personal calendar feed. 204. The previous URL then answers 404. A link guest gets 403. |
| GET | /api/me/away | session | The caller's own away days as <code>{ranges: [{id, startsOn, endsOn}]}</code>, dates as YYYY-MM-DD, earliest first. A link guest gets 403. |
| POST | /api/me/away | session | Add an inclusive away range: <code>{startsOn, endsOn}</code>. 201 with the range. 400 for an unknown field, a date that is not YYYY-MM-DD, a last day before the first, a range longer than 90 days, or a start more than 30 days ago or more than a year ahead. 409 when the caller already holds 20 ranges. A link guest gets 403. |
| DELETE | /api/me/away/{id} | session | Remove one of the caller's own ranges. 204. Anybody else's id, or an id that exists nowhere, is 404. A link guest gets 403. |
| GET | /ics/{token} | token | The caller's standup calendar (text/calendar). No cookie. A missing or revoked token is 404. Lists async windows that are still open and scheduled slots for the next 14 days, each with one alarm at the minted offset. The description is only the link. |
PATCH /api/me/settings requires an explicit boolean and answers with the same
identity body as GET /api/me:
{ "notificationSounds": true }Missing, null, and non-boolean values are 400. The setting is private to the identity response: it is not included in member rosters, participant lists, or plugin session payloads.
Choosing an avatar
Section titled “Choosing an avatar”PATCH /api/me/avatar takes one id and replaces the choice:
{ "icon": "fox" }It answers 200 with the same body as GET /api/me. An omitted or empty icon
clears the choice, so {} removes the avatar entirely.
avatarHue is unrelated and is not settable: it stays derived from the user id,
and is always an integer on every surface that carries a person.
The server never enumerates icon ids — it validates their shape only, so the
picker can grow without a server release. The id must match
^[a-z0-9-]{1,32}$ when present.
| Status | When |
|---|---|
| 400 | An id is present and does not match the pattern; nothing is written. |
| 400 | The body is not valid JSON. |
| 401 | No valid session cookie. Unlike POST /api/me, this never creates a user. |
| 415 | The body is not declared application/json. |
Unlike POST /api/me, this does not rotate the session token, and it is not
refused under AUTH_MODE=oidc: the identity provider owns names, not avatars.
Nothing is pushed to other participants — they see the change on their next
session envelope.
Spaces
Section titled “Spaces”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs | user | The orgs the caller belongs to: slug, name, role. Cross-org and un-prefixed by design — it is what a client asks before it knows an org to ask within. An empty array is a real answer: an account whose identity-provider claims matched no org belongs nowhere. |
| GET | /api/spaces | user | The caller's own memberships, most recently active first: slug, orgSlug, name, whether it is protected. Cross-org, so it stays un-prefixed. Never the passcode. |
| GET | /api/orgs/{org}/spaces | member | The org directory: every space in this org with visibility 'org', plus every space the caller belongs to — slug, name, visibility, whether it is protected, and whether the caller is already a member. Never the passcode. Paged: see below. Someone outside the org gets 404, not an empty list. |
| PATCH | /api/orgs/{org}/spaces/{slug}/visibility | owner | Set {visibility} to 'org' to list the space in the org directory, or 'private' to take it back out. 204. On an instance without sign-in configured, 'org' is refused with 403 — every visitor would be in the org. It never touches the passcode: visibility governs discovery, not entry. |
| GET | /api/orgs/{org}/spaces/{slug} | public | Members 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/spaces | user | Create a space. Protected by default. Un-prefixed like the list above, and for the same reason — it is the landing page's route, called before an org is in the URL, so it names none and the space lands in the default org. |
| POST | /api/orgs/{org}/spaces/{slug}/join | user | Join, sending {passcode} — or {handle} from the invite route below — if the space is protected. Throttled at 8 attempts per 60s per address. |
| POST | /api/orgs/{org}/spaces/{slug}/invite | public | Trade {passcode} for {handle}: an opaque, single-use capability on this one space, good for five minutes, that a visitor can carry across an identity-provider sign-in instead of the 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}/seen | user | Stamp the caller's membership as recently active, which is what orders the landing list. 204, and a silent no-op for a non-member — it never creates a membership. |
| POST | /api/orgs/{org}/spaces/{slug}/passcode | member | Mint a new passcode, or with {open:true} remove it. Any member may do this. |
| POST | /api/orgs/{org}/spaces/{slug}/sessions | member | Create a session: kind, title, config. A poker config's deck is either a built-in name or a card list — {name, values, ordinal} of 2-15 cards, each 1-8 characters, no duplicates, none of them `?` or `coffee`, a name that does not reuse a built-in deck's unless its cards are exactly that deck's, and all of them finite numbers unless the deck is ordinal. Anything else is a 400, and the cards are stored with the session, so a deck cannot change under votes already cast. |
| GET | /api/orgs/{org}/spaces/{slug}/decks | member | This space's saved decks: id, name, cards, ordinal, createdAt. Membership of the space itself, not of the org around it — a non-member gets the same 404 a stranger gets, so deck names cannot be used to enumerate a private space. |
| POST | /api/orgs/{org}/spaces/{slug}/decks | owner | Save a deck: {name, cards, ordinal}. The cards follow exactly the rule the session-create route above applies, and it is the same code — a deck that saves is a deck a session can be created from. 409 over the per-space cap, and 409 if the space already has a deck by that name. |
| GET | /api/orgs/{org}/spaces/{slug}/kudos | member | This space's kudos, newest first, 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}/kudos | member | Give a kudo: {to, text}. Text is 1 to 280 characters, counted in runes. 400 if the recipient is yourself, or is not a member of the space — a link guest holds no membership, so it neither sends nor receives. 409 over the per-space cap. |
| DELETE | /api/orgs/{org}/spaces/{slug}/kudos/{id} | sender | Withdraw a kudo you sent. 204, 403 for a member who is not the sender, 404 if this space has no such kudo. Nobody can edit one: text somebody has already read is never rewritten underneath them. |
| PATCH | /api/orgs/{org}/spaces/{slug}/decks/{deckId} | owner | Rewrite a saved deck, same body and same rules as creating one. Sessions already created from it keep the cards they copied. A deck belonging to another space is a 404, whatever the caller owns. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-schedule | member | The space's standup schedule as <code>{schedule}</code>, or <code>{schedule: null}</code> when it has none. A link guest gets 403. |
| PUT | /api/orgs/{org}/spaces/{slug}/standup-schedule | owner | Create or replace the schedule: <code>{weekdays, openTime, timezone, windowMinutes, enabled}</code>. weekdays are 0 (Sunday) to 6; openTime is "HH:MM"; timezone is an IANA name that both Parley and Postgres know, so <code>Local</code>, <code>localtime</code> and <code>posix/…</code> or <code>right/…</code> names are refused; windowMinutes is 1–1440. 400 for any invalid field, including an unknown time zone; 403 for a member who is not an owner. Changes reach future slots only. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-trend | member | The team participation trend: <code>{weeks: [{weekStart, ratio}]}</code> for the last 12 completed Monday-to-Sunday weeks, bucketed by each scheduled slot's local date, oldest first. Sums only the frozen per-day counts of scheduled standups; a day under four eligible people (non-spectator members, not link guests, not away) is dropped. ratio is rounded to one decimal. A week is <code>{weekStart, suppressed: true}</code> with no ratio when no day in it is left. Saves, once, any past scheduled day not yet frozen before reading. No parameters: any query string is ignored. Never carries names, ids or counts. 404 outside the space; a link guest gets 403. |
| GET | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | The space's standup webhook as <code>{url}</code>, or <code>{url: null}</code>. The secret is never returned here. 403 for a member who is not an owner, and for a link guest. |
| PUT | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | Set the webhook: <code>{url}</code>. Answers <code>{url, secret}</code>; the secret is shown in this response only, and every PUT mints a new one. 400 unless the url is https, names a host rather than an IP address, carries no credentials, and matches <code>STANDUP_WEBHOOK_HOSTS</code>. 503 without <code>PLUGIN_SECRET_KEY</code>. |
| DELETE | /api/orgs/{org}/spaces/{slug}/standup-webhook | owner | Remove the webhook. 204. Undelivered events are dropped on their next attempt. |
| DELETE | /api/orgs/{org}/spaces/{slug}/decks/{deckId} | owner | Delete a saved deck. 204, or 404 if this space has no such deck. Rooms created from it keep working: a session stores the cards it was created with, so nothing joins back to this row. |
| PATCH | /api/orgs/{org}/spaces/{slug} | owner | Rename the space: {name}, 1-64 characters. The slug never moves with it, so invites already sent keep working. |
| DELETE | /api/orgs/{org}/spaces/{slug} | owner | Delete the space and everything under it — sessions, stories, standups, memberships. Irreversible, and it closes the sockets on every open room. |
| PATCH | /api/orgs/{org}/spaces/{slug}/sessions/{id} | owner | Rename a session in this space: {title}, 1-200 characters. Bumps the version, so everyone in the room sees it without a reload. |
| DELETE | /api/orgs/{org}/spaces/{slug}/sessions/{id} | owner | Delete a session and its history. Irreversible, and unlike closing it is not idempotent: a second call is 404. Distinct from DELETE /api/sessions/{id}, which closes. |
| POST | /api/orgs/{org}/spaces/{slug}/members/{userId}/role | owner | Promote or demote: {role} is owner or member. 409 if it would leave the space with no owner. |
| DELETE | /api/orgs/{org}/spaces/{slug}/members/{userId} | owner | Remove a member. Access is revoked on their next request. 409 if they are the last owner. |
Paging the org directory
Section titled “Paging the org directory”GET /api/orgs/{org}/spaces answers with one page, never the whole org:
{ "spaces": [ { "slug": "platform-team", "name": "Platform Team", "visibility": "org", "protected": true, "member": false } ], "next": "UGxhdGZvcm0gVGVhbQBwbGF0Zm9ybS10ZWFt"}| Parameter | Default | Maximum | Meaning |
|---|---|---|---|
limit |
50 | 200 | Rows on this page. An ask above the maximum is clamped, not refused. |
after |
— | — | The next from the previous page. Omit it for the first page. |
next is present only while there is another page, so “no next” is the end
of the list. It is an opaque cursor: it encodes the sort key of the last row on
the page — the directory is ordered by name, with the slug breaking ties — and
a client must hand it back unread. A value this build did not issue is refused
with 400 rather than quietly restarting from the top, and a limit that is not
a positive whole number is 400 too.
Paging is by cursor and not by offset because the set moves while somebody is reading it. An offset re-counts the list on every request, so a space created ahead of the reader pushes a row across the page boundary and it is never seen, and one archived behind them shows a row twice. A cursor names a position, so neither can happen.
The scope rule holds page by page and is not a filter the client applies: a private space the caller is not a member of never appears on any page, and archived spaces appear on none.
Org custody
Section titled “Org custody”An org admin manages the spaces in their org — including private ones they are
not a member of — and can read nothing said inside them. Custody responses
carry an explicit allow-list of fields (id, slug, name, ownerIds,
visibility, memberCount, archivedAt) and nothing else: no roster, no
presence, no sessions, no votes, no standup entries. The handlers behind them
live in a package that does not link the session, presence, hub or store
packages at all, so the limit is a build-time fact rather than a convention.
Two rules make custody stop short of access. Custody may only make a space
more private — org → private is allowed here, private → org is
refused with 403 and remains the space owner’s alone. And ownership is
granted, never transferred: an admin may promote somebody who is already a
member of the space, may not name themself or any non-member, and never
demotes an incumbent owner.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs/{org}/admin/spaces | org admin | Every space in the org, private and archived ones included, as metadata only: id, slug, name, ownerIds (all of them — a space can have several), visibility, memberCount, archivedAt. |
| PATCH | /api/orgs/{org}/admin/spaces/{slug} | org admin | Rename with {name}, archive or restore with {archived}, and narrow with {visibility:'private'}. 403 on {visibility:'org'}: custody can only make a space more private. Archiving takes a space out of the org directory and does nothing else — its members, history and URL are untouched. |
| DELETE | /api/orgs/{org}/admin/spaces/{slug} | org admin | Delete the space and everything under it. Irreversible, and audit-logged. |
| POST | /api/orgs/{org}/admin/spaces/{slug}/owners | org admin | Grant ownership to {userId}, who must already be a member of that space. Additive: existing owners keep their role. 403 if the admin names themself or anybody who is not a member — that would be a way into a space they cannot read, through the ordinary member routes. |
| POST | /api/orgs/{org}/admin/spaces/{slug}/claim | org admin | Take ownership of a space nobody is left in. 409 while any member remains. This is the one action that makes an admin a member of a space they were not in, and it writes an audit record every time it succeeds. |
| GET | /api/orgs/{org}/admin/members | org admin | The org's membership, revoked rows included: userId, name, role, revokedAt. |
| POST | /api/orgs/{org}/admin/members/{userId}/role | org admin | Set {role} to admin or member. 409 if it would leave the org with no admin. |
| DELETE | /api/orgs/{org}/admin/members/{userId} | org admin | Revoke somebody from the org and from every space in it, in one transaction. Where they were a space's sole owner it promotes the most recently active remaining member; where there is nobody to promote it refuses with 409 and names those spaces, and writes nothing at all. The tombstone is an upsert, so somebody with no membership row yet can be revoked before their first sign-in. Their open sockets close on every replica. |
| POST | /api/orgs/{org}/admin/members/{userId}/restore | org admin | Lift a revocation, so the next sign-in enrols them again if their claim still matches. Without it a mis-click would be permanent. |
| DELETE | /api/orgs/{org} | org admin | Purge the org: every space, every session, and the org row itself, in one transaction. Send {confirm} matching the org's own slug; without it the call is 400 and reports the exact space and session counts it would destroy, read inside the transaction that would have done it. Irreversible, and the audit records survive it. The default org cannot be purged. |
Plugin administration
Section titled “Plugin administration”The operator’s surface over what is installed and what it is allowed to do. It sits behind the same three-deep gate as custody — signed in, a member of the org, and an admin of it — so an ordinary member gets 403 on every route below, whatever the frontend chose to draw.
Consent copy is written by the server, not the client: permits, allows and
refuses on every described grant come from internal/plugin, beside the guard
that enforces them, so the sentence an operator agrees to and the rule the host
applies cannot drift apart.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/orgs/{org}/admin/plugins | org admin | Everything installed in this org — and only this org: version, 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/preview | org admin | Describe 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/plugins | org admin | Install, 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}/upgrade | org admin | Approve a pending upgrade. Body must be {approve:true}; an empty body is 400 and changes nothing. This is the only thing that puts a wider grant set into force. 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}/enabled | org admin | Switch a plugin off or back on with {enabled}. Reversible, and it destroys nothing; disabling also evicts the compiled module rather than leaving it resident. 404 for an install this org does not own. |
| DELETE | /api/orgs/{org}/admin/plugins/{id} | org admin | Uninstall. Irreversible: it cascades to the plugin's grants, key-value store, deliveries, jobs and encrypted secrets. 409 while any session of a kind the plugin provides still exists, and the refusal names those kinds and their counts. The check, the retirement of the kinds it provided, the delete and the audit row are one transaction, so a refusal changes nothing and a completed uninstall cannot be unlogged. 404 for an install this org does not own. |
| POST | /api/orgs/{org}/admin/plugins/themes | org admin | Record that a theme pack was applied. A pack never reaches the server — it is a value map applied in the browser — so this writes the audit row and nothing else, including whether the contrast gate was overridden. |
| DELETE | /api/orgs/{org}/admin/plugins/themes | org admin | Record a reset to the built-in palette. |
POST /api/orgs/{org}/admin/plugins/dev-register exists only in a binary built
with -tags plugindev. It is absent from the default build — not hidden behind
an environment variable — so it cannot become a verification-bypassing install
path in production. See Plugin SDK.
Every install, upgrade request, upgrade approval, disable, re-enable, uninstall
and theme change writes a row to the same org_audit_log an admin’s custody
actions land in, naming the acting user.
Links shared before spaces carried an org
Section titled “Links shared before spaces carried an org”Space URLs used to be /s/{slug}, with no org in them. That path is still
served, as a redirect, so links shared before the change keep working.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /s/{slug} | public | 302 to /o/{org}/s/{slug} when exactly one of the caller's own orgs holds a space with this slug. Anonymous visitors and guests holding a signed link get the app itself instead, so the client can put them through sign-in. A signed-in caller whose orgs hold no such space — or more than one, which repeating slugs across orgs now makes possible — gets 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.
Sessions
Section titled “Sessions”Every route below requires membership of the session’s space, and answers 404 rather than 403 otherwise — including for anonymous callers — so a session’s existence is never disclosed.
| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/sessions/{id} | member | The session envelope, redacted for the caller. |
| GET | /api/sessions/{id}/export.csv | member | CSV of the session, rendered from the same redacted view. A plugin kind exports that view's state fields; a kind with no exporter is 404. |
| POST | /api/sessions/{id}/facilitator/claim | member | Claim the facilitator role after the 60s grace period. Any member. |
| POST | /api/sessions/{id}/facilitator | facilitator | Hand the role to {userId}. Distinct from the claim route above. |
| POST | /api/sessions/{id}/participants/{userId}/remove | facilitator | Remove {userId} from this session only — their presence row goes and their sockets for this room close with code 4001. Space membership is untouched, so they stay in the space roster and may rejoin. An open round stops waiting for them; a vote they had already cast stays and still counts. Optional body {message}, shown to them on the way out and truncated to 123 bytes on a rune boundary. Removing yourself or the facilitator is 400. |
| DELETE | /api/sessions/{id} | facilitator | Close the session — it ends, its history stays, and it can be reopened. Idempotent. To discard one, see DELETE /api/orgs/{org}/spaces/{slug}/sessions/{id}, which is owner-only. |
| POST | /api/sessions/{id}/reopen | facilitator | Reopen a closed session. |
| POST | /api/sessions/{id}/spectator | member | Toggle your own spectator seat with {on}. A property of a member, not of a kind, so it is a core route rather than an action. |
| POST | /api/sessions/{id}/links | facilitator | Mint a signed link for this room. Refused once the room is closed. The reply carries the plain token exactly once — nothing can read it again. |
| GET | /api/sessions/{id}/links | member | List this room's links: ids, expiry, revocation and redemption counts. Never tokens. |
| DELETE | /api/sessions/{id}/links/{linkId} | facilitator | Revoke a link. Idempotent — a second call is another 204. The row stays, marked revoked, and everyone who joined through it loses their session immediately. |
Redeeming a signed link
Section titled “Redeeming a signed link”| Method | Path | Who | Purpose |
|---|---|---|---|
| POST | /api/links/redeem | public | Trade a link token for a guest identity in the one room it names: {token, name}. 201 with {sessionId, expiresAt, me} and a session-scoped cookie: the browser drops it when the browsing session ends, and the token behind it expires with the link. |
This is the only /api route open to a caller with no identity that is not
POST /api/me, and the only one a link guest may call.
It carries no org prefix, and neither does anything under /api/sessions/{id}
or the /session/{id} and /link pages that reach them. That is deliberate and
permanent: a link guest belongs to no org and no space, so it has no org slug to
put in a URL and no membership to derive one from. Adding a prefix here would
404 every signed link ever issued.
| Status | Meaning |
|---|---|
| 201 | Redeemed. The cookie carries no Max-Age, so closing the tab ends the seat; its token expires exactly when the link does. |
| 400 | name is missing or longer than 64 characters. |
| 404 | The link is wrong, expired, revoked, or has been redeemed its maximum number of times — the four are one answer, and the reply never repeats the submitted token back. |
| 429 | Either 8 wrong tokens from this address in 60s, or the hourly identity-creation limit. |
What redemption grants. An ordinary users row flagged link-bound, and
nothing but participate access to the one room the link names:
GET /api/sessions/{id} and the session actions that are not
facilitator-only — voting in poker, and in a standup, writing an update and
holding a place in the round. start, next and skip stay facilitator-only,
so a guest takes a turn but never runs the round. Every other route answers
401, 403 or 404 — the identity routes, the space view, the CSV export, the
facilitator controls, and the link routes themselves. A link grants no space
membership and no sight of any other room.
When it ends. The link’s expiry (24 hours) lives on the minted session token, so the socket is severed mid-session when it lapses. Revoking the link deletes those tokens outright and closes the sockets at once. Nothing is swept: the guest and their votes stay in the meeting’s history.
Session actions
Section titled “Session actions”Everything a session kind does goes through one dispatcher:
<VERB> /api/sessions/{id}/actions/{action}Each action declares its own verb, and the dispatcher routes on the pair.
Most actions are transitions on the session and take POST. Three are not: a
standup entry is an upsert of the caller’s own row, so it is
PUT /api/sessions/{id}/actions/standup; a standup readiness signal is an
upsert of the caller’s own row too, so it is
PUT /api/sessions/{id}/actions/ready; and a story edit is a partial update, so
it is PATCH /api/sessions/{id}/actions/story carrying {storyId} in the
body. The verb is listed against every action in the tables below.
The case for collapsing all of them onto POST is a fair one — every action is
a state transition on the session rather than a replacement of a resource at
that URL, and one verb spares clients this table. It was weighed and not taken:
an upsert is exactly what PUT is for, and folding it into POST throws away
the idempotency signal a client acts on, since a retried PUT is safe to send
and a retried POST is not.
A real action reached with the wrong verb is 405, with Allow naming
the verb that works. A name this kind has no action for is 404 under every
verb — the dispatcher decides both itself, because the action name is a path
parameter and the router cannot know which names are real.
No action may declare GET or HEAD; registering a kind that does is refused
at startup. Every action is a write, and the cross-site guard exempts GET on
the promise that a GET changes nothing.
The action name is resolved against this session’s own kind, so an action
belonging to another kind is a 404 rather than a route two kinds share. The
{id} in the path is the binding for everything the action touches: a storyId
in the body that belongs to a different session answers 404.
Membership is resolved before dispatch. Mutable facilitator and ended-session state is revalidated inside the transaction that performs each write. Any action against a closed session is 409; reopen it first.
Planning poker
Section titled “Planning poker”| Method | Path | Who | Purpose |
|---|---|---|---|
| POST | /api/sessions/{id}/actions/stories | facilitator | Add a story. Needs a ref or a title, either alone is enough; title ≤200, notes ≤2000, ref ≤40. |
| PATCH | /api/sessions/{id}/actions/story | facilitator | Edit {storyId}: title, notes, ref, position or estimate. An edit that would leave a story with neither a ref nor a title is refused. An estimate is validated against the session's deck; empty clears it. |
| POST | /api/sessions/{id}/actions/vote | member | Cast or change your vote on {storyId}. Refused for spectators. When session config autoReveal is true, triggers the auto-reveal check. |
| POST | /api/sessions/{id}/actions/select | facilitator | Put a story on the table and clear the reveal. |
| POST | /api/sessions/{id}/actions/reveal | facilitator | Open the round. |
| POST | /api/sessions/{id}/actions/reset | facilitator | Clear votes on the current story. |
| PATCH | /api/sessions/{id}/actions/config | facilitator | Partial update of {autoReveal, openVoting}, both default false. A key the body leaves out keeps its value, an empty body changes nothing, an unknown key is 400. Turning autoReveal on mid-round with a full table runs the check immediately; turning it off never un-reveals. Turning openVoting on mid-round records the expected voters for the story already on the table. |
| POST | /api/sessions/{id}/spectator | member | Toggle your own spectator seat with {on}. |
Daily standup
Section titled “Daily standup”| Method | Path | Who | Purpose |
|---|---|---|---|
| PUT | /api/sessions/{id}/actions/standup | member | Upsert your own yesterday / today / blockers. Each ≤2000 characters. |
| PUT | /api/sessions/{id}/actions/ready | member | Set your own readiness with {ready}. Advisory only — nothing gates on it, and it does not affect the speaking order or the export. |
| POST | /api/sessions/{id}/actions/add | member | Open a commitment of your own with {text}, 1–500 characters. It carries across standups in this space until you answer it. |
| POST | /api/sessions/{id}/actions/answer | member | Answer one of your own open commitments with {id, done}. done true closes it and it leaves the list; false increments its carry count and it stays. An id that is not yours, or is already closed, is 404. |
| POST | /api/sessions/{id}/actions/remove | member | Withdraw one of your own open commitments with {id}. An id that is not yours, or is already closed, is 404. |
| POST | /api/sessions/{id}/actions/drop | member | Close one of your own open commitments as dropped with {id}. It leaves the list like a landed one, its carry count is untouched, and it is recorded as dropped, never landed. An id that is not yours, or is already closed, is 404. |
| PUT | /api/sessions/{id}/actions/mention | member | Ask another member of the space for help with a blocker, with {to, needed}: to is a user id, needed true records the mention and false withdraws it. Idempotent: a repeat, or withdrawing a mention that does not exist, is 204 and changes nothing, not even the session version. 403 for a link guest caller; 409 in a sync standup, which never shows a mention; 400 when to is yourself (in any letter case), not a current member of the space, a link guest, or not a user id in 8-4-4-4-12 hex form. The mention never enters the session state. |
| POST | /api/sessions/{id}/actions/kudo | member | Thank somebody in the room with {to, text}, 1–280 characters counted in runes. The kudo lands on the space's kudos wall carrying this session, and in the session state as {id, fromUserId, toUserId, text}. 403 for a link guest — the action checks the space's membership itself, because the speaking roster deliberately includes guests and nothing before dispatch does. 400 for yourself or a non-member recipient, 409 over the per-space cap. |
| GET | /api/sessions/{id}/mentions | member | The caller's own mentions in this room, as {needsYou, asked}: the user ids who asked the caller for help, and the user ids the caller asked. Served per caller because the session state is broadcast to the whole room. 403 for a link guest. Empty lists for a room with none, including a poker room. |
| POST | /api/sessions/{id}/actions/start | facilitator | Snapshot the roster into a speaking order and prefill yesterday from the last standup. |
| POST | /api/sessions/{id}/actions/next | facilitator | Advance to the next speaker. |
| POST | /api/sessions/{id}/actions/skip | facilitator | Mark the current speaker skipped and advance. |
Plugin UI
Section titled “Plugin UI”| Method | Path | Who | Purpose |
|---|---|---|---|
| GET | /api/sessions/{id}/plugins/panels | member | The room’s org’s enabled installs that ship UI, with their grants and declared slots (panel, room, toolbar, nav, export-menu). A missing slots sidecar is the nested panel. A link guest never sees nav; export-menu is included only when this kind already exports. Scoped to the room’s own org. Empty on an instance with no plugins. |
| GET | /api/orgs/{org}/plugins/panels | member | Nav-slot plugin UI for this org. RequireUser then requireOrgMember — a link guest is 401. Only installs that declared nav are listed. |
| GET | /plugin-ui/{name}/{version} | public | The sandboxed document a plugin's UI runs in. 404 when 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.
WebSocket
Section titled “WebSocket”| Method | Path | Who | Purpose |
|---|---|---|---|
| WS | /ws?session={id} | member | Live session state. Membership is checked before the upgrade; a non-member gets 404. |
The socket is push-only in practice: clients send effectively nothing (frames are capped at 4096 bytes) and make changes over HTTP. On attach, and after every mutation, the server pushes the whole session envelope, redacted for that viewer — there are no incremental patches, and an unrevealed vote is absent from the payload rather than hidden by the client.
Redaction is per viewer, not per room. A guest joining by
link is served a copy with spaceSlug empty and
participants cut to the people taking part in that meeting — plus the guest’s
own seat, which is always present even on a first GET made before their socket
connects — on the socket and on GET /api/sessions/{id} alike. A space member’s envelope is the full one.
Every participants entry carries guest: true for a seat held by a signed
link, false for a space member. A guest may pick any display name, a member’s
included, so this is the only reliable way to tell the two apart.
The Origin header must match BASE_URL. An empty Origin is allowed, so
that non-browser clients can connect.
The authenticated token is checked in the shared store at least every 30
seconds without refreshing its idle lifetime. Revocation, expiry, or validation
failure closes the socket with WebSocket policy code 1008; logout disconnects
the token’s sockets synchronously.
A facilitator removing someone from a room closes only that person’s sockets for
that room, with the application close code 4001 and the facilitator’s message
as the close reason. It is a distinct code from 1008 on purpose: 1008 means
the space is gone for them, 4001 means this meeting is, and they may rejoin.
Anything else
Section titled “Anything else”An unmatched path under /api/ returns a plain-text 404 page not found,
not JSON — worth knowing if your client parses every response body as JSON.
Everything else falls through to the single-page app, so an unknown path outside
/api/ returns the frontend with a 200 rather than a 404.