JSON

Authentication

All endpoints require an API key passed via the x-api-key request header or ?api_key= query parameter.

Keys are scoped. Each key grants access only to the feature groups listed on it. Keys are issued by the server owner and shown once at creation.
adminlog

Admin Logging

Game server command and spectate logging

POST /adminlog/session/start

Register a new server session when the Roblox server boots. Must be called before any other /adminlog endpoints.

Request Body
sessionId string: unique per server boot
serverType string: e.g. "Training", "Tryout"
POST /adminlog/session/match-start

Mark when the event formally begins. Captures the overseer team at this instant.

Request Body
sessionId string
overseerTeam optional string[]: usernames present at match start
timestamp optional number: Unix seconds or ms, defaults to now
POST /adminlog/session/match-end

Record when the match ends. Does not post the log. That happens on server close via /session/discard.

Request Body
sessionId string
timestamp optional number: Unix seconds or ms, defaults to now
POST /adminlog/session/discard

Called on server close (game:BindToClose). If a match was started, posts the full event log to Discord then destroys the session. If no match was started, silently discards.

Request Body
sessionId string
POST /adminlog/overseer/add

Add a player to the overseer team at any point during the session. Idempotent. Safe to call multiple times for the same username.

Request Body
sessionId string
username string
POST /adminlog/command

Record an admin command execution. Call this every time a player runs an Adonis command during the session.

Request Body
sessionId string
executor string: Roblox username of the admin
executorTeam string: team name at time of command
adminLevel number: 0=Everyone, 1=VIP, 2=Mod, 3=Admin, 4=SuperAdmin, >100=Creator
commandName string: name of the Adonis command
target optional string: target username; omit or set to "Self" for self-targeting commands
timestamp optional number: Unix seconds or ms, defaults to now
duringMatch boolean: true if /session/match-start has already been called
POST /adminlog/spectate/start

Record when an admin opens the spectate or CXW UI on a target.

Request Body
sessionId string
spectateId string: client-generated unique ID; used to pair with /spectate/end
spectator string: Roblox username of the admin spectating
target string: Roblox username being spectated
timestamp optional number: Unix seconds or ms, defaults to now
POST /adminlog/spectate/end

Record when the spectate or CXW UI is closed.

Request Body
sessionId string
spectateId string: same ID sent to /spectate/start
timestamp optional number: Unix seconds or ms, defaults to now
api:recording-policy

Recording Policy

Notify flagged attendees when a new event is logged

POST /api/recording-policy/event

Check an event's attendee list against the recording policy flaglist and send Discord DM notifications to any flagged attendees. Called by Google Apps Script when a new event row is logged.

Request Body
eventId string: unique event identifier
host string: host Roblox username
date string: YYYY-MM-DD
eventType string
screenshot optional string: screenshot URL
attendance optional number: total attendee count
attendees string: comma-separated Roblox usernames
Response
{ ok: true, notified: number, flaggedAttendees: string[], skipped: number }
api:mainframe

Mainframe

Mainframe sheet operations: the trackers, the Event Log, settings and officer management.

GET /api/mainframe/data

Returns all mainframe sheet data in one call: weekly and monthly events, activity tracker, officer tracker, honored tracker, departments, and settings. Cached for 10 minutes with stale-while-revalidate. Add ?bust=true to force an immediate refresh.

Response
{ weeklyEvents, monthlyEvents, activity: { members[] }, officers: { officers[] }, honored: { members[] }, departments[], settings }
GET /api/mainframe/events

Returns all rows from the Event Log sheet as structured objects. Cached for 5 minutes.

Response
Array of event objects: { eventId, date, host, eventType, screenshot, attendeeCount, attendeeList, apPoints, opPoints, ... }
GET /api/mainframe/objectives

Returns current objectives data from the configured Apps Script deployment. Cached for 5 minutes.

GET /api/mainframe/query

Run a specific named read query against the mainframe. Use the fn parameter to select which query to run.

Query Parameters
fn "getGroupMembers" | "getEventById" | "getExemptionDays" | "getDeploymentEvents"
payload optional URL-encoded JSON object of arguments for the selected function (see below)
bust optional "true" to bypass the cache for this request
GET /api/mainframe/query?fn=getGroupMembers

Returns a flat list of Roblox usernames from the GroupMembers sheet tab. Cached for 1 hour.

Response
string[]
GET /api/mainframe/query?fn=getEventById

Look up a single event log entry by its Event ID (column P). Cached for 1 minute.

Query Parameters
payload (JSON) { eventId: string }
Response
{ found: boolean, hostUsername, date, eventType, attendees, screenshot, notes }
GET /api/mainframe/query?fn=getExemptionDays

Look up how many exemption days a member has used and how many remain.

Query Parameters
payload (JSON) { username: string }
Response
{ found: boolean, daysUsed: number, daysRemaining: number }
GET /api/mainframe/query?fn=getDeploymentEvents

Returns per-attendee rows from the Monthly Events tab, used for DIS sync. Each row includes the Roblox gameId the DIS tile is keyed to.

Response
{ events: [{ date, username, eventType, gameId }] }
POST /api/mainframe/eventlog

Submit a new event log entry. Writes via the Google Form (same path as the /eventlog Discord command) to ensure formula columns (AP, OP, multiplier) are populated correctly. Responds immediately with a locally-generated eventId; sheet confirmation and DM to the submitter happen in the background.

Request Body
host string: host Roblox username
date string: YYYY-MM-DD
eventType string: must match an event type defined in the Settings tab
screenshot string: direct URL to event screenshot
attendees optional string: comma-separated Roblox usernames
notes optional string
discordId optional string: submitter's Discord ID; if provided, a confirmation DM is sent
Response
{ success: true, eventId: string }
POST /api/mainframe/strikes

Notify officers of strike changes after a quota reset. DMs each affected officer and posts a summary to the configured strike log channel. Called by the mainframe Apps Script after quota resets.

Request Body
quotaWeek optional string: week label e.g. "Week 12"
strikes [{ username: string, amount: number, newTotal: number }]: positive amount = added, negative = removed
Response
{ ok: true, notified: number, failed: number }
POST /api/mainframe/submit

Run a named write operation against the mainframe. Use the fn field to select which action to perform.

Request Body
fn "submitEditEventLog" | "submitStatsTransfer" | "submitMissingAP" | "addOfficer" | "removeOfficer" | "processCallback"
payload object: arguments for the selected function (see below)
discordId optional string: if provided, a DM confirmation is sent to this Discord user ID
POST /api/mainframe/submit (fn: submitEditEventLog)

Edit an existing event log row by its Event ID. Only fields provided in the payload are updated; omitted fields are left unchanged. Busts the event log cache.

Request Body
payload.eventId string: required; the Event ID (column P) of the row to edit
payload.host optional string
payload.date optional string: YYYY-MM-DD
payload.eventType optional string
payload.screenshot optional string
payload.attendees optional string: full replacement comma-separated list
payload.notes optional string
Response
{ success: true }
POST /api/mainframe/submit (fn: submitStatsTransfer)

Submit a stats transfer request. Logs to the transfer sheet as Pending, sends a Discord notification with Approve/Deny links.

Request Body
payload.oldUsername string: current Roblox username
payload.newUsername string: new Roblox username
payload.transferType string: transfer type label
payload.evidence string: URL to evidence screenshot
Response
{ success: true, requestId: string }
POST /api/mainframe/submit (fn: submitMissingAP)

Submit a missing AP request. Logs to the Missing AP Log sheet as Pending and sends a Discord notification with Approve/Deny links. Rate limited to 1 request per 5 minutes per username. Evidence must be a direct http(s) screenshot URL. Discord links are rejected.

Request Body
payload.username string: Roblox username of the member requesting AP
payload.hostUsername string: Roblox username of the event host
payload.date string: YYYY-MM-DD
payload.eventType string
payload.evidence string: direct screenshot URL (no Discord links)
Response
{ success: true, requestId: string }
POST /api/mainframe/submit (fn: addOfficer)

Add a member to the Officer Tracker. Inserts at the correct rank-grouped position. Returns 409 if the username is already on the tracker.

Request Body
payload.username string
payload.rank optional string: rank label for position grouping
Response
{ success: true }
POST /api/mainframe/submit (fn: removeOfficer)

Remove a member from the Officer Tracker by username (column A). Returns 404 if not found.

Request Body
payload.username string
Response
{ success: true }
api:mainframe

Mainframe Website

What the Mainframe website reads and sends for members: profiles, avatars, ranks, medals, rank history and member requests. Uses the same key scope as Mainframe.

GET /api/site/member

Public profile of any Roblox account: its ID, headshot, rank in the Commandos and Ghost groups, Clan Labs XP and medals, every time it joined the division, and what our games have reported for it (gameStats, null if never seen). Clan Labs is only asked about accounts in the Commandos group, so clanLabs is null for anyone else. Returns 404 when no Roblox account has that username.

Query Parameters
username string: Roblox username (3 to 20 letters, digits or underscores)
Response
{ robloxId, username, headshot: string | null, mainRank: { rank, name } | null, ghostRank: { rank, name } | null, clanLabs: { xp, rank, nextRank: { name, xp } | null, medals: string[] } | null, clanLabsUnavailable: boolean, intake: { joinedAt: number (ms), entries: [{ at: number (ms), type, source }] } | null, intakeUnavailable: boolean, gameStats: { sessions, playtimeSeconds, firstSeen, lastSeen (Unix seconds), lastPlace: { placeId, name } | null, stats: [{ name, label, total?, perSession?, best? }], kdr?, winRate?, places: [{ placeId, name, sessions, playtimeSeconds, lastSeen, stats, kdr?, winRate? }] } | null, gameStatsUnavailable: boolean }
GET /api/site/avatars

Roblox user IDs and headshot image URLs for up to 100 usernames in one call. Keys in the response are the usernames in lower case. A name Roblox does not know comes back with id and url set to null.

Query Parameters
names string: comma-separated Roblox usernames, at most 100
Response
{ avatars: { [username]: { id: string | null, url: string | null } } }
GET /api/site/ranks

The Clan Labs ranks, lowest first, with the XP each needs, plus the roles of the Commandos and Ghost Roblox groups. A part that could not be loaded is null. Ranks that are given rather than earned have xp 0 and locked true.

Response
{ clanLabs: [{ rankId, name, xp, quota, locked }] | null, main: [{ rank, name, memberCount }] | null, ghost: [{ rank, name, memberCount }] | null }
GET /api/site/medals

Every medal in the Clan Labs catalogue, with its description.

Response
{ medals: [{ name, description }] }
GET /api/site/rank-changes

Rank history, newest first: every rank change the bot has made (rank sets and evaluation passes) and every approved prestige level. Pass a username to get one account's history. Rows include who made the change and why, so only show those two fields to people who should see them.

Query Parameters
username optional string: Roblox username; leave out for the whole log
limit optional number: 1 to 200, defaults to 50
Response
{ changes: [{ at: number (ms) | null, date, username, robloxId, from, to, group, by, reason }] }
GET /api/site/requestable-medals

The medals a member can ask for with POST /api/site/requests/medal.

Response
{ medals: [{ name, description }] }
POST /api/site/requests/medal

Send a medal request on a member's behalf, the same as /medal request in Discord. It is posted to the medal request channel for staff to approve or deny, and the member is told by DM. A member can send 3 an hour (429 after that). The caller vouches for who is asking: only send the identity of a member you have signed in.

Request Body
discordId string: Discord user ID of the member making the request
robloxId string: their Roblox user ID
robloxUsername string: their Roblox username
medal string: a name from /api/site/requestable-medals
reason optional string: up to 1,000 characters
Response
{ ok: true, message: string }
POST /api/site/requests/prestige

Request the member's next prestige level, the same as /prestige request in Discord. Their Raid/Defense record and Clan Labs XP are checked straight away: Prestige 1 is approved on the spot and levels 2 to 4 go to a vote. Returns 409 with the reason in error when they do not qualify, already have a request open, or a check could not be run.

Request Body
discordId string: Discord user ID of the member making the request
robloxId string: their Roblox user ID
robloxUsername string: their Roblox username
Response
{ ok: boolean, message: string }
POST /api/site/requests/exemption

Request an activity exemption for the member, the same as /exemptionrequest in Discord, with the same checks and automatic approval. The end date is moved to the nearest Monday, since exemptions end when a quota week starts.

Request Body
discordId string: Discord user ID of the member making the request
robloxId string: their Roblox user ID
robloxUsername string: their Roblox username
reason string: up to 1,000 characters
startDate string: YYYY-MM-DD
endDate string: YYYY-MM-DD
Response
{ ok: true, message: string }
GET /api/site/requests/mine

One member's own requests of every type, newest first (at most 60), including ones made in Discord. A type that could not be read is named in unavailable and the rest are still returned.

Query Parameters
discordId string: Discord user ID of the member making the request
robloxId string: their Roblox user ID
robloxUsername string: their Roblox username
Response
{ requests: [{ type, id, summary, status, state: "pending" | "approved" | "denied" | "closed", note: string | null, submittedAt: number (ms) | null, updatedAt: number (ms) | null }], unavailable: string[] }
api:applications

Applications

Application review system

POST /api/applications/notify

Post an application review card to the configured Discord channel. Called by Google Apps Script when a form submission is categorised. The card includes an Approve/Deny interface for staff. The applicationType must match a key defined in lib/applicationTypes.js.

Request Body
applicationType string: e.g. "department", "iwo_app"
applicant optional { discordId?: string, robloxId?: string, robloxUsername?: string }
fields [{ question: string, answer: string }]: dynamic Q&A to display on the card
sourceUrl optional string: link back to the raw submission row
callback optional { spreadsheetId, tab, keyColumn, keyValue, statusColumn, reviewedByColumn, reasonColumn, decidedAtColumn }: sheet cells to write when the application is decided
Response
{ success: true, flowId: string }
api:feedback

Event Feedback

Send event feedback form responses to Discord for review

POST /api/feedback/notify

Post an event feedback form response to the Event Feedback Channel with Accept and Deny. Accepting gives the attendee 5 XP on Clan Labs when they are a real Roblox user; either decision is written to the Event Feedback tab (Status, Reviewer, Date Reviewed, XP Added). Sending the same submissionId again does not post a second card.

Request Body
submissionId string: unique per form response (the form response ID)
attendee string: the submitter's Roblox username
feedback string
host optional string: the host's Roblox username
eventType optional string: e.g. "Specific Event", "General Hosting"
proof optional string: screenshot link or text
timestamp optional string: when it was submitted
Response
{ success: true, id: number, duplicate: boolean }
api:ia

Internal Affairs

IA Notion integration

POST /api/ia/form-submission

Create a Notion card for an IA form submission and post an interactive notification to the configured ia_channel. Supports misconduct reports, punishment appeals, and clemency requests. The Discord notification includes Create Case / Dismiss buttons for IA staff. History requests create a Notion card only (no Discord buttons). Requires NOTION_IA_DATABASE_ID to be configured.

Request Body
formType "misconduct" | "appeal" | "clemency" | "history_request"
submitterRobloxUsername optional string
submitterDiscordId optional string
Timestamp optional string: ISO 8601; defaults to now
...fields All other keys are mapped to Notion paragraph blocks. Standard fields vary by formType. See the Google Form for the exact question text used as keys.
Response
{ success: true, cardId: string, url: string, caseId: number }
api:intake

Intake

Create intakes (background checks, officer applications, bypasses) for users

POST /api/intake/create

Create intakes for one or more users. The same pipeline as the /intake slash command (RoWifi verification, grace-period + active-intake checks, group-membership routing to officer eval / ghost entrance completion / background check). aep and ot are not supported here. They have their own dedicated flows.

Request Body
guildId string: the /intake command reads this from the interaction; the API has no interaction so it must be passed explicitly
type "entrance_bypass" | "ghost" | "department" | "iwo" | "wo" | "nco_iwo" | "ghost_trial" | "ghost_cadet"
userIds string[]: Discord user IDs (not mentions), deduped internally
requestedBy optional string: Discord ID credited as the submitter in DMs/pings
Response
{ success: true, results: [{ userId, status, detail, flowId?, robloxUsername?, tag? }] }: status is one of: processed, skipped, error, not_verified, already_active, grace_period, already_in_division
POST /api/intake/create-direct

CreateIntake. Single-user variant for callers that already know BOTH the Roblox ID and Discord ID (no RoWifi lookup happens). Restricted to the three bypass types. Still runs the same active-intake, grace-period, and group-membership checks as the bulk endpoint before submitting the background check.

Request Body
guildId string: required
type "Entrance Bypass" (entrance_bypass) | "Ghost Bypass" (ghost) | "Department Bypass" (department)
robloxUserId string: required
discordUserId string: required; Discord user ID (not a mention)
requestedBy optional string: Discord ID credited as the submitter in DMs/pings
Response
{ success: true, status, detail, flowId?, robloxUsername? }: status is one of: processed, skipped, error, already_active, grace_period, already_in_division
api:stats:write

Player Stats (write)

For game servers: report per-session player stats and import existing totals

POST /api/stats/sessions

Report what players did in one server. Send deltas (what happened since the last report), usually when a player leaves, in batches of up to 100. Each report has a reportId, so resending the same report after a timeout is safe and is never counted twice. A facility is registered automatically the first time its placeId reports. Stats named kills + deaths get a K/D ratio and wins + losses get a win rate automatically. Keep the key in a server script or the Secrets store, never on the client.

Request Body
placeId string | number: game.PlaceId
universeId optional string | number: game.GameId
placeName optional string: display name for the facility (defaults to the Roblox game name)
sessions array (1 to 100) of { userId, sessionId, reportId?, username?, playtime?, endedAt?, stats?, max? }
sessions[].userId number: Player.UserId
sessions[].sessionId string: one per player visit, e.g. game.JobId .. ":" .. UserId .. ":" .. joinTime. Sessions are counted per distinct sessionId
sessions[].reportId optional string: unique per report. Defaults to sessionId (one report per visit). Use a new one for each periodic flush
sessions[].playtime optional number: seconds played since the previous report (max 86400)
sessions[].stats optional object of stat name to number, added to the running totals (letters, digits, _; max 50)
sessions[].max optional object of stat name to number, keeps the highest value ever seen (e.g. best_streak)
sessions[].endedAt optional number: unix time of the report, defaults to now (within the last 7 days)
Response
{ ok: true, recorded, duplicate, invalid, results: [{ index, status: "recorded" | "duplicate" | "invalid", reason? }] }
A 500 means nothing from the request was saved; resend it unchanged.
Example
local HttpService = game:GetService("HttpService")
local API_KEY = HttpService:GetSecret("CIPHER_STATS_KEY")

local function reportSession(player, session)
  local body = HttpService:JSONEncode({
    placeId = game.PlaceId, universeId = game.GameId,
    sessions = {{
      userId = player.UserId, username = player.Name,
      sessionId = session.id, playtime = os.time() - session.joinedAt,
      stats = { kills = session.kills, deaths = session.deaths, captures = session.captures },
      max = { best_streak = session.bestStreak },
    }},
  })
  local ok, res = pcall(HttpService.RequestAsync, HttpService, {
    Url = "https://api.cipherinteractive.dev/api/stats/sessions", Method = "POST",
    Headers = { ["Content-Type"] = "application/json", ["x-api-key"] = API_KEY },
    Body = body,
  })
  if not ok or not res.Success then warn("Stats report failed, will retry:", ok and res.StatusCode or res) end
end
POST /api/stats/import

One-off backfill of totals a game already tracked before this API existed. mode "add" adds to what is stored, "set" replaces the stored totals. Does not change session counts or playtime.

Request Body
userId string | number
placeId string | number
username optional string
stats optional object of stat name to number
max optional object of stat name to number
mode optional "add" (default) or "set"
Response
{ ok: true, imported }
api:games

Games (read)

For our games and facilities: the live global ban list, who is on the Recording Policy, and who holds the perk roles

GET /api/games/bans

Every global ban, read live from Imperial Affairs, which updates it the moment someone is banned or unbanned (the Google Sheet is only read here if Imperial Affairs is down; source says which). Never cached, so check it when a player joins rather than keeping a copy.

Response
{ count, bans: [{ robloxId, username, reason, date }], source: "live" | "sheet", checkedAt }
GET /api/games/bans/:robloxId

Whether one account is globally banned, and why. The quickest check to run when a player joins.

Response
{ robloxId, banned: boolean, ban: { robloxId, username, reason, date } | null, source, checkedAt }
Example
local HttpService = game:GetService("HttpService")
local res = HttpService:RequestAsync({
  Url = "https://api.cipherinteractive.dev/api/games/bans/" .. player.UserId,
  Method = "GET",
  Headers = { ["x-api-key"] = API_KEY },
})
local data = HttpService:JSONDecode(res.Body)
if data.banned then player:Kick("You are banned: " .. data.ban.reason) end
GET /api/games/recording-policy

Everyone on the Recording Policy. Only the Roblox account and the date they were placed on it are given, never the reason.

Response
{ count, members: [{ robloxId, username, since: "YYYY-MM-DD" | null }], source, checkedAt }
GET /api/games/recording-policy/:robloxId

Whether one account is on the Recording Policy, for example to announce in chat when they join.

Response
{ robloxId, onPolicy: boolean, username, since, source, checkedAt }
GET /api/games/perks

Who holds each perk role (Peak Performance, Top Host, Officer of the Month). The roles are set on the dashboard under Game Perks and matched to Roblox accounts through RoWifi; unlinked counts members with a perk who are not verified. Refreshed every 2 minutes.

Response
{ perks: { peak_performance: [{ robloxId, username }], top_host: [...], officer_of_the_month: [...] }, labels, unlinked, notSet: string[], updatedAt }
GET /api/games/perks/:robloxId

The perks one account has, as keys from /api/games/perks.

Response
{ robloxId, perks: ["peak_performance" | "top_host" | "officer_of_the_month"], labels, updatedAt }
api:stats:read

Player Stats (read)

Read player stats, leaderboards and the list of facilities

GET /api/stats/players/:userId

Everything recorded for one player: combined totals across every facility plus a per-facility breakdown. Hidden facilities are left out.

Response
{ robloxId, username, global: { sessions, playtimeSeconds, firstSeen, lastSeen, lastPlace, stats: { name: { total?, perSession?, best? } }, derived: { kdr?, winRate? } }, places: [{ placeId, name, sessions, playtimeSeconds, firstSeen, lastSeen, stats, derived }] }
404 when the player has no stats yet.
GET /api/stats/leaderboard

Top players for one stat, across every facility or for one.

Query Parameters
stat string: stat name
kind optional "sum" (default, totals) or "max" (best values)
placeId optional string: one facility only
limit optional number: 1 to 100, default 10
offset optional number: for paging
Response
{ stat, kind, placeId, entries: [{ rank, robloxId, username, value }] }
GET /api/stats/places

Facilities that have reported stats, busiest first.

Response
{ places: [{ placeId, universeId, name, hidden, players, sessions }] }