API Reference
Evalyn HTTP API
Integrate your Roblox game with Evalyn — clock shifts, log activity, moderate players and read staff status over a simple JSON API. All requests are made to https://evalyn.tacogroup.uk.
Authentication
Every request is authenticated with your workspace's API secret, sent as a bearer token. The owner can view and regenerate it in Settings → API. Keep it secret — treat it like a password, and store it in a server-side DataStore / secret, never in client code.
Authorization: Bearer <your-workspace-api-secret>Conventions
- All request and response bodies are JSON. Send
Content-Type: application/json. workspace_slugmay be your workspace slug or your Roblox group ID.- Roblox user IDs are passed as strings.
- Successful calls return
{ "ok": true }(plus any data); failures return a non-2xx status with{ "error": "…" }.
Roblox module
Drop this ModuleScript into ServerScriptService (name it EvalynAPI), fill in your workspace and secret, and call it from your server scripts. Enable Allow HTTP Requests in Game Settings → Security first.
--!strict
-- EvalynAPI — ServerScriptService/EvalynAPI (ModuleScript)
local HttpService = game:GetService("HttpService")
local BASE = "https://evalyn.tacogroup.uk"
local WORKSPACE = "my-group" -- your workspace slug or Roblox group ID
local API_SECRET = "PASTE_YOUR_SECRET" -- Settings -> API. Keep server-side only!
local Evalyn = {}
local function request(path: string, method: string, body: any?)
local ok, res = pcall(function()
return HttpService:RequestAsync({
Url = BASE .. path,
Method = method,
Headers = {
["Authorization"] = "Bearer " .. API_SECRET,
["Content-Type"] = "application/json",
},
Body = body and HttpService:JSONEncode(body) or nil,
})
end)
if not ok then
warn("[Evalyn] request failed:", res)
return nil
end
if not res.Success then
warn(("[Evalyn] %d %s"):format(res.StatusCode, tostring(res.Body)))
return nil
end
if res.Body and res.Body ~= "" then
local decoded
pcall(function() decoded = HttpService:JSONDecode(res.Body) end)
return decoded
end
return {}
end
function Evalyn.clockIn(userId: number, gameName: string?)
return request("/api/handler/shifts", "POST", {
action = "clock_in", workspace_slug = WORKSPACE,
user_id = tostring(userId), game_name = gameName,
})
end
function Evalyn.clockOut(userId: number, durationMinutes: number?)
return request("/api/handler/shifts", "POST", {
action = "clock_out", workspace_slug = WORKSPACE,
user_id = tostring(userId), duration_minutes = durationMinutes,
})
end
function Evalyn.syncShift(userId: number, durationMinutes: number)
return request("/api/handler/shift-sync", "POST", {
workspace_slug = WORKSPACE, user_id = tostring(userId),
duration_minutes = durationMinutes,
})
end
function Evalyn.playerJoined(userId: number)
return request("/api/handler/activity", "POST", {
workspace_slug = WORKSPACE, action = "player_joined", user_id = tostring(userId),
})
end
function Evalyn.playerLeft(userId: number)
return request("/api/handler/activity", "POST", {
workspace_slug = WORKSPACE, action = "player_left", user_id = tostring(userId),
})
end
function Evalyn.heartbeat(playerCount: number)
return request("/api/handler/activity", "POST", {
workspace_slug = WORKSPACE, action = "heartbeat", player_count = playerCount,
})
end
function Evalyn.setAfk(userId: number, afk: boolean)
return request("/api/handler/afk", "POST", {
workspace_slug = WORKSPACE, user_id = tostring(userId),
action = afk and "afk" or "active",
})
end
function Evalyn.ban(playerId: number, reason: string?, bannedBy: number?)
return request("/api/handler/ban", "POST", {
workspace_slug = WORKSPACE, player_id = tostring(playerId),
reason = reason, banned_by = bannedBy and tostring(bannedBy) or nil,
})
end
function Evalyn.unban(playerId: number)
return request("/api/handler/ban", "POST", {
workspace_slug = WORKSPACE, player_id = tostring(playerId), action = "unban",
})
end
function Evalyn.warnPlayer(userId: number, reason: string, warnedBy: string?)
return request("/api/handler/warn", "POST", {
workspace_slug = WORKSPACE, user_id = tostring(userId),
reason = reason, warned_by = warnedBy,
})
end
function Evalyn.logChat(userId: number, displayName: string, message: string)
return request("/api/handler/chat", "POST", {
workspace_slug = WORKSPACE, user_id = tostring(userId),
display_name = displayName, message = message,
})
end
function Evalyn.getStatus(userId: number)
return request("/api/handler/status/" .. WORKSPACE .. "/" .. tostring(userId), "GET")
end
-- Returns { banned, blacklisted, action, reason, ... } for the player.
function Evalyn.check(userId: number)
return request("/api/handler/check/" .. WORKSPACE .. "/" .. tostring(userId), "GET")
end
function Evalyn.blacklist(playerId: number, reason: string?, addedBy: number?)
return request("/api/handler/blacklist", "POST", {
workspace_slug = WORKSPACE, player_id = tostring(playerId),
reason = reason, added_by = addedBy and tostring(addedBy) or nil,
})
end
function Evalyn.unblacklist(playerId: number)
return request("/api/handler/blacklist", "POST", {
workspace_slug = WORKSPACE, player_id = tostring(playerId), action = "remove",
})
end
return EvalynUsing it (a Script in ServerScriptService)
local Players = game:GetService("Players")
local Evalyn = require(game.ServerScriptService.EvalynAPI)
Players.PlayerAdded:Connect(function(player)
-- Enforce bans & blacklists on join.
local verdict = Evalyn.check(player.UserId)
if verdict and verdict.action == "kick" then
player:Kick("You are " .. (verdict.banned and "banned" or "blacklisted")
.. (verdict.reason and (": " .. verdict.reason) or "") .. ".")
return
end
Evalyn.playerJoined(player.UserId)
-- Log this player's chat (server-side, works with TextChatService).
player.Chatted:Connect(function(message)
Evalyn.logChat(player.UserId, player.DisplayName, message)
end)
end)
Players.PlayerRemoving:Connect(function(player)
Evalyn.playerLeft(player.UserId)
end)
-- Heartbeat every 60s
task.spawn(function()
while task.wait(60) do
Evalyn.heartbeat(#Players:GetPlayers())
end
end)Instant AFK when a player alt-tabs (optional)
Window focus is a client signal, so this needs two parts. On the server, create a RemoteEvent named EvalynAFK in ReplicatedStorage and forward it to Evalyn.setAfk:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local afk = ReplicatedStorage:FindFirstChild("EvalynAFK") or Instance.new("RemoteEvent")
afk.Name = "EvalynAFK"
afk.Parent = ReplicatedStorage
afk.OnServerEvent:Connect(function(player, focused)
Evalyn.setAfk(player.UserId, not focused)
end)Then add a LocalScript in StarterPlayer → StarterPlayerScripts:
local UserInputService = game:GetService("UserInputService")
local afk = game:GetService("ReplicatedStorage"):WaitForChild("EvalynAFK")
-- Fires the moment the player alt-tabs / clicks another app.
UserInputService.WindowFocusReleased:Connect(function() afk:FireServer(false) end)
UserInputService.WindowFocused:Connect(function() afk:FireServer(true) end)The downloadable handler script already includes the server half — you only need to add the LocalScript.
Endpoints
/api/handler/shiftsClock in / out
Start or end a staff member's shift. A clock-in from the game starts the timer immediately.
| Field | Type | Description |
|---|---|---|
actionreq | string | "clock_in" or "clock_out". |
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | string | The staff member's Roblox user ID. |
game_name | string | Optional game/place label for the shift. |
duration_minutes | number | On clock_out, the minutes worked (game-tracked). |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/shifts \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"action":"clock_in","workspace_slug":"my-group","user_id":"261","game_name":"Main Cafe"}'Example response
{ "ok": true, "shift_id": "b2f1…" }/api/handler/shift-syncSync shift duration
Update the running shift's elapsed minutes (call periodically from the game to keep totals live).
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | string | The staff member's Roblox user ID. |
duration_minutesreq | number | Minutes worked so far this shift. |
game_name | string | Optional game/place label. |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/shift-sync \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","user_id":"261","duration_minutes":42}'Example response
{ "ok": true }/api/handler/activityReport server activity
Log a server heartbeat or a player join/leave. player_joined starts a pending web shift; player_left auto-clocks it out.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
actionreq | string | "heartbeat", "player_joined", or "player_left". |
user_id | string | Roblox user ID for join/leave events. |
player_count | number | Current server population (for heartbeats). |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/activity \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","action":"player_joined","user_id":"261"}'Example response
{ "ok": true }/api/handler/afkSet AFK state
Mark the member's active shift as AFK or active. A GET on the same path returns the user's live in-game presence.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | string | The staff member's Roblox user ID. |
actionreq | string | "afk" or "active". |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/afk \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","user_id":"261","action":"afk"}'Example response
{ "ok": true }/api/handler/check/{workspace_slug}/{user_id}Check a player (ban + blacklist)
Call on player join. Returns whether the player is banned or blacklisted (in this workspace or via a shared blacklist from an allied group) and a recommended action so you can kick them.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | path | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | path | Roblox user ID of the joining player. |
Example request
curl https://evalyn.tacogroup.uk/api/handler/check/my-group/99 \
-H "Authorization: Bearer $EVALYN_SECRET"Example response
{
"ok": true,
"banned": false,
"blacklisted": true,
"action": "kick",
"reason": "Exploiting",
"source": "shared",
"source_workspace": "Allied Cafe"
}/api/handler/blacklistAdd / remove a blacklist
Blacklist a player (or remove them). Blacklists are enforced by the check endpoint above and are shareable with allied workspaces in the dashboard.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
player_idreq | string | Roblox user ID to blacklist. |
action | string | Set to "remove" to lift the blacklist. Omit to add. |
reason | string | Why they're blacklisted. |
player_name | string | Optional; fetched from Roblox if omitted. |
added_by | string | Roblox user ID of the moderator. |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/blacklist \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","player_id":"99","reason":"Ban evasion","added_by":"261"}'Example response
{ "ok": true, "id": "c3a9…" }/api/handler/banBan / unban a player
Create or revoke a ban. When Open Cloud is configured the ban is also applied in-game, and Discord ban sync fires if enabled.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
player_idreq | string | Roblox user ID to ban/unban. |
action | string | Set to "unban" to revoke. Omit to create a ban. |
reason | string | Ban reason (shown in the dashboard). |
banned_by | string | Roblox user ID of the moderator. |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/ban \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","player_id":"99","reason":"Exploiting","banned_by":"261"}'Example response
{ "ok": true }/api/handler/warnWarn a player
Record a warning in the member's logbook. DMs the member on Discord if they're linked.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | string | Roblox user ID to warn. |
reasonreq | string | Warning reason. |
warned_by | string | Name/ID of the issuing moderator. |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/warn \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","user_id":"99","reason":"Spamming"}'Example response
{ "ok": true }/api/handler/chatLog a chat message
Store an in-game chat message for the chat-logs view and moderation.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | string | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | string | Sender's Roblox user ID. |
messagereq | string | The chat message. |
display_name | string | Sender's display name. |
channel | string | Chat channel (e.g. "all"). |
game_name | string | Game/place label. |
server_id | string | Job/server ID. |
Example request
curl -X POST https://evalyn.tacogroup.uk/api/handler/chat \
-H "Authorization: Bearer $EVALYN_SECRET" \
-H "Content-Type: application/json" \
-d '{"workspace_slug":"my-group","user_id":"261","message":"hello","display_name":"Nova"}'Example response
{ "ok": true }/api/handler/status/{workspace_slug}/{user_id}Get a member's status
Fetch a staff member's role, rank and shift totals — handy for in-game panels.
| Field | Type | Description |
|---|---|---|
workspace_slugreq | path | workspace_slug accepts either your workspace slug or your Roblox group ID. |
user_idreq | path | Evalyn/Roblox user ID. |
Example request
curl https://evalyn.tacogroup.uk/api/handler/status/my-group/261 \
-H "Authorization: Bearer $EVALYN_SECRET"Example response
{
"display_name": "Nova",
"role": "Manager",
"rank": 100,
"total_shifts": 34,
"total_minutes": 1820
}REST API (API keys)
Separate from the game handler, workspaces can create API keys (the API Keys page) with scoped permissions for read access from your own tools and bots. Send the key as a bearer token. Each endpoint requires the matching scope on the key ("View Staff", "View Shifts", "View Sessions").
/api/v1/{workspace_slug}/staffFull staff list (role, rank, Roblox identity). Scope: view_staff.
/api/v1/{workspace_slug}/shifts?user_id=&limit=Recent shifts with durations. Scope: view_shifts.
/api/v1/{workspace_slug}/sessions?status=Sessions with participant counts. Scope: view_sessions.
curl https://evalyn.tacogroup.uk/api/v1/my-group/staff \
-H "Authorization: Bearer <your-api-key>"Promoting & demoting
Change a member's rank from outside the dashboard — a Discord bot, an in-game panel, or your own tooling. The change lands in Evalyn, mirrors to your linked Roblox group, DMs the member, and shows up in the audit log and on their profile exactly as if it had been done by hand.
Before you start
- These endpoints need the
change_ranksscope, ticked on its own. A key with*does not get it — that would have silently upgraded every key already in the wild. - Every key has a rank ceiling. It can't grant a rank above it, and can't touch a member already above it. Set it as low as the job allows: if the key leaks, this is what bounds the damage.
- The workspace owner can never be ranked through the API, and neither can anyone at rank 254+.
Endpoints
/api/v1/{workspace_slug}/promoteMove up one rung of the role ladder.
/api/v1/{workspace_slug}/demoteMove down one rung.
/api/v1/{workspace_slug}/rankSet an exact role. Idempotent — safe to retry, unlike promote/demote. Prefer this anywhere a request might be sent twice.
/api/v1/{workspace_slug}/rolesThe role ladder, each rung flagged with whether this key can assign it. Scope: view_staff.
Request body
| Field | Type | Description |
|---|---|---|
roblox_idreq | string | Who to rank. Any one of these four identifies the target. |
roblox_username | string | Alternative to roblox_id. Matched case-insensitively. |
discord_id | string | Alternative — handy from a Discord bot. |
user_id | string | Alternative — the Evalyn account id. |
role | string | /rank only: destination role name. |
rank | number | /rank only: destination rank level, if you'd rather not use the name. |
steps | number | promote/demote: rungs to move. Default 1, max 20. |
actor_roblox_id | string | Who is doing this. Strongly recommended — see below. |
actor_discord_id | string | Alternative way to name the acting user. |
expected_role | string | Refuse the change unless they're currently in this role. |
expected_rank | number | Same, by rank level. Makes retries safe. |
reason | string | Shown in the audit log and on their profile. |
sync_roblox | boolean | Set false to skip the Roblox group update. Default true. |
Naming the acting user
Pass actor_roblox_id or actor_discord_idand the call is checked against that person's own rank: they must hold manage_staff or edit_staff_roles, must outrank the target, and must outrank the rank being granted. Nobody can rank themselves.
It's optional, but a bot that omits it is telling Evalyn “trust the key, not the person” — so a leaked token can do anything the ceiling allows, and the audit log records the key instead of a name. Send it whenever you know who typed the command.
Discord bot
// /promote @user — discord.js
const res = await fetch(
"https://evalyn.tacogroup.uk/api/v1/my-group/promote",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.EVALYN_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
discord_id: target.id,
actor_discord_id: interaction.user.id, // who ran the command
reason: `via /promote in #${interaction.channel.name}`,
}),
}
);
const data = await res.json();
if (!data.ok) {
// Branch on `code`, never on the error text — the wording may change.
const friendly = {
actor_outranked: "You can't promote someone at or above your own rank.",
at_ceiling: "They're already at the top of the ladder.",
key_ceiling_exceeded: "That rank is above what this bot is allowed to grant.",
target_not_member: "They aren't in the group.",
}[data.code];
return interaction.reply(friendly ?? data.error);
}
await interaction.reply(
`${data.member.username}: ${data.from.name} → ${data.to.name}` +
(data.roblox && !data.roblox.ok ? `\n⚠️ Roblox group not updated: ${data.roblox.error}` : "")
);In-game (Luau)
Note expected_rank: Roblox HTTP calls time out and get retried, and a retried promote would move someone two rungs. Sending the rank you believe they hold makes the second attempt fail harmlessly with state_mismatch instead.
local HttpService = game:GetService("HttpService")
local EVALYN_KEY = "ev_…" -- store in a secret, never in client code
local WORKSPACE = "my-group"
local function promote(targetUserId: number, actorUserId: number, currentRank: number)
local ok, res = pcall(function()
return HttpService:RequestAsync({
Url = "https://evalyn.tacogroup.uk/api/v1/" .. WORKSPACE .. "/promote",
Method = "POST",
Headers = {
["Authorization"] = "Bearer " .. EVALYN_KEY,
["Content-Type"] = "application/json",
},
Body = HttpService:JSONEncode({
roblox_id = tostring(targetUserId),
actor_roblox_id = tostring(actorUserId),
expected_rank = currentRank, -- makes a retry safe
reason = "In-game promotion panel",
}),
})
end)
if not ok then
return false, "Could not reach Evalyn"
end
local body = HttpService:JSONDecode(res.Body)
if not body.ok then
return false, body.error
end
return true, body.to.name
endResponse
{
"ok": true,
"changed": true,
"member": {
"user_id": "cm2…",
"username": "Builderman",
"display_name": "Builderman",
"roblox_id": "156"
},
"from": { "name": "Trainee", "rank": 5 },
"to": { "name": "Moderator", "rank": 10 },
"effective_rank": 10,
"direction": "promotion",
"actor": { "user_id": "cm1…", "username": "Shedletsky", "rank": 200 },
"roblox": { "ok": true, "role": "Moderator (rank 10)" }
}changed: false means they were already in that role — a no-op, not a failure. effective_rank is what permission and rank gates actually compare against. It usually equals to.rank, but a member can hold additional Evalyn roles alongside their group role, and effective rank is the highest of them — so a demotion can succeed on the group and still leave the member above the rank you demoted them to. Check it if that matters to you. roblox is null when no group is linked, and reports ok: false with a reason if the group update failed. Evalyn is the source of truth: a Roblox failure is reported, never rolled back, so a Roblox outage can't block your staff team.
Error codes
Failures return { "ok": false, "code": "…", "error": "…" }. Branch on code— it's stable; the message is not.
target_required | 400 | No target given — send one of the four identifiers. |
target_not_found | 404 | No Evalyn account matches that identifier. They may not have signed in yet. |
target_not_member | 403 | They aren't in this workspace. |
role_not_found | 404 | No role matches that name or rank. Call /roles to see the ladder. |
target_is_owner | 403 | The workspace owner can't be ranked through the API. |
key_ceiling_exceeded | 403 | The destination rank is above this key's ceiling. |
key_ceiling_target | 403 | The member already outranks this key's ceiling. |
key_cannot_rank | 403 | The key's ceiling is 0 — it can't change ranks. |
actor_outranked | 403 | The acting user doesn't outrank the target or the new rank. |
actor_lacks_permission | 403 | The acting user's role can't change ranks. |
actor_is_target | 403 | Nobody can change their own rank. |
at_ceiling / at_floor | 409 | Already at the top or bottom of the ladder. |
state_mismatch | 409 | They aren't in the role you expected — someone got there first. |
ambiguous_rank | 409 | Two roles share the destination rank. Use /rank with a name. |
rate_limited | 429 | 60 rank changes per key per minute. |
Errors
Errors use standard HTTP status codes with a JSON body. Common cases:
401 | Missing or invalid Authorization header / API secret. |
404 | Workspace not found for the given slug or group ID. |
400 | Missing or malformed request fields. |