Pokor Public API
Versioned, JSON-only contract under v1. Authenticate with an API key minted from the API dashboard.
On this page
- Base URL
- Authentication
- Plans & limits
- JSON & errors
- Rate limits
- Sessions
- List sessions
- Get session
- Create session
- Update session
- Delete session
- Stories
- List stories
- Get story
- Add story
- Update story
- Delete story
- Voting & results
- List votes
- Round history
- Consensus stats
- Participants
- List participants
- Presence
- Get participant
- Remove participant
- Moderation
- List join requests
- Approve request
- Deny request
- List bans
- Remove ban
- Account & credits
- Get account
- AI consent
- Credit ledger
- AI
- Actions
- Envelope
- Request bodies
- Result shapes
- Saved outputs
- Teams
- List teams
- Get team
- List members
- Invite members
- Change role
- Change seat
- Remove member
- Team sessions
- Create session
- AI credit pool
- Team AI
- Webhooks
- Playground
Base URL
All public API endpoints are versioned under /v1/. The API is served from the dedicated API subdomain — application UI and the public API never share the same host.
Authentication & headers
Send your API key as a Bearer token in the Authorization header. Mint and revoke keys from the API dashboard. Keys begin with the prefix pk_ and are shown in plaintext exactly once at creation time.
Required headers
Authorization: Bearer pk_…— required on every request.Content-Type: application/json— required when sending a JSON body (POST,PATCH).Accept: application/json— recommended; the API only emits JSON.
Email-authenticated users must verify their email before minting or using API keys. Guests cannot use the public API because they cannot create API keys.
Optional key restrictions
API keys can be restricted to up to 50 IP addresses or CIDR ranges and up to 50 origin domains. Origin entries may use a wildcard prefix such as *.example.com. Requests outside a configured allowlist are rejected with ip_not_allowed or origin_not_allowed.
Discovery endpoint
GET /v1/ returns the API name, version, and a link back to this page — useful as a connectivity / authentication smoke test.
Plans & limits
The Pokor public API mirrors the same plan-based limits enforced in the app. UI-created and API-created resources count together against the same totals. Anything marked with * elsewhere in this reference is plan-gated and points back to this section.
| Capability | Free | Organizer |
|---|---|---|
| Active API keys | 1 | Unlimited |
| Throttle (per key, per minute) | 30 req/min | 300 req/min |
| Synchronous sessions | ||
| Asynchronous sessions | ||
| Add / update / delete stories on existing sessions | ||
Approval-gated joins (require_approval) | Stored as false | |
| API key IP / origin allowlists | ||
| Active webhook endpoints | 1 | Unlimited |
Plan-gated error responses
Plan-gated calls return 403 with a stable code so clients can branch deterministically:
api_key_quota_exceeded— beyond your active key quota.async_sessions_paid_only— Free user requesting async mode.story_management_paid_only— Free user mutating an existing session's stories.
JSON, status codes & errors
The API speaks JSON only. Request bodies must be valid JSON and use UTF-8. Timestamps are returned as ISO-8601 strings (for example 2026-04-28T12:34:56+00:00). Identifiers are stable across requests: every resource (sessions, stories, participants, bans, webhooks, deliveries) is addressed by a 26-character uppercase ULID matching [0-9A-HJ-KM-NP-TV-Z]{26} (Crockford Base32, e.g. 01HX7Z3Q2K8B0M4N6P9R1S3T5V). Path parameters such as {session_id}, {story_id}, {participant_id}, and {ban_id} must match this pattern; non-conforming values return 404.
Successful responses
Successful responses are wrapped in a data envelope. Single resources return an object; collections return an array. Empty 204 responses (used by DELETE) have no body.
Error responses
Errors return an error object with a stable machine-readable code and a human-readable message. Some errors include extra fields documented alongside the code (for example fields on validation errors, limit on plan-limit errors, retry_after_seconds on rate-limit errors).
Status codes
The API uses standard HTTP status codes to indicate the result of every request.
| Status | Meaning |
|---|---|
200 OK | Request succeeded; response body contains the resource. |
201 Created | Resource was created; response body contains the new resource. |
204 No Content | Request succeeded with no body (used by DELETE). |
401 Unauthorized | Missing, invalid, revoked, or expired API key. |
403 Forbidden | Plan does not allow this action, or email is unverified. |
404 Not Found | Resource does not exist or you do not own it. Also returned for unsupported API versions and unknown /v1/ paths. |
422 Unprocessable Entity | Request body failed validation. The fields object lists invalid fields. |
429 Too Many Requests | Rate limit exceeded. Retry after the time given in Retry-After. |
500 Internal Server Error | Unexpected server error. Retry idempotent requests after a short delay. |
Common error examples
Authentication (401)
Validation (422)
Not found (404)
Reference: error codes
| Code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | No Authorization header. |
invalid_api_key | 401 | Header present but token does not match a known key. |
revoked_api_key | 401 | Key was revoked by its owner. |
expired_api_key | 401 | Key passed its expires_at. |
email_not_verified | 403 | Owner's email is not verified. |
ip_not_allowed | 403 | Request IP is outside the API key allowlist. |
origin_not_allowed | 403 | Request origin is outside the API key allowlist. |
api_key_quota_exceeded | 403 | Plan-based API key quota reached. * |
async_sessions_paid_only | 403 | Async sessions require a paid plan. * |
story_management_paid_only | 403 | Adding, updating, or deleting stories on existing sessions requires a paid plan. * |
join_request_not_found | 404 | Join request is missing or expired. |
validation_failed | 422 | Request body failed validation; see fields. |
voted_story_locked | 422 | Cannot edit scope on a voted story; re-vote first. |
cannot_remove_organizer | 422 | The session organizer cannot be removed as a participant. |
not_found | 404 | Resource missing or not owned by the caller. |
unsupported_api_version | 404 | Version path is not currently supported. |
rate_limit_exceeded | 429 | Too many requests; retry after retry_after_seconds. |
Rate limits *
Public API requests are rate limited per API key. The per-minute limit depends on the owning user's plan — see the Plans & limits section for the exact numbers.
Every response includes X-RateLimit-Limit and X-RateLimit-Remaining headers so clients can pace themselves. When the limit is exceeded, the API returns 429 with a Retry-After header (seconds), an X-RateLimit-Reset header (unix timestamp), and a retry_after_seconds field in the body:
Sessions
Planning sessions are the top-level resource. The authenticated user is the organizer of every session created through the API. Sessions you do not organize return 404.
List sessions
GET /v1/sessions — return every planning session you organize, ordered by most recently created. Stories on each session are returned in sort_order.
Example response (200 OK)
Get a session
GET /v1/sessions/{session_id} — fetch a single session you organize, including its stories.
Example response (200 OK)
Create a session *
POST /v1/sessions — start a new planning session. Free users can create synchronous sessions only; paid users can create both synchronous and asynchronous sessions.
Request body
name(string, optional) — session name; auto-generated when omitted.card_deck(string[], optional) — voting cards; defaults to a Fibonacci deck.?and☕are always included.mode(string, optional) —sync(default) orasync.asyncrequires a paid plan.*default_vote_window_hours(integer, required forasync) — vote window in hours, 1–720.auto_reveal_when_all_voted(boolean, optional) — defaults totrue.organizer_votes(boolean, optional) — defaults totrue.require_approval(boolean, optional) — paid plans only.*stories(array, optional) — initial backlog. Each story takestitle(required),description,link,source_type,source_external_id,source_metadata,vote_opens_at,vote_closes_at.
Example request
Example response (201 Created)
Update a session *
PATCH /v1/sessions/{session_id} partially updates a session you own. Free users can update regular session settings, but mode: "async" and require_approval: true are paid-plan capabilities. For Free users, require_approval: true is stored as false.
Example response (200 OK)
Delete a session
DELETE /v1/sessions/{session_id} deletes a session you organize and notifies connected participants. Returns 204 No Content.
Stories *
Stories are scoped under a session. Pass the parent session id in the URL. Voted stories are scope-locked: editing title, description, link, or vote windows on a voted story returns 422 with code voted_story_locked. Re-vote to change scope.
List stories
GET /v1/sessions/{session_id}/stories — list stories in sort_order.
Example response (200 OK)
Get a story
GET /v1/sessions/{session_id}/stories/{story_id} — fetch a single story.
Add a story *
POST /v1/sessions/{session_id}/stories — append a story to the session. title is required.
Example response (201 Created)
Update a story *
PATCH /v1/sessions/{session_id}/stories/{story_id} — partial update. Set final_estimate, edit scope (when not voted), reorder via sort_order.
Example response (200 OK)
Delete a story *
DELETE /v1/sessions/{session_id}/stories/{story_id} — remove a story. Returns 204 No Content with no body.
Voting & results
Read the votes cast on a story, its round history, and the computed consensus. These endpoints are read-only — casting votes stays in the app, where it is tied to a live participant. Votes are wiped when a new round starts, so votes always reflects the current round; the rounds history preserves each round's average and reveal time.
List current votes
GET /v1/sessions/{session_id}/stories/{story_id}/votes — every vote in the current round, oldest first.
Example response (200 OK)
List round history
GET /v1/sessions/{session_id}/stories/{story_id}/rounds — each round with its average and revealed_at (null while a round is still open).
Example response (200 OK)
Get consensus statistics
GET /v1/sessions/{session_id}/stories/{story_id}/results — aggregate stats for the current round: average, median, spread, distribution, and whether the numeric votes reached consensus.
Example response (200 OK)
Participants
Participants reflect everyone who joined a session via the web app or chat integrations. Joining is initiated from the app — there is no POST on this endpoint, since identity in real-time sessions is established via the WebSocket join handshake. Internal identifiers (such as anonymous tokens or IP addresses) are never returned.
List participants
GET /v1/sessions/{session_id}/participants — return everyone who has joined the session, ordered by join time.
Example response (200 OK)
Presence
presence is what the participant's browser last reported, and last_seen_at is when it reported it. Presence expires rather than being revoked — a browser can report that it is in the room but has no way to report that it stopped existing, so a participant whose heartbeat has gone quiet reads as offline even though no explicit leave was ever received.
| Value | Meaning |
|---|---|
online | Session open in front of them and actively in use. |
away | Still connected, but the tab is in the background or has been idle for several minutes. |
offline | Left, or stopped reporting. last_seen_at may still be set — it records the last contact, not a promise they are present. |
Poll if you need presence: there is no push delivery for it, and it is not carried in webhook payloads. participant.joined and participant.left cover membership changes, which is a different question from whether someone is currently looking at the room.
Get a participant
GET /v1/sessions/{session_id}/participants/{participant_id} — fetch a single participant from a session you organize.
Remove a participant
DELETE /v1/sessions/{session_id}/participants/{participant_id} — remove a participant. The WebSocket room is notified and the participant leaves the session. Returns 204 No Content with no body. Removing the organizer returns 422 with code cannot_remove_organizer.
Join requests & bans
Moderation endpoints are scoped under a session you organize. They are useful for approval-gated sessions and for clearing bans created from the app or API.
List pending join requests
GET /v1/sessions/{session_id}/join-requests returns pending join requests for sessions with require_approval enabled.
Approve a join request
POST /v1/sessions/{session_id}/join-requests/approve accepts a pending request. The identifier is returned by the list endpoint and webhook payload.
Deny a join request
POST /v1/sessions/{session_id}/join-requests/deny rejects a pending request. Send ban: true to also create a session ban for that requester.
List bans
GET /v1/sessions/{session_id}/bans lists current bans for a session. Internal identifiers such as IP addresses and anonymous tokens are not returned.
Remove a ban
DELETE /v1/sessions/{session_id}/bans/{ban_id} removes a ban and returns 204 No Content.
Resolving a missing or expired join request returns 404 with join_request_not_found.
Account & credits
Inspect the account behind the API key — its plan, throttle ceiling, AI-data-consent flag, and AI credit balances — plus the personal AI credit ledger. Read account before attempting credit-gated or AI calls so you can surface a clear message when consent is off or the balance is empty.
Get the account
GET /v1/account — the key owner's plan and credits. No numeric user id is exposed; the API acts as a single account.
Example response (200 OK)
Grant AI data consent
PUT /v1/account/ai-consent with { "consent": true } records the account's acceptance of how story data is used by AI — the same consent the in-app modal captures, so a headless client can unblock AI without opening the app. Pass false to revoke. Until granted, every AI call returns 403 ai_consent_required.
Example response (200 OK)
List the credit ledger
GET /v1/credits/transactions — newest first. Filter by type, action, from, and to. Page with limit (default 25, max 100) and before — pass the last row's id to fetch older entries.
Example response (200 OK)
AI
Run the same on-demand AI actions the app offers — estimate suggestions, story explanations and enrichment, disagreement and session summaries, backlog ordering, and retrospectives — from your own tools. Every action costs 1 AI credit and never runs automatically.
Before you call
- The account must have granted AI data consent — otherwise every AI call returns
403with codeai_consent_required. Checkai_data_consentonGET /v1/account, and grant it (once) withPUT /v1/account/ai-consent({ "consent": true }). - Each call spends a credit. When the balance can't cover it you get
402with codeinsufficient_ai_credits(plusbalanceandcost). - Voter names and organizer notes are anonymized before any prompt leaves the server, then restored into your response.
Actions
| Action | Endpoint | Who |
|---|---|---|
| Estimate suggestion | POST …/stories/{story_id}/ai/estimate | Organizer |
| Story explanation | POST …/stories/{story_id}/ai/explain | Participant |
| Disagreement summary | POST …/stories/{story_id}/ai/disagreement | Organizer |
| Session summary | POST …/sessions/{session_id}/ai/session-summary | Participant |
| Story enrichment * | POST …/sessions/{session_id}/ai/enrich | Organizer (paid) |
| Story ordering * | POST …/sessions/{session_id}/ai/order | Organizer (paid) |
| Retrospective narrative * | POST …/sessions/{session_id}/ai/retro-narrative | Organizer (paid) |
| Analytics insights * | POST /analytics/ai/insights | Organizer (paid) |
Request & response envelope
Every action is a POST. Story/session actions take no body (the story and session in the URL are the input); the request bodies for the actions that need one are listed below. All actions return the same envelope — the action-specific payload is under result (and mirrored in the persisted output). Example, POST …/stories/{story_id}/ai/estimate:
Example response (200 OK)
Request bodies
| Action | Body |
|---|---|
| Story enrichment | { "title": "…", "source_metadata"?: { … } } |
| Story ordering | { "stories": [{ "key": "…", "title": "…", "description"?: "…" }] } (min 2) |
| Team sessions summary | { "session_ids": ["…"], "context_note"?: "…" } (1–25) |
| Team member insights | { "user_ids": [123], "context_note"?: "…" } (1–25) |
| All others | No body |
Result shape per action
The result object by action. Paid ("full") variants add the fields in italics.
| Action | result fields |
|---|---|
| Estimate suggestion | estimate, rationale, confidence |
| Story explanation | explanation |
| Disagreement summary | summary, key_factors[], revote_focus |
| Session summary | summary, highlights[], next_steps[] |
| Story enrichment | description, acceptance_criteria[] |
| Story ordering | order[], rationale |
| Retrospective narrative | narrative, action_items[] |
| Analytics insights | narrative, recommendations[] |
| Team sessions summary | summary, highlights[], per_session[], next_steps[] |
| Team member insights | members[] of { member, cadence, confidence_trend, tendencies[], notes_summary } |
Read saved outputs (no credit)
Every AI result is persisted. Re-read the latest output per action — GET …/stories/{story_id}/ai-outputs for a story, or GET …/sessions/{session_id}/ai-outputs for session-level actions — to rehydrate prior results without spending a credit.
Teams
Manage the teams you belong to — list members and sessions, invite people, change roles, spin up a team session from the template, and run the shared AI credit pool. Access is scoped to your membership: a team you don't belong to returns 404, and admin-only actions return 403 with code forbidden for non-admin members. Teams bind by ULID. A member's id is a ULID (used in the member paths below); user_id is the account id you pass to the team AI member-insights action.
List your teams
GET /v1/teams — every team you're an active member of, with your role and counts.
Example response (200 OK)
Get a team
GET /v1/teams/{team_id} — team detail including the AI pool and your viewer flags.
Example response (200 OK)
List members (admin)
GET /v1/teams/{team_id}/members — active members first, then pending invites. Pending invites have a null user_id until the invitee signs in.
Example response (200 OK)
Invite members (admin)
POST /v1/teams/{team_id}/members — up to 25 emails. Addresses are lower-cased and de-duplicated; anyone already on the team is skipped. Each new invite is emailed a join link. Returns only the newly created (pending) members.
Example response (201 Created)
Change team role (admin)
PATCH /v1/teams/{team_id}/members/{member_id} — set role to admin or member. Changing the owner's role returns 422 with code team_owner_is_admin.
Example response (200 OK)
Change session seat (admin)
PATCH /v1/teams/{team_id}/members/{member_id}/session-role — set session_role to organizer, voter, or spectator. A team has exactly one organizer: promoting a member demotes the previous organizer to voter. You can't directly demote the current organizer (promote someone else instead) — that returns 422 with code organizer_reassignment_required.
Example response (200 OK)
Remove a member (admin)
DELETE /v1/teams/{team_id}/members/{member_id} — removes the member and revokes their seat in every team session. Returns 204 No Content. Removing the owner returns 422 with code cannot_remove_owner.
List team sessions
GET /v1/teams/{team_id}/sessions — the team's sessions, most recently updated first, with participant / story / resolved counts.
Example response (200 OK)
Create a team session
POST /v1/teams/{team_id}/sessions — build a session from the team template and auto-seat every member. Requires admin, or any member when the team allows member sessions (else 403 with code team_session_creation_forbidden). Returns the full session (same shape as the Sessions API).
Example response (201 Created)
AI credit pool
GET /v1/teams/{team_id}/credits — the pool balance and whether it's enabled (any member).
Example response (200 OK)
Pool ledger (admin)
GET /v1/teams/{team_id}/credits/transactions — newest first. Filter by type, action, from, to; page with limit (max 100) and before (a transaction id).
Fund, withdraw & toggle (admin)
POST …/credits/contribute moves your personal purchased credits into the pool; POST …/credits/withdraw moves them back — both take { "amount": N } (1–100000) and return the new pool state. Overdrawing returns 422 with code insufficient_credits and the available balance. POST …/credits/toggle takes { "enabled": true|false }.
Example response (200 OK)
Team AI actions (admin)
Two admin-only AI actions spend the pool (or the admin's own credits if the pool can't cover it) and follow the same envelope as the AI section: POST /v1/teams/{team_id}/ai/sessions-summary ({ "session_ids": ["…"] }, 1–25) and POST …/teams/{team_id}/ai/member-insights ({ "user_ids": [123] }, 1–25 — the user_ids from the members list). Both accept an optional context_note.
Webhooks
Webhooks let you react to planning session activity in real time. Configure endpoints from the API dashboard or the /webhook-endpoints API below, and Pokor will sign and POST event payloads to your URL whenever something happens in a session you organize.
Manage endpoints
Full CRUD lives under /v1/webhook-endpoints: GET lists them, POST creates one (returns the signing_secret in plaintext once), PATCH updates or toggles disabled, and DELETE removes one. The free plan allows one active endpoint; over the limit returns 403 with code webhook_quota_exceeded.
Create — POST /webhook-endpoints
Example response (201 Created)
List — GET /webhook-endpoints
Your endpoints, newest first. The full secret is never returned again — only signing_secret_prefix.
Update / disable — PATCH /webhook-endpoints/{id}
Send only the fields you're changing: name, url, events, or disabled (a boolean that toggles delivery). A changed url is re-probed. Returns the updated endpoint under data.
Delete — DELETE /webhook-endpoints/{id}
Removes the endpoint. Returns 204 No Content.
Setup
- Each endpoint stores an HTTPS destination URL and one or more event subscriptions.
- Pokor probes new webhook URLs before saving them and expects a 2xx response within 10 seconds.
- You may attach up to 5 custom headers to each delivery. Pokor-managed headers such as
X-Pokor-Signature,X-Pokor-Timestamp,Content-Type, andUser-Agentcannot be overridden. - The signing secret is shown exactly once when the endpoint is created.
Events
| Event | When it fires |
|---|---|
participant.joined | A participant joined a planning session you organize. |
participant.left | A participant left a planning session you organize. |
participant.join_requested | A participant requested access to an approval-gated session you organize. |
story.voted | A story received a vote. |
story.revealed | Voting cards were revealed — includes all votes and the average. |
story.estimate_updated | The organizer changed the final estimate after reveal. |
session.disbanded | A planning session was deleted by the organizer. |
Delivery headers
Payload structure
Every webhook payload uses the same envelope. The event_id is stable across retries for the same delivery, so use it to deduplicate idempotently.
Example: participant.joined
Example: participant.left
Example: participant.join_requested
Example: story.voted
Example: story.revealed
Example: story.estimate_updated
Example: session.disbanded
Retry policy
- A delivery is successful when your endpoint returns a 2xx status within 10 seconds.
- Organizer plan: up to 5 total attempts; retries at 1 min, 5 min, 30 min, and 2 h.
- Free plan: up to 3 total attempts; retries at 1 min and 5 min.
- After the final failure, the delivery is marked
failed. - Delivery status, attempt count, response status, and last error are visible in the webhook dashboard.
Signature verification
The signature is sent as X-Pokor-Signature: v1=<hex_hmac_sha256>, with the unix timestamp in X-Pokor-Timestamp. Verify the raw request body, not a parsed and re-serialized JSON object. The signed payload is {timestamp}.{raw_body}. Reject timestamps outside a short replay window such as 5 minutes.
Playground
Pick an endpoint, paste an API key, fill in any path parameters and body, then send. Requests are sent directly to the public API and count against the pasted key's plan permissions, allowlists, and rate limits. Mutating calls (POST / PATCH / DELETE) require the safety toggle below.