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
- Participants
- List participants
- Get participant
- Remove participant
- Moderation
- List join requests
- Approve request
- Deny request
- List bans
- Remove ban
- 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.
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)
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.
Webhooks
Webhooks let you react to planning session activity in real time. Configure endpoints from the API dashboard and Pokor will sign and POST event payloads to your URL whenever something happens in a session you organize.
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.