Luau Client
DreamSync ships a small OOP wrapper for the HTTP API so Roblox game servers can call DreamSync without writing their own HttpService plumbing. Drop the module into ServerScriptService (or ServerStorage) and require it from a server Script.
Requirements
- Game Settings › Security › Allow HTTP Requests must be on.
- A guild-scoped API key from the
/dashboard/<guildId>/apipage. Pick the scopes the methods you call require — each method's docstring lists them. - Use the module from server scripts only. The API key must never reach the client.
Quick start
local DreamSync = require(game.ServerScriptService.DreamSyncClient)
local client = DreamSync.new({
apiKey = "ds_live_xxxxxxxxxxxxxxxxxxxxxxxx",
guildId = "123456789012345678",
})
-- Look up a Discord member's linked Roblox account
local result = client:LookupDiscord("987654321098765432")
if result.ok then
print("Roblox ID:", result.data.robloxId)
else
warn(result.error)
end
-- Ban a user across every configured universe + sync to Discord
client:CreateBan({
robloxId = 12345678,
reason = "Exploiting",
durationSeconds = 7 * 24 * 3600, -- 7 days
syncDiscord = true,
})
-- Tell DreamSync that your game just banned someone so it can mirror
-- the ban onto Discord (Roblox → Discord sync)
client:ReportModeration({
robloxId = 12345678,
action = "ban",
universeId = game.GameId,
reason = "Caught speed-hacking",
})Result shape
Every method returns the same envelope so callers don't need to pcall:
type Result<T> = {
ok: boolean, -- true when HTTP 2xx
statusCode: number, -- HTTP status, 0 if RequestAsync threw
data: T?, -- decoded JSON body on success, raw error body on failure
error: string?, -- human-readable error message when ok = false
}Mirroring in-game bans to Discord
Roblox doesn't push DreamSync any "user banned" webhook, so the only way Roblox-side bans propagate to Discord is when your game tells DreamSync about them. That's a one-line API call right after your existing ban code:
local DreamSync = require(game.ServerScriptService.DreamSyncClient)
local client = DreamSync.new({
apiKey = "ds_live_xxxxxxxxxxxxxxxxxxxxxxxx",
guildId = "123456789012345678",
})
local function onBanIssued(player: Player, reason: string)
-- 1) Apply your normal in-game ban via your own moderation system.
-- (DataStore write, kick, restriction, etc.)
-- 2) Tell DreamSync so the linked Discord account gets banned too.
local result = client:ReportModeration({
robloxId = player.UserId,
action = "ban",
universeId = game.GameId,
reason = reason,
})
if not result.ok then
warn(("DreamSync sync failed (%s): %s"):format(
tostring(result.statusCode), result.error or "unknown"
))
end
endIf you'd rather have DreamSync drive the ban end-to-end (apply the Roblox restriction AND ban the Discord user in one shot), use CreateBan instead — it replaces your in-game ban code, calls Roblox Open Cloud for you, and syncs Discord in the same request:
client:CreateBan({
robloxId = player.UserId,
reason = "Caught speed-hacking",
durationSeconds = 7 * 24 * 3600, -- omit for permanent
syncDiscord = true,
})Either method requires the guild's Pro plan and a configured ban integration in Roblox Moderation matching game.GameId. The reason template configured in Ban Sync Settings is applied automatically when DreamSync writes the Discord ban.
Methods
The constructor accepts a guild-scoped client and exposes the following methods.
- Lookups (guild):
LookupDiscord(discordId),LookupRoblox(robloxId)— scopelink:read - Update:
UpdateMember(discordId)— scopeupdate:write - Bans:
ListBans(opts),GetBan(robloxId),CreateBan(opts),LiftBan(opts)— scopesbans:read/bans:write - Moderation report:
ReportModeration(report)— scopebans:write - Global lookups (Developer plan):
LookupDiscordGlobal(discordId),LookupRobloxGlobal(robloxId)— scopelink:read:global - Role checks (Developer plan):
DiscordHasRole(discordId, roleId),RobloxHasRole(robloxId, roleId)— scoperoles:check
Source
The full module source is below. It's a single file with no dependencies beyond Roblox's built-in HttpService, so paste it into a ModuleScript verbatim.
--!strict
-- DreamSyncClient.lua
--
-- OOP wrapper around the DreamSync HTTP API for use inside a Roblox
-- game server. Drop this ModuleScript into ServerScriptService (or
-- ServerStorage) and require it from a Script.
--
-- Quick start:
--
-- local DreamSync = require(game.ServerScriptService.DreamSyncClient)
-- local client = DreamSync.new({
-- apiKey = "ds_live_xxxxxxxxxxxxxxxxxxxxxxxx",
-- guildId = "123456789012345678",
-- })
--
-- local result = client:LookupDiscord("987654321098765432")
-- if result.ok then
-- print("Roblox ID:", result.data.robloxId)
-- else
-- warn("lookup failed:", result.error)
-- end
--
-- Requirements:
-- - Game Settings > Security > "Allow HTTP Requests" must be ON.
-- - The API key must be created in the DreamSync dashboard under
-- the API section, scoped to this guild, with the scopes you
-- intend to use (see method docs below for required scopes).
-- - Never expose your API key to clients. Only use this module
-- from server-side scripts.
local HttpService = game:GetService("HttpService")
-- Default base URL. Pass `baseUrl` in the constructor to override (e.g.
-- when pointing at a self-hosted DreamSync instance).
local DEFAULT_BASE_URL = "https://api.dreamsync.link/api/v1"
-- Default request timeout. Roblox's HttpService doesn't expose a
-- per-request timeout, so this is enforced via task.delay + cancel.
local DEFAULT_TIMEOUT_SECONDS = 10
export type ClientOptions = {
apiKey: string,
guildId: string,
baseUrl: string?,
timeoutSeconds: number?,
}
export type Result<T> = {
ok: boolean,
statusCode: number,
data: T?,
error: string?,
}
export type BanRecord = {
guildId: string,
robloxId: string,
cachedUsername: string?,
reason: string?,
displayReason: string?,
bannedByDiscordId: string,
enforceDiscord: boolean,
createdAt: string,
liftedAt: string?,
}
export type BanUniverseOutcome = {
banIntegrationId: string,
universeId: string,
label: string,
status: string, -- 'success' | 'failed'
error: string?,
}
export type BanCreateOptions = {
robloxId: number | string,
reason: string,
durationSeconds: number?, -- nil = permanent
banIntegrationIds: { string }?, -- nil = all configured
syncDiscord: boolean?, -- defaults to true server-side
}
export type BanLiftOptions = {
robloxId: number | string,
reason: string?,
banIntegrationIds: { string }?,
syncDiscord: boolean?,
}
export type ModerationReport = {
robloxId: number | string,
action: string, -- 'ban' or 'unban'
universeId: number | string,
reason: string?,
}
local DreamSyncClient = {}
DreamSyncClient.__index = DreamSyncClient
export type Client = typeof(setmetatable(
{} :: {
_apiKey: string,
_guildId: string,
_baseUrl: string,
_timeout: number,
},
DreamSyncClient
))
-- ============================================================
-- Constructor
-- ============================================================
function DreamSyncClient.new(opts: ClientOptions): Client
assert(type(opts) == "table", "DreamSync.new expects a table of options")
assert(type(opts.apiKey) == "string" and #opts.apiKey > 0, "apiKey is required")
assert(type(opts.guildId) == "string" and #opts.guildId > 0, "guildId is required")
local self = setmetatable({
_apiKey = opts.apiKey,
_guildId = opts.guildId,
_baseUrl = opts.baseUrl or DEFAULT_BASE_URL,
_timeout = opts.timeoutSeconds or DEFAULT_TIMEOUT_SECONDS,
}, DreamSyncClient)
return self
end
-- ============================================================
-- Internal request helpers
-- ============================================================
local function encodeQueryParam(key: string, value: any): string
return HttpService:UrlEncode(key) .. "=" .. HttpService:UrlEncode(tostring(value))
end
local function buildQuery(query: { [string]: any }?): string
if not query then return "" end
local parts = {}
for k, v in pairs(query) do
if v ~= nil then
table.insert(parts, encodeQueryParam(k, v))
end
end
if #parts == 0 then return "" end
return "?" .. table.concat(parts, "&")
end
-- Coerce a number/string Roblox or Discord id to a string. The API
-- always takes IDs as strings to avoid 64-bit precision loss.
local function toIdString(id: number | string): string
if type(id) == "number" then
return string.format("%.0f", id)
end
return id
end
local function decode(body: string): any
if body == nil or body == "" then return nil end
local ok, decoded = pcall(HttpService.JSONDecode, HttpService, body)
if ok then return decoded end
return body
end
function DreamSyncClient._request<T>(
self: Client,
method: string,
path: string,
body: any?,
query: { [string]: any }?
): Result<T>
local url = self._baseUrl .. path .. buildQuery(query)
local headers: { [string]: string } = {
["Authorization"] = "Bearer " .. self._apiKey,
["Accept"] = "application/json",
}
local request: { [string]: any } = {
Url = url,
Method = method,
Headers = headers,
}
if body ~= nil then
headers["Content-Type"] = "application/json"
request.Body = HttpService:JSONEncode(body)
end
local ok, response = pcall(HttpService.RequestAsync, HttpService, request)
if not ok then
return {
ok = false,
statusCode = 0,
error = "RequestAsync failed: " .. tostring(response),
}
end
local decoded = decode(response.Body)
if response.Success then
return {
ok = true,
statusCode = response.StatusCode,
data = decoded,
}
end
local errMessage: string
if type(decoded) == "table" and decoded.error and decoded.error.message then
errMessage = decoded.error.message
elseif type(decoded) == "table" and decoded.error and decoded.error.code then
errMessage = decoded.error.code
else
errMessage = "HTTP " .. tostring(response.StatusCode)
end
return {
ok = false,
statusCode = response.StatusCode,
data = decoded,
error = errMessage,
}
end
-- ============================================================
-- Lookups (guild-scoped)
-- Required scopes: link:read
-- ============================================================
-- Look up the Roblox account linked to a Discord user, scoped to this
-- guild. Use this for Free/Pro tiers — the result is restricted to
-- DreamSync members of the calling guild.
function DreamSyncClient.LookupDiscord(self: Client, discordId: number | string): Result<any>
return self:_request(
"GET",
"/guilds/" .. self._guildId .. "/discord-to-roblox/" .. toIdString(discordId)
)
end
-- Look up Discord users linked to a Roblox account, scoped to this
-- guild. Free/Pro tier semantics — same caveats as LookupDiscord.
function DreamSyncClient.LookupRoblox(self: Client, robloxId: number | string): Result<any>
return self:_request(
"GET",
"/guilds/" .. self._guildId .. "/roblox-to-discord/" .. toIdString(robloxId)
)
end
-- ============================================================
-- Updates
-- Required scopes: members:update
-- ============================================================
-- Trigger DreamSync to recompute role bindings + nickname for a Discord
-- member. Useful after a custom in-game event that should immediately
-- flip a role binding (e.g. the player just hit max level).
function DreamSyncClient.UpdateMember(self: Client, discordId: number | string): Result<any>
return self:_request(
"POST",
"/guilds/" .. self._guildId .. "/update/" .. toIdString(discordId)
)
end
-- ============================================================
-- Bans
-- Required scopes: bans:read for List/Get, bans:write for Create/Lift
-- ============================================================
-- List active bans (or include lifted with `includeLifted = true`).
-- Cursor-paginated; `nextCursor` in the response feeds the next call.
function DreamSyncClient.ListBans(
self: Client,
opts: { includeLifted: boolean?, limit: number?, cursor: string? }?
): Result<{ items: { BanRecord }, nextCursor: string? }>
return self:_request("GET", "/guilds/" .. self._guildId .. "/bans", nil, opts)
end
-- Fetch a single ban record by Roblox user ID. Returns 404 if no ban
-- exists for that user in this guild.
function DreamSyncClient.GetBan(self: Client, robloxId: number | string): Result<BanRecord>
return self:_request(
"GET",
"/guilds/" .. self._guildId .. "/bans/" .. toIdString(robloxId)
)
end
-- Create a Roblox ban. Fans out to all configured ban integrations
-- unless `banIntegrationIds` is provided. `syncDiscord` defaults to
-- true server-side, set false to skip the Discord-side ban.
function DreamSyncClient.CreateBan(
self: Client,
options: BanCreateOptions
): Result<BanRecord & { universes: { BanUniverseOutcome }, discordSync: string }>
assert(options ~= nil and options.robloxId ~= nil, "robloxId is required")
assert(type(options.reason) == "string" and #options.reason > 0, "reason is required")
local robloxId = toIdString(options.robloxId)
local body = {
reason = options.reason,
durationSeconds = options.durationSeconds,
banIntegrationIds = options.banIntegrationIds,
syncDiscord = options.syncDiscord,
}
return self:_request("POST", "/guilds/" .. self._guildId .. "/bans/" .. robloxId, body)
end
-- Lift (unban) a Roblox ban. Same fan-out semantics as CreateBan.
function DreamSyncClient.LiftBan(
self: Client,
options: BanLiftOptions
): Result<{ lifted: boolean, universes: { BanUniverseOutcome }, discordSync: string }>
assert(options ~= nil and options.robloxId ~= nil, "robloxId is required")
local robloxId = toIdString(options.robloxId)
local body = {
reason = options.reason,
banIntegrationIds = options.banIntegrationIds,
syncDiscord = options.syncDiscord,
}
return self:_request("DELETE", "/guilds/" .. self._guildId .. "/bans/" .. robloxId, body)
end
-- ============================================================
-- Moderation report
-- Required scopes: bans:write
-- ============================================================
-- Report a Roblox-side ban or unban so DreamSync can mirror it onto
-- Discord (Roblox→Discord sync). Use this when your game server bans
-- someone via your own logic and you want the Discord ban to follow.
-- DreamSync only acts if the guild has Roblox→Discord sync enabled
-- and (in SELECTED mode) the source universe is on the allow-list.
function DreamSyncClient.ReportModeration(
self: Client,
report: ModerationReport
): Result<{ applied: boolean, reason: string, error: string? }>
assert(report ~= nil, "report is required")
assert(report.action == "ban" or report.action == "unban", "action must be 'ban' or 'unban'")
local body = {
robloxId = toIdString(report.robloxId),
action = report.action,
universeId = toIdString(report.universeId),
reason = report.reason,
}
return self:_request("POST", "/guilds/" .. self._guildId .. "/moderation/report", body)
end
-- ============================================================
-- Developer-tier global lookups
-- Required scopes: link:read:global
-- Plan required: Developer
-- ============================================================
-- Global Discord→Roblox lookup not scoped to this guild. Returns the
-- linked Roblox ID for any DreamSync user. Developer plan only.
function DreamSyncClient.LookupDiscordGlobal(self: Client, discordId: number | string): Result<any>
return self:_request("GET", "/discord-to-roblox/" .. toIdString(discordId))
end
-- Global Roblox→Discord lookup. Developer plan only.
function DreamSyncClient.LookupRobloxGlobal(self: Client, robloxId: number | string): Result<any>
return self:_request("GET", "/roblox-to-discord/" .. toIdString(robloxId))
end
-- ============================================================
-- Role checks (Developer-tier)
-- Required scopes: roles:check
-- ============================================================
-- Check whether the Discord user has a given Discord role in the
-- caller's guild. Returns `{ hasRole = true|false }`.
function DreamSyncClient.DiscordHasRole(
self: Client,
discordId: number | string,
roleId: string
): Result<{ hasRole: boolean }>
return self:_request(
"GET",
"/guilds/" .. self._guildId
.. "/discord-has-role/" .. toIdString(discordId)
.. "/" .. roleId
)
end
-- Check whether the Roblox user (linked to a member of this guild)
-- has a given Discord role. Returns 404 if the Roblox user isn't a
-- DreamSync-linked member of this guild.
function DreamSyncClient.RobloxHasRole(
self: Client,
robloxId: number | string,
roleId: string
): Result<{ hasRole: boolean }>
return self:_request(
"GET",
"/guilds/" .. self._guildId
.. "/roblox-has-role/" .. toIdString(robloxId)
.. "/" .. roleId
)
end
return DreamSyncClient