Skip to content

API Reference

All endpoints are served by the Flask API (dps-code-api/app.py) on https://api.eccdps.org (production) / https://demo-api.eccdps.org (demo).

Conventions

  • Authentication is one of: session (HttpOnly cookie set after Discord OAuth) or API key (Authorization header equal to the environment's API_KEY, no Bearer prefix). Some routes accept both; many accept only one.
  • Success: {"success": true, ...}.
  • Error: {"success": false, "error": "<message>"} with an HTTP status.
  • CORS: credentialed responses only for the configured dashboard/training origins.
  • Body limit: 1 MiB (MAX_CONTENT_LENGTH); oversized bodies → 413.
  • Fields marked server-managed are never accepted from the client.

Legend for the auth column: 🔑 API key · 👤 browser session · either.


Health and root

GET /

Liveness probe. Returns the plain string DPS API Online. Exempt from environment resolution so Render health checks work.


Authentication and session

GET /auth/discord/login

Redirects the browser to Discord OAuth. Query: popup=1 (popup flow), return_to=<url> (must resolve to an allowlisted dashboard/training origin — otherwise 400). The OAuth state is environment-prefixed and stored in the session. Errors: 503 when OAuth is not configured, 400 for invalid return_to.

GET /auth/discord/callback

Discord redirect target. Verifies state (environment prefix + constant-time token compare), exchanges the code for a token (via the OAuth proxy Worker when configured, direct otherwise, with bounded retry), fetches /users/@me, resolves the Discord ID against agents, and stores discord_id in the session (clearing any previous session). Failures redirect to the dashboard login with an error code (discord_authorization_denied, oauth_state_mismatch, not_a_dps_agent, agent_not_active, demo_not_authorized, …). The demo environment maps allowlisted real IDs to fictional demo agents.

GET /auth/me

👤 Returns {agent, permissions, can_handle_supervisor, minimum_clearances, admin} — the live agent, every CLEARANCE_POLICY permission flag, supervisor rank flag, the clearance table, and server-computed admin capabilities (allowlist + Director-rank aware). 401 when not signed in.

POST /auth/logout

👤 Clears the session. Returns {"success": true}.

POST /demo/reset

👤 Demo environment only (403 elsewhere). Re-seeds the demo database from demo_seed.py. 401 without a session.


Generators (bot-facing)

All generator routes require the API key (401 otherwise). They generate random identifiers for onboarding; the API never regenerates stored identifiers.

EndpointProduces
GET /generate/agreementAGM-#####
GET /generate/agentDPS-AG-#####
GET /generate/joinSC-###X#
GET /generate/caseDPS-CASE-#####
GET /generate/investigationINV-#####
GET /generate/clearanceCL-###

Utilities (bot-facing)

POST /util/timestamp

🔑 Converts a JavaScript Date.toString() string (e.g. Thu Jul 16 2026 02:57:13 GMT+0000 (Coordinated Universal Time)) to a Discord-ready Unix timestamp. Body: {"date": "…"}. 200 {"timestamp": <int>}; 400 when date is missing or unparseable.


Reports / Dockets

POST /reports/create

either · manage_dockets clearance. Creates a report. Required: report_id, reporter, reported, reason, notes, evidence. Optional: reporter_name, reported_name, status (must be in the display status set), assigned_agent, is_supervisor, thread_id, evidence_url (http(s) only). Accepts form-encoded or JSON. Side effects: report row + "Report created" timeline + report.created event + optional initial evidence row in one transaction (rpc_create_report). 201 with the full report; 409 duplicate; 400 validation.

GET /reports

either · view_dashboard clearance. Lists reports, newest first, optionally ?status=. Without limit: legacy full fetch (bot embeds, Analytics, Agents). With limit/offset (+ statuses, type, contact_open, sorts): server-side pagination via rpc_list_reports with total + per-type counts. Supervisor-flagged rows are redacted to a restricted placeholder (and supervisor counts zeroed) for non-supervisor callers.

GET /reports/<report_id>

either · view_dashboard. Full report: base fields, notes, evidence, timeline, discord profile snapshots, resolved names. 404 unknown; 403 supervisor-flagged for non-supervisors.

PATCH /reports/<report_id>/update

either · manage_dockets (or reassign_docket when assigned_agent is set — Head Investigator rank required). Updates whitelisted fields only (reporter, reported, notes, reason, status, assigned_agent, is_supervisor). is_supervisor/notes/reason are bot-only (403 for sessions). Browser sessions must be the assigned agent. Status may only be set to Open/Pending/Appealed (or Pending to conclude an in-progress investigation). Side effects: updated_at bump, timeline row, canonical event (report.assigned / report.status_changed / report.updated) with before/after state. 200 with the full report; 403/409/400 as described in Reports.

DELETE /reports/<report_id>

either · delete_docket clearance (5). Hard-deletes a report. Requires a reason ({"reason": "…"}) from every caller. rpc_delete_report captures the pre-image, deletes (working data cascades; timeline/events retained), and writes the forensic audit + canonical report.deleted event in the same transaction. 200 {"deleted": …}; 400 no reason; 404 unknown.

POST /reports/<report_id>/action

either · manage_dockets. Body {"action": "<name>", "reason": "…"}. Actions: validate, invalidate, investigate, contact_reporter, claim. Routes through perform_report_actionrpc_report_action (row lock, status guard, timeline, event, contact message, queue insert — one transaction). 200 {action, queued: true, report}; 409 on status conflict/duplicate contact/already claimed; 403 assignment/supervisor; 400 unknown action.

POST /reports/<report_id>/investigation/begin

either · manage_dockets. Legacy adapter for action=investigate — same authoritative mutation and side effects. 200 {report}.

POST /reports/<report_id>/investigation/end

either · manage_dockets. Thin adapter over conclude_investigationrpc_report_action(action='conclude') (Under Investigation → Pending, no queue row). 200 {report}; 409 when no investigation is in progress.

POST /reports/<report_id>/viewed

either · view_dashboard. No-op (API compatibility; view tracking was removed because it flooded the audit log). 200.

POST /reports/<report_id>/evidence/opened

either · view_dashboard. No-op (same rationale). 200.

POST /reports/<report_id>/notes

either · add_case_note clearance (1). Body {"note": "…"} (author derived from session for browser callers). Requires assignment (or supervisor rank) and, for sessions, status Under Investigation. rpc_add_note writes note + timeline + report.note_added event atomically. 201 {note, report}; 403/409 per gates.

POST /reports/<report_id>/evidence

either · add_evidence clearance (1). Body {"description": "…", "url": "http(s)…"} (submitter derived from session). Same assignment/investigation gates as notes. URL scheme validated. 201 {evidence, report}.

POST /reports/<report_id>/timeline

either · manage_dockets. Body {"event": "…"}. Browser callers: actor derived from session, assignment + investigation-in-progress gates. Writes timeline row + report.timeline_entry_added event + updated_at bump. 201 {event, report}.

POST /reports/<report_id>/discord-profiles

🔑 BotGhost-only. Body: normalized {reporter: {...}, reportee|reported: {...}} profile payloads. Upserts into discord_profiles (deduplicated by discord_id). Report must exist (404). 200 {stored: [...]}.


Contact threads

GET /reports/<report_id>/contact

either · manage_dockets. Full thread: messages, closed, closed_at, closed_by, closed_by_name, message_count. 403 for non-assigned agents.

POST /reports/<report_id>/contact/reply

either · manage_dockets. Body {"body": "…"}. Assigned-agent gate; blocked when the thread is closed (409). rpc_contact_reply: message + timeline + contact.message_added event + bot DM queue insert atomically (actor Discord ID as p_actor_id). 201 with the updated thread.

POST /reports/<report_id>/contact/close

either · manage_dockets. Ends the conversation (409 if already closed). rpc_close_contact_thread: close state + timeline + contact.thread_closed event + contact_closed bot action queued. 200 with the updated thread.

POST /reports/<report_id>/contact/respond

🔑 Bot relays the reporter's DM. Body {"body": "…", "sender_name": "…"}. Blocked when closed (409). rpc_contact_respond records the reporter message + timeline + event.


Evidence

GET /evidence

either · view_dashboard. All evidence rows newest first, enriched with a lightweight report summary (batched) and submitted_by_id resolution.


Audit log

GET /audit

either · decide_appeal clearance (4). Global timeline audit log, paginated, searchable, sorted, supervisor-filtered — all in SQL (rpc_audit_log_v2). Query: search, event (comma-separated substrings), report_id, limit (≤500), offset, sort (newest/oldest/event/by/report), supervisor (only/exclude). Non-supervisors can never see supervisor events regardless of params. Returns {total, offset, limit, events} with identifier-scrubbed text.


Action queues (report bot work)

Queue statuses: pendingprocessingsuccess/failed (stale recovery: 10-minute timeout, 3-attempt cap).

GET /actions/pending

🔑 Pending/processing actions joined with report basics (limit 50).

GET /actions/next

🔑 Single-action pickup: stale requeue sweep, then atomic claim (rpc_claim_action, FOR UPDATE SKIP LOCKED). 200 with the claimed action; 204 empty body when the queue is empty.

GET /actions/next/operator · GET /actions/next/supervisor

🔑 Queue-specific pickups (non-supervisor / supervisor reports) for BotGhost's two read-event triggers. Same contract as /actions/next.

GET /actions/pending/operator · GET /actions/pending/supervisor

either · list. Operator list: API key or session. Supervisor list additionally requires Senior Agent rank for sessions. ?status= filter (pending,processing,success,failed; default pending,processing). Returns {count, actions} with failure metadata (attempts, result_note).

POST /actions/<action_id>/complete

🔑 Bot records the outcome. Body: {"result": "success"|"failed", "note": "…"}. rpc_complete_action: row lock + status guard + terminal transition + canonical queue.action_completed/queue.action_failed event in one transaction. 404 ACTION_NOT_FOUND; 409 ACTION_ALREADY_COMPLETED; 400 invalid result.

POST /actions/<action_id>/requeue

either · manage_dockets. Failed → pending (attempts reset) via rpc_action_requeue (locked, queue.action_requeued event). 409 when not failed.


Agents

GET /agents

either · view_agents. All agents newest first, optional ?status=.

GET /agents/<discord_id>

either · view_agents. One agent. 404 unknown.

POST /agents/create

either · manage_agents. Body: discord_id (required), optional name, status (default onboarding), agent_rank. Creates the onboarding row; all onboarding identifiers start null. 201; 409 duplicate.

PATCH /agents/<discord_id>/update

Dual-mode endpoint.

  • 🔑 Bot path: posts whitelisted onboarding fields (name, status, security_id, agreement_id, agent_id, clearance_id, clearance_level 1–5) via rpc_agent_set_onboarding_fields. agent_rank is rejected (400) — rank changes require a Director via the dashboard. Every write is audited.
  • 👤 Dashboard path: require_admin("admin_agents") + required reasonrpc_admin_agent_update. Rank/clearance changes are Director-only; self-modification is blocked.

200 with the serialized agent; 400/403/404 per the guards.


Punishments & discipline

Punishment routes are browser-session only (the API key gets 403).

POST /punishments

👤 · manage_punishments (4). Issue a punishment. Required: user_id, reason. Optional: username, detailed_reason, duration, expires_at (ISO), status, evidence, notes, report_id (must exist). Server-managed fields are rejected. rpc_punishment_create generates the AA0AA0 ID transactionally, validates, writes the row + audit + punishment.issued event, and enqueues the issue delivery. 201 with the serialized punishment (incl. delivery state and stored user profile).

GET /punishments

👤 · view_punishments (2). List with ?user_id=, ?status=, bounded limit/offset (default 200, max 500).

GET /punishments/<punishment_id>

👤 · view_punishments (2). One punishment. 404 unknown.

PATCH /punishments/<punishment_id>

👤 · manage_punishments (4). Body includes the required audit reason plus whitelisted fields (username, detailed_reason, duration, expires_at, status, evidence, notes, report_id). status=Revoked via PATCH → 409 (use the revoke endpoint). Before/after audit + punishment.updated event.

POST /punishments/<punishment_id>/revoke

👤 · manage_punishments (4). Reason required. Status → Revoked (locked row, before/after audit, punishment.revoked event, revoke delivery enqueued). 409 when already revoked/expired.

GET /punishments/users/<discord_id>/profile

👤 · view_punishments (2). Stored Discord profile snapshot for the discipline issue flow. 404 when none on record (the flow then enqueues a user_lookup).

GET /discipline/next

🔑 Bot delivery pickup: stale sweep + atomic claim (rpc_discipline_claim) with the live punishment row embedded. 200; 204 when empty.

GET /discipline/pending

either · list. ?status= (default pending,processing). Returns {count, items} with the delivery surface + serialized punishment.

POST /discipline/user-lookup

👤 · manage_punishments (4). Body {"user_id": "…"}. Enqueues a user_lookup delivery so BotGhost captures the user's profile. 201 with the lookup; 409 when a lookup is already pending for that user.

POST /discipline/<item_id>/complete

🔑 Body: {"result": "success"|"failed", "note": "…", "ref": "…", "retry_after_seconds": n}. rpc_discipline_complete: locked terminal transition (or paced retry for transient failures) + canonical delivery.completed/delivery.failed/delivery.requeued event. 409 DISCIPLINE_ALREADY_COMPLETED.

POST /discipline/user-lookup/<item_id>/profile

🔑 BotGhost delivers the captured profile: upserts discord_profiles and completes the queue item (idempotent on retry; discord_id must match the lookup target). 200 with the stored profile.

POST /discipline/<item_id>/requeue

either · manage_punishments. Failed → pending (attempts reset), delivery.requeued event. 409 unless failed.


System Administration

Admin routes are browser-session only and require require_admin(...): allowlist membership + clearance (+ Director rank for Director-only capabilities). Reasons are required for mutations; every mutation writes an audit row and a canonical event.

GET /admin/health

👤 · admin_health (4). Status-site payload: 10 sections (overall, database, api, actions, queue, agents, evidence, auth, audit, events), each ok/warn/error, plus incident history (recorded on overall-status change).

GET /admin/queue

👤 · admin_queue (4). All queue actions across ?status= (default pending,processing,failed), joined with report basics, limit 200.

POST /admin/queue/<action_id>/requeue

👤 · admin_queue. Reset a failed action; reason audited (admin.queue_requeued).

POST /admin/queue/<action_id>/cancel

👤 · admin_queue. Cancel a pending/processing action; reason audited (admin.queue_cancelled).

POST /admin/queue/sweep

👤 · admin_queue. Manual stale sweep (requeue past timeout, fail past retry cap); reason audited (admin.queue_swept).

GET /admin/audit

👤 · admin_audit (4). Paginated admin audit trail; limit (≤200), offset, agent, action, target filters. Returns {total, rows}.

GET /admin/agents

👤 · admin_agents (4). Full agent list for the admin Agents tab.

PATCH /admin/agents/<discord_id>

👤 · admin_agents + guards. Body: {"fields": {...}, "reason": "…"}. Whitelisted fields; clearance changes Director-only; rank above Head Investigator Director-only; self-modification blocked; hierarchy guard (only at/below your rank). rpc_admin_agent_update re-checks everything in the DB. Returns the RPC result with correlation_id.

GET /admin/access

👤 · Director-only. Admin allowlist.

POST /admin/access/grant · POST /admin/access/revoke · POST /admin/access/remove

👤 · Director-only; reason required; audited (admin.access_granted / admin.access_revoked / admin.access_removed). Directors are protected from revoke/remove (DIRECTOR_PROTECTED → 403).

GET /admin/reports

👤 · admin_reports (Director). Read-only report search (?search=, sanitized against PostgREST filter injection; limit 50).

POST /admin/reports/<report_id>/correct

👤 · admin_reports (Director). Whitelisted field correction; reason required; before/after audited (admin.report_corrected).

GET /admin/schema

👤 · admin_schema (Director). Applied migrations + RPC function inventory.

POST /admin/schema/reload

👤 · admin_schema (Director). Fires a PostgREST schema reload (NOTIFY), audited (admin.schema_reloaded).


Training Center

Training routes are browser-session only — the bot API key is rejected with 403. Trainers = is_trainer flag or supervisor rank; controls require being the session's assigned trainer; trainees see only their own sessions.

GET /training/me

👤 · Session. {is_trainer, agent} — the frontend's role discovery.

GET /training/realtime-config

👤 · Session. {supabase_url, anon_key, token_path} — public Supabase config for the browser Realtime client.

GET /training/realtime-token

👤 · Session. Mints a short-lived JWT (role=authenticated, sub=<discord_id>, default TTL 600 s) signed with the environment's SUPABASE_JWT_SECRET. {token, ttl, expires_at}. 503 when Realtime is not configured.

GET /training/scenarios

👤 · Trainer. Scenario catalog, including unpublished drafts, ordered by title.

GET /training/trainees

👤 · Trainer. Agents with status exactly onboarding (the only eligible trainees), ordered by name.

GET /training/sessions

👤 · Session. Role-scoped list (max 300): trainers see sessions they trained; trainees see their own active/paused/completed sessions (pending/aborted hidden), newest first, with batched scenario titles and agent names.

POST /training/sessions

👤 · Trainer. Body: {"trainee_id": "…", "scenario_id": <int>?}. Creates the parent session (pending) + membership rows + optional first scenario child + SESSION_CREATED event (broadcast to both users). Trainee must be onboarding (400 otherwise). 201 with the full session payload.

GET /training/sessions/<session_id>

👤 · Participant. Full role-aware payload (session, active scenario, all scenario instances with definitions, ≤200 events, role-filtered notes, injections, agent names) built from four parallel batched reads. Trainees on pending/aborted sessions get 409 NO_ACTIVE_SESSION.

POST /training/sessions/<session_id>/start

👤 · Assigned trainer. pendingactive, activates the first waiting scenario if present, records SESSION_STARTED (+ SCENARIO_ACTIVATED), broadcasts to both user channels. 409 when closed.

POST /training/sessions/<session_id>/pause · /resume · /restart · /reset · /end · /abort

👤 · Assigned trainer. Lifecycle controls, each status-guarded (409 on invalid transitions), each recording its SESSION_* event and broadcasting to trainer/trainee user channels:

  • pause: activepaused, stores elapsed time.
  • resume: pausedactive.
  • restart: any run state → fresh active run (state wiped, evaluation cleared).
  • reset: any run state → pending (trainee redoes the prologue; scenario child back to waiting).
  • end: active/pausedcompleted (grading via /evaluate also completes).
  • abort: non-terminal → aborted (trainee lands on the no-active state).

All return slim {"success": true}.

POST /training/sessions/<session_id>/events

👤 · Participant. Records a whitelisted trainee action (type, target?, client_id?, session_scenario_id?, payload?). Actor is derived from the session; session must be active (409 otherwise); duplicate client_id retries are idempotent (duplicated: true). The scenario engine then evaluates objectives/evidence unlocks. Returns {recorded, duplicated, engine_events}.

POST /training/sessions/<session_id>/scenarios/<scenario_id>/activate

👤 · Assigned trainer. Introduces a scenario into the session (completes any active child), records SCENARIO_ACTIVATED, broadcasts. 409 when already in the session; 404 unknown scenario; 409 unless pending/active/paused.

POST /training/sessions/<session_id>/evidence/<key>/release · /lock

👤 · Assigned trainer. Manual evidence override against the active scenario (404 unknown key). Persists instance + session state, records TRAINER_RELEASED_EVIDENCE/TRAINER_LOCKED_EVIDENCE, broadcasts EVIDENCE_RELEASED/EVIDENCE_LOCKED to the session channel.

POST /training/sessions/<session_id>/objectives/<id>/complete · /reset

👤 · Assigned trainer. Manual objective override (404 unknown objective), records TRAINER_COMPLETED_OBJECTIVE/TRAINER_RESET_OBJECTIVE.

POST /training/sessions/<session_id>/inject

👤 · Assigned trainer. Sends information to the trainee. typesupplemental_report, witness_statement, admin_information, custom_message, simulated_evidence; content required (≤2000 chars), title ≤120. Persists the injection + TRAINER_INJECTED_INFORMATION event; broadcast via the event path. Returns {injection, event}.

POST /training/sessions/<session_id>/notes

👤 · Participant. Trainer private note (starts released=false) or trainee investigation note (visible to the trainer for monitoring, starts released). body required (≤1000 chars). Records TRAINER_NOTE_ADDED/TRAINEE_NOTE_ADDED.

POST /training/sessions/<session_id>/notes/<note_id>/release

👤 · Assigned trainer. Releases a private trainer note to the trainee (404 unknown note), records TRAINER_NOTE_RELEASED.

POST /training/sessions/<session_id>/evaluate

👤 · Assigned trainer. Manual grading: score 0–100, result pass/fail, optional comments/released_feedback (≤2000 each). Completes the session (status → completed, evaluation fields set). Trainees can never write their own grade. Records SESSION_EVALUATED, broadcasts to both users.


ER:LC integration

GET /erlc/player/<roblox_id>

🔑 Player lookup against the current ER:LC server via the relay (erlc_relay.fetch_player — relay selection, failover, circuit breaker owned there). 200 {online, robloxId, username, permission, team, wantedStars, location, raw} (online=false with nulls when the player is not in the server); 400 non-positive ID; upstream errors mapped with Retry-After when provided.


Error responses (summary)

StatusMeaning
400Validation failure (fields, enums, URLs, bounds, reasons missing where required)
401Missing/invalid authentication (session or API key)
403Authorization failure (clearance, rank, assignment, allowlist, bot-key rejection, self-modification, Director required)
404Unknown resource (report, agent, punishment, session, queue item)
409State conflict (status changed, duplicate action, already claimed/completed/closed, already pending)
413Request body too large
500Database/API error (message included for diagnostics)
503Environment unavailable / integration not configured