Authentication
All endpoints require an API key passed via the x-api-key request header or ?api_key= query parameter.
Admin Logging
Game server command and spectate logging
/adminlog/session/start
Register a new server session when the Roblox server boots. Must be called before any other /adminlog endpoints.
sessionId |
string: unique per server boot |
serverType |
string: e.g. "Training", "Tryout" |
/adminlog/session/match-start
Mark when the event formally begins. Captures the overseer team at this instant.
sessionId |
string |
overseerTeam optional |
string[]: usernames present at match start |
timestamp optional |
number: Unix seconds or ms, defaults to now |
/adminlog/session/match-end
Record when the match ends. Does not post the log. That happens on server close via /session/discard.
sessionId |
string |
timestamp optional |
number: Unix seconds or ms, defaults to now |
/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.
sessionId |
string |
/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.
sessionId |
string |
username |
string |
/adminlog/command
Record an admin command execution. Call this every time a player runs an Adonis command during the session.
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 |
/adminlog/spectate/start
Record when an admin opens the spectate or CXW UI on a target.
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 |
/adminlog/spectate/end
Record when the spectate or CXW UI is closed.
sessionId |
string |
spectateId |
string: same ID sent to /spectate/start |
timestamp optional |
number: Unix seconds or ms, defaults to now |
Recording Policy
Notify flagged attendees when a new event is logged
/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.
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 |
{ ok: true, notified: number, flaggedAttendees: string[], skipped: number }
Mainframe
Mainframe sheet operations: the trackers, the Event Log, settings and officer management.
/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.
{ weeklyEvents, monthlyEvents, activity: { members[] }, officers: { officers[] }, honored: { members[] }, departments[], settings }
/api/mainframe/events
Returns all rows from the Event Log sheet as structured objects. Cached for 5 minutes.
Array of event objects: { eventId, date, host, eventType, screenshot, attendeeCount, attendeeList, apPoints, opPoints, ... }
/api/mainframe/objectives
Returns current objectives data from the configured Apps Script deployment. Cached for 5 minutes.
/api/mainframe/query
Run a specific named read query against the mainframe. Use the fn parameter to select which query to run.
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 |
/api/mainframe/query?fn=getGroupMembers
Returns a flat list of Roblox usernames from the GroupMembers sheet tab. Cached for 1 hour.
string[]
/api/mainframe/query?fn=getEventById
Look up a single event log entry by its Event ID (column P). Cached for 1 minute.
payload (JSON) |
{ eventId: string } |
{ found: boolean, hostUsername, date, eventType, attendees, screenshot, notes }
/api/mainframe/query?fn=getExemptionDays
Look up how many exemption days a member has used and how many remain.
payload (JSON) |
{ username: string } |
{ found: boolean, daysUsed: number, daysRemaining: number }
/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.
{ events: [{ date, username, eventType, gameId }] }
/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.
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 |
{ success: true, eventId: string }
/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.
quotaWeek optional |
string: week label e.g. "Week 12" |
strikes |
[{ username: string, amount: number, newTotal: number }]: positive amount = added, negative = removed |
{ ok: true, notified: number, failed: number }
/api/mainframe/submit
Run a named write operation against the mainframe. Use the fn field to select which action to perform.
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 |
/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.
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 |
{ success: true }
/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.
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 |
{ success: true, requestId: string }
/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.
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) |
{ success: true, requestId: string }
/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.
payload.username |
string |
payload.rank optional |
string: rank label for position grouping |
{ success: true }
/api/mainframe/submit (fn: removeOfficer)
Remove a member from the Officer Tracker by username (column A). Returns 404 if not found.
payload.username |
string |
{ success: true }
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.
/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.
username |
string: Roblox username (3 to 20 letters, digits or underscores) |
{ 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 }
/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.
names |
string: comma-separated Roblox usernames, at most 100 |
{ avatars: { [username]: { id: string | null, url: string | null } } }
/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.
{ clanLabs: [{ rankId, name, xp, quota, locked }] | null, main: [{ rank, name, memberCount }] | null, ghost: [{ rank, name, memberCount }] | null }
/api/site/medals
Every medal in the Clan Labs catalogue, with its description.
{ medals: [{ name, description }] }
/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.
username optional |
string: Roblox username; leave out for the whole log |
limit optional |
number: 1 to 200, defaults to 50 |
{ changes: [{ at: number (ms) | null, date, username, robloxId, from, to, group, by, reason }] }
/api/site/requestable-medals
The medals a member can ask for with POST /api/site/requests/medal.
{ medals: [{ name, description }] }
/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.
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 |
{ ok: true, message: string }
/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.
discordId |
string: Discord user ID of the member making the request |
robloxId |
string: their Roblox user ID |
robloxUsername |
string: their Roblox username |
{ ok: boolean, message: string }
/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.
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 |
{ ok: true, message: string }
/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.
discordId |
string: Discord user ID of the member making the request |
robloxId |
string: their Roblox user ID |
robloxUsername |
string: their Roblox username |
{ requests: [{ type, id, summary, status, state: "pending" | "approved" | "denied" | "closed", note: string | null, submittedAt: number (ms) | null, updatedAt: number (ms) | null }], unavailable: string[] }
Applications
Application review system
/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.
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 |
{ success: true, flowId: string }
Event Feedback
Send event feedback form responses to Discord for review
/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.
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 |
{ success: true, id: number, duplicate: boolean }
Internal Affairs
IA Notion integration
/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.
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. |
{ success: true, cardId: string, url: string, caseId: number }
Intake
Create intakes (background checks, officer applications, bypasses) for users
/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.
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 |
{ 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
/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.
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 |
{ success: true, status, detail, flowId?, robloxUsername? }: status is one of: processed, skipped, error, already_active, grace_period, already_in_division
Player Stats (write)
For game servers: report per-session player stats and import existing totals
/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.
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) |
{ ok: true, recorded, duplicate, invalid, results: [{ index, status: "recorded" | "duplicate" | "invalid", reason? }] }
A 500 means nothing from the request was saved; resend it unchanged.
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
/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.
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" |
{ ok: true, imported }
Games (read)
For our games and facilities: the live global ban list, who is on the Recording Policy, and who holds the perk roles
/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.
{ count, bans: [{ robloxId, username, reason, date }], source: "live" | "sheet", checkedAt }
/api/games/bans/:robloxId
Whether one account is globally banned, and why. The quickest check to run when a player joins.
{ robloxId, banned: boolean, ban: { robloxId, username, reason, date } | null, source, checkedAt }
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
/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.
{ count, members: [{ robloxId, username, since: "YYYY-MM-DD" | null }], source, checkedAt }
/api/games/recording-policy/:robloxId
Whether one account is on the Recording Policy, for example to announce in chat when they join.
{ robloxId, onPolicy: boolean, username, since, source, checkedAt }
/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.
{ perks: { peak_performance: [{ robloxId, username }], top_host: [...], officer_of_the_month: [...] }, labels, unlinked, notSet: string[], updatedAt }
/api/games/perks/:robloxId
The perks one account has, as keys from /api/games/perks.
{ robloxId, perks: ["peak_performance" | "top_host" | "officer_of_the_month"], labels, updatedAt }
Player Stats (read)
Read player stats, leaderboards and the list of facilities
/api/stats/players/:userId
Everything recorded for one player: combined totals across every facility plus a per-facility breakdown. Hidden facilities are left out.
{ 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.
/api/stats/leaderboard
Top players for one stat, across every facility or for one.
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 |
{ stat, kind, placeId, entries: [{ rank, robloxId, username, value }] }
/api/stats/places
Facilities that have reported stats, busiest first.
{ places: [{ placeId, universeId, name, hidden, players, sessions }] }