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_slug may 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 Evalyn

Using 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

POST/api/handler/shifts

Clock in / out

Start or end a staff member's shift. A clock-in from the game starts the timer immediately.

FieldTypeDescription
actionreqstring"clock_in" or "clock_out".
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqstringThe staff member's Roblox user ID.
game_namestringOptional game/place label for the shift.
duration_minutesnumberOn 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…" }
POST/api/handler/shift-sync

Sync shift duration

Update the running shift's elapsed minutes (call periodically from the game to keep totals live).

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqstringThe staff member's Roblox user ID.
duration_minutesreqnumberMinutes worked so far this shift.
game_namestringOptional 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 }
POST/api/handler/activity

Report server activity

Log a server heartbeat or a player join/leave. player_joined starts a pending web shift; player_left auto-clocks it out.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
actionreqstring"heartbeat", "player_joined", or "player_left".
user_idstringRoblox user ID for join/leave events.
player_countnumberCurrent 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 }
POST/api/handler/afk

Set 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.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqstringThe staff member's Roblox user ID.
actionreqstring"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 }
GET/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.

FieldTypeDescription
workspace_slugreqpathworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqpathRoblox 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"
}
POST/api/handler/blacklist

Add / 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.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
player_idreqstringRoblox user ID to blacklist.
actionstringSet to "remove" to lift the blacklist. Omit to add.
reasonstringWhy they're blacklisted.
player_namestringOptional; fetched from Roblox if omitted.
added_bystringRoblox 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…" }
POST/api/handler/ban

Ban / 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.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
player_idreqstringRoblox user ID to ban/unban.
actionstringSet to "unban" to revoke. Omit to create a ban.
reasonstringBan reason (shown in the dashboard).
banned_bystringRoblox 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 }
POST/api/handler/warn

Warn a player

Record a warning in the member's logbook. DMs the member on Discord if they're linked.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqstringRoblox user ID to warn.
reasonreqstringWarning reason.
warned_bystringName/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 }
POST/api/handler/chat

Log a chat message

Store an in-game chat message for the chat-logs view and moderation.

FieldTypeDescription
workspace_slugreqstringworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqstringSender's Roblox user ID.
messagereqstringThe chat message.
display_namestringSender's display name.
channelstringChat channel (e.g. "all").
game_namestringGame/place label.
server_idstringJob/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 }
GET/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.

FieldTypeDescription
workspace_slugreqpathworkspace_slug accepts either your workspace slug or your Roblox group ID.
user_idreqpathEvalyn/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").

GET/api/v1/{workspace_slug}/staff

Full staff list (role, rank, Roblox identity). Scope: view_staff.

GET/api/v1/{workspace_slug}/shifts?user_id=&limit=

Recent shifts with durations. Scope: view_shifts.

GET/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_ranks scope, 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

POST/api/v1/{workspace_slug}/promote

Move up one rung of the role ladder.

POST/api/v1/{workspace_slug}/demote

Move down one rung.

POST/api/v1/{workspace_slug}/rank

Set an exact role. Idempotent — safe to retry, unlike promote/demote. Prefer this anywhere a request might be sent twice.

GET/api/v1/{workspace_slug}/roles

The role ladder, each rung flagged with whether this key can assign it. Scope: view_staff.

Request body

FieldTypeDescription
roblox_idreqstringWho to rank. Any one of these four identifies the target.
roblox_usernamestringAlternative to roblox_id. Matched case-insensitively.
discord_idstringAlternative — handy from a Discord bot.
user_idstringAlternative — the Evalyn account id.
rolestring/rank only: destination role name.
ranknumber/rank only: destination rank level, if you'd rather not use the name.
stepsnumberpromote/demote: rungs to move. Default 1, max 20.
actor_roblox_idstringWho is doing this. Strongly recommended — see below.
actor_discord_idstringAlternative way to name the acting user.
expected_rolestringRefuse the change unless they're currently in this role.
expected_ranknumberSame, by rank level. Makes retries safe.
reasonstringShown in the audit log and on their profile.
sync_robloxbooleanSet 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
end

Response

{
  "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_required400No target given — send one of the four identifiers.
target_not_found404No Evalyn account matches that identifier. They may not have signed in yet.
target_not_member403They aren't in this workspace.
role_not_found404No role matches that name or rank. Call /roles to see the ladder.
target_is_owner403The workspace owner can't be ranked through the API.
key_ceiling_exceeded403The destination rank is above this key's ceiling.
key_ceiling_target403The member already outranks this key's ceiling.
key_cannot_rank403The key's ceiling is 0 — it can't change ranks.
actor_outranked403The acting user doesn't outrank the target or the new rank.
actor_lacks_permission403The acting user's role can't change ranks.
actor_is_target403Nobody can change their own rank.
at_ceiling / at_floor409Already at the top or bottom of the ladder.
state_mismatch409They aren't in the role you expected — someone got there first.
ambiguous_rank409Two roles share the destination rank. Use /rank with a name.
rate_limited42960 rank changes per key per minute.

Errors

Errors use standard HTTP status codes with a JSON body. Common cases:

401Missing or invalid Authorization header / API secret.
404Workspace not found for the given slug or group ID.
400Missing or malformed request fields.