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.

Download DreamSyncClient.lua402 lines · MIT-licensed reference code

Requirements

  • Game Settings › Security › Allow HTTP Requests must be on.
  • A guild-scoped API key from the /dashboard/<guildId>/api page. 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
end

If 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) — scope link:read
  • Update: UpdateMember(discordId) — scope update:write
  • Bans: ListBans(opts), GetBan(robloxId), CreateBan(opts), LiftBan(opts) — scopes bans:read / bans:write
  • Moderation report: ReportModeration(report) — scope bans:write
  • Global lookups (Developer plan): LookupDiscordGlobal(discordId), LookupRobloxGlobal(robloxId) — scope link:read:global
  • Role checks (Developer plan): DiscordHasRole(discordId, roleId), RobloxHasRole(robloxId, roleId) — scope roles: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