REST API
Everything the Flagpost UI does goes through the same API you can call
yourself: REST under /api, WebSockets under /ws, served same-origin
through the front proxy.
Interactive OpenAPI docs
Section titled “Interactive OpenAPI docs”FastAPI generates a complete, always-current interactive reference. Run the dev stack and open http://localhost:8000/docs — every endpoint, schema, and response model, with a try-it-out console. That generated reference is the endpoint-level source of truth; this page covers the contracts that don’t change.
Authentication
Section titled “Authentication”- Register / login —
POST /api/auth/register,POST /api/auth/login. Login takes anidentifier(username or email) plus password and returns a short-lived JWT access token; a rotating refresh session rides an httpOnly cookie.POST /api/auth/refreshrotates and re-issues. - SSO —
GET /api/auth/providerslists the enabled redirect providers as{slug, name, kind, brand}(v1.3.0 — this replacedGET /api/auth/oidc/providers, a breaking change for integrations on the old path). The providerkindset is nowoidc | oauth2 | saml | ldap—oauth2is new in v1.5.0.brand, added in v1.4.0 and extended in v1.5.0, is"google"/"microsoft"/"github"/"discord"/null, derived server-side so the login page can draw the official button mark.GET /api/auth/{kind}/{slug}/loginstarts the redirect flow for OIDC, OAuth2 and SAML; OIDC completes atGET /api/auth/oidc/{slug}/callback, OAuth2 atGET /api/auth/oauth2/{slug}/callback, SAML atPOST /api/auth/saml/{slug}/acs, with public SP metadata atGET /api/auth/saml/{slug}/metadata. LDAP has no endpoints of its own —POST /api/auth/loginfalls through to a directory bind after local password verification fails, behind the same rate limit. All paths issue the same session local login does; admin provider CRUD lives at/api/admin/auth-providers(manage_auth_providers), with the built-in preset catalogue read-only atGET /api/admin/auth-providers/presets(v1.4.0). See SSO & external identity. - REST calls send
Authorization: Bearer <access-token>. - WebSockets connect to
wss://<host>/ws/…and send the same access token as the first frame after connect — never in the URL. - Capabilities —
GET /api/auth/me/permissionsreturns the resolved permission set the UI uses for role-aware navigation; your integrations can use it the same way. - Self-service account endpoints:
POST /api/auth/forgot-password(always 204 — it never discloses whether an account exists) andPOST /api/auth/reset-password;POST /api/auth/change-email,POST /api/auth/verify-email, andPOST /api/auth/resend-verificationwhen email verification is on. - Credential endpoints are rate-limited (login, registration, password reset, email verification) — back off on 429s.
Personal API tokens
Section titled “Personal API tokens”For scripts and integrations, mint a personal API token from the
API tokens tab of /profile (/profile?tab=tokens) instead of
capturing a browser session:
POST /api/api-tokensmints one for your own account (the route has no holder field — tokens for other users are structurally impossible). The rawflp_…value is returned once, at mint time; only its hash is stored.- Use it exactly like an access token:
Authorization: Bearer flp_…— it authenticates as you, with your full effective permissions. REST only; the WebSocket handshake does not accept API tokens. GET /api/api-tokens/melists yours;DELETE /api/api-tokens/me/{id}revokes one. Administrators holdingmanage_api_tokenscan list and revoke any token (GET /api/api-tokens,DELETE /api/api-tokens/{id}) — oversight for killing leaked credentials.
Shape of the API
Section titled “Shape of the API”Endpoints are competition-scoped in the path
(/api/competitions/{id}/challenges, …/scoreboard, …/tickets, …), one
router per domain, mirroring the platform’s
tenancy model. Permission
enforcement happens server-side on every route — see the
permissions reference.
The v1.4.0 AI assistants follow the same shape: assistant
conversations, availability, usage, per-competition settings, and
transcript review live under /api/competitions/{id}/ai/…, and site
provider config at GET/PUT /api/admin/ai/settings +
POST /api/admin/ai/test-connection (gated manage_ai). Assistant
messages are rate-limited per user (60/hour organiser, 20/hour
competitor, one answer in flight) — back off on 429s. The OpenAPI console
remains the endpoint-level source of truth.
v1.5.0 adds a few more domains. GET /api/modules returns the site-level
optional-module catalogue (requires create_competition). Post-event
reports live under /api/competitions/{id}/reports
(all require generate_report and are module-gated), including
GET /{report_id}/download/{pdf|html}, which streams the file through
the API rather than handing back a presigned URL. Custom-page
administration is CRUD under /api/admin/pages (requires manage_pages).
Avatars have self-service POST/DELETE /api/profile/avatar for your own
account, plus DELETE /api/users/{id}/avatar for moderators
(manage_users); and competitors pull their own certificates
from GET /api/me/certificates.
v1.6.0 adds several more domains.
Challenge instances are competition-scoped.
A competitor launches, polls, extends and destroys their own instance of
a challenge (all instance_launch):
POST /api/competitions/{cid}/challenges/{chid}/instancelaunches one (201, returns the instance with connection details hidden until it is running). It answers429when the site spawn rate-limit refuses the launch,409when a concurrency cap is reached (per-subject, per-competition or the global ceiling), and403on the demo (instancing is force-disabled there) or before the competition is running (staff holdinginstance_managebypass the running gate to test-launch pre-publish).GET /api/competitions/{cid}/challenges/{chid}/instancepolls your active instance (404if none).POST /api/competitions/{cid}/challenges/{chid}/instance/extendrenews its lifetime by one session length, up to five times.DELETE /api/competitions/{cid}/challenges/{chid}/instancetears it down (202 {status: "destroying"}).
Staff manage instances competition-wide: GET /api/competitions/{cid}/instances
lists every active instance with its resolved challenge title, subject label
and backend handle (instance_view), and
DELETE /api/competitions/{cid}/instances/{id} force-kills any one of them
(202, instance_manage). An author defines the per-challenge deployment
spec with GET/PUT/DELETE /api/competitions/{cid}/challenges/{chid}/deployment
(challenge_edit).
Site instancing infrastructure is admin-only:
GET/PUT /api/admin/instances/settings reads and updates the provisioner
backend, endpoint URL, public host, credentials and limits, and
POST /api/admin/instances/test-connection runs the saved provisioner’s
staged connection probe leg-by-leg — all gated manage_instance_infra, its
own grant (Administrator-only) because it holds infrastructure credentials.
Custom brand themes are site-wide CRUD
under /api/admin/themes (GET/POST, and PUT/DELETE /api/admin/themes/{id}),
all gated manage_site_settings — no per-competition scope. The endpoints
only curate the theme library; a theme becomes the active site theme by
pointing default_palette at its id through the site-settings update.
The authenticated scoreboard gains a cumulative points-over-time series:
GET /api/competitions/{cid}/scoreboard/timeline (challenge_view, same as
the board read) plots the top entrants as running-total lines. Query params
live, bracket and top mirror the board — ?live=true only bypasses a
freeze for a scoreboard_freeze holder, bracket scopes to one division,
and top is clamped server-side (default 10, hard cap 25).
Scheduled announcements add three staff routes on
/api/competitions/{cid}/announcements (all announcement_create):
GET /scheduled lists pending drafts, PATCH /{id} edits a still-scheduled
draft’s content or timing, and DELETE /{id} cancels one. PATCH/DELETE
return 409 on a row that has already published — only a still-scheduled
draft can be edited or cancelled.
Custom registration fields are
competition-scoped under /api/competitions/{cid}/registration-fields:
GET/PUT on the collection reads the visible field definitions / replaces
the set as an organiser (edit_competition); GET/PUT /me reads and edits an
individual’s own answers (challenge_view, individual-mode competitions only
— 400 in team mode, where answers are edited through the team); and
GET /export streams the organiser’s CSV of every subject’s answers
(edit_competition).
Flag submission is rate-limited per user/team with escalating backoff — build clients accordingly.
Public endpoints (no authentication)
Section titled “Public endpoints (no authentication)”| Endpoint | Purpose |
|---|---|
GET /api/health |
Liveness — 200 once migrated and serving |
GET /api/site-settings |
Public branding (name, palette, background style, sign-in notice, logo URL, registration state) so login screens brand themselves — the background style and notice are v1.4.0 |
GET /api/site-settings/logo |
The uploaded logo (served sandboxed) |
GET /api/public/competitions |
Directory of competitions that opted into the public scoreboard |
GET /api/public/competitions/{id}/scoreboard |
Spectator scoreboard (respects freezes; 404 for private/non-opted-in) |
GET /api/public/competitions/{id}/insights |
Spectator stats, highlights and the top-10 points timeline (freeze-parity with the board) |
GET /api/public/competitions/{id}/activity |
Recent awarded solves, newest first (capped at 25), each tagged is_first_blood — drives the venue-mode first-blood splash. Freeze-aware, same opt-in/404 gating as the scoreboard (v1.3.0) |
GET /api/public/competitions/{id}/ctftime |
CTFtime scoreboard feed |
GET /api/pages |
Custom-page nav entries (v1.5.0) — anonymous callers see public pages only; signed-in callers also see members-only pages |
GET /api/pages/{slug} |
A single custom page (v1.5.0); hidden or draft pages fail closed to 404 |
GET /api/users/{id}/avatar |
A user’s avatar image (v1.5.0), immutable-cached with a ?v=<epoch> cache-buster |
Metrics scrape endpoint
Section titled “Metrics scrape endpoint”GET /metrics (v1.6.0) is a Prometheus exposition target for operators. It
is a kernel route, not under /api, and it is off by default — it
returns 404 until you set METRICS_ENABLED=true. It does not use the
RBAC permission system: a scraper has no interactive session, so access is
gated by a static bearer token and/or an IP allowlist instead. An enabled but
unauthorised scrape gets 401 with WWW-Authenticate: Bearer; an authorised
one gets 200 text/plain. Enabling it without a token or an allowlist is a
hard startup refusal, so the endpoint can never be public. It exposes bounded
operational numbers only (request timing, event-bus lanes, WebSocket and
challenge-instance counts, scheduler liveness, DB-pool depth) — never
competitor content or personal data. See
observability for the metric families and a sample
scrape config.
WebSocket rooms
Section titled “WebSocket rooms”wss://<host>/ws/<resource_type>/<resource_id> — scoped rooms for shared
resources (scoreboard, challenge presence, ticket threads), plus
/ws/user/<user_id> for the personal notification stream, note/<doc_key>
rooms relaying collaborative-note updates, and (v1.4.0)
ai/<conversation_id> rooms streaming assistant answers —
joinable by the conversation’s owner or the matching oversight grant. A
per-competition activity/<competition_id> room fans out tiny event
frames that clients use to refresh their own permission-filtered REST
views — it’s what keeps the whole UI live. Most are id-only pings, but
since v1.4.0 the solve ping carries a compact public delta
({challenge_id, solve_count, value, team_id} — all already-public
fields) that clients patch in place instead of refetching; the delta
falls back to a plain ping when the challenge isn’t competitor-visible or
the board is frozen, and same-event bursts are server-side coalesced.
After the first-frame auth handshake, rooms stream JSON updates;
reconnect with exponential backoff and jitter, as the built-in client
does — the handshake itself is rate-limited per user since v1.4.0
(default 60 per 30 s across all rooms), so a hammering reconnect loop is
actively refused.