GROUPSPACE
Developers · Roblox

Luau SDK quickstart

A server-side Luau module. Drop it in, call Init with a server key, and presence, doors, moderation commands and clock-ins start working without you writing a request.

Last updated 10 August 2026

Overview

The SDK is a client for the game REST API. It has no privileged access — everything it does you could do yourself with HttpService. What it saves you is the tedious and easy-to-get-wrong part: batching events inside the request budget, backing off correctly, keeping a bounded local queue, acking commands, caching door and position lookups, and flushing on shutdown.

It runs on the game server. Nothing in it should be required from a LocalScript, and your server key must never reach a client.

The server key is a server secret. Anything replicated to a client is public. Keep the module in ServerScriptService or ServerStorage, and never put the key in a ModuleScript that lives under ReplicatedStorage.

Install

Three ways in, in order of how much tooling you already have.

Wally

# wally.toml
[server-dependencies]
GroupSpace = "groupspace/groupspace@1.0.0"

Roblox model

Take the published model from the workspace game settings page and drag it into ServerScriptService. The model is versioned; the version you insert is the version you keep until you replace it deliberately.

One-file drop-in

A single ModuleScript with no dependencies, for people who do not use a package manager and do not want a folder of files. Same API, same behaviour, shipped alongside every release.

However you install it, the module ends up at a path you require from a server script.

local GroupSpace = require(game.ServerScriptService.GroupSpace)

Enable HttpService

This is the number-one setup failure, and no code can fix it. HttpService.HttpEnabled cannot be turned on from a script. It is a manual toggle in Experience Settings, under Security, and it must be enabled on the experience before any request leaves the server. If it is off, every call fails and Roblox reports it as a generic HTTP 403.

To turn it on: Studio, then Game Settings, then Security, then Allow HTTP Requests.

The SDK detects this case specifically and logs an explicit message naming the setting, rather than passing along a 403 that looks identical to a scope problem. If you see the message below, the key is fine and the toggle is not.

[GroupSpace] HttpService is disabled for this experience.
             Game Settings > Security > Allow HTTP Requests.
             No requests will be sent until this is on.
  • The toggle is per experience, so a test place and a live place need it separately.
  • It does not persist across a fresh place created from a template. Check it after any migration.
  • A 403 with HttpService already on means a scope problem on the server key instead. Check the key’s scopes.

Initialise

One call, once, from a server script at boot. Everything else is optional.

local GroupSpace = require(game.ServerScriptService.GroupSpace)

GroupSpace:Init({
  serverKey = "gsk_live_<prefix>.<secret>",
  heartbeatSeconds = 20,
})
OptionDefaultNotes
serverKeyrequiredThe scoped server key for this game. Read it from a server-only location; never from a replicated one.
heartbeatSeconds20Accepted range is 15 to 60. The server can override this in the heartbeat response, and the SDK follows the response.
The interval is a request, not a setting. Whatever you pass, the SDK uses the config.heartbeatSecondsvalue the API returns. That is how an operator changes cadence for every server at once without anyone redeploying a place.

After Init, the SDK registers the instance, starts the heartbeat loop, tracks joins and leaves on its own, and fetches the door manifest. You do not need to report presence manually.

The five calls

TrackEvent

Queue an event. It does not make a request — it goes into the local queue and rides the next heartbeat. The event id is generated at the moment you call this, which is what makes a retried heartbeat safe.

GroupSpace:TrackEvent("arrest_made", {
  officer = plr.UserId,
  suspect = other.UserId,
  code = "PC-451",
})

The type is a free string. join, leave, team_change, death and spawn are understood by the analytics layer; anything else is stored as a custom type for your workspace. Do not put personal data in the payload — user ids and usernames only.

CanOpenDoor

A synchronous access decision. Served from cache in the normal case, because the full door set for a user is prefetched on join.

local ok = GroupSpace:CanOpenDoor(plr, "pd_armory")
if ok then
  door:Open()
end

Both grants and denials are written to the access log with a reason. The denial log is what tells an operator their policy is wrong, so do not suppress calls you expect to fail.

CommandReceived

An event that fires for each remote command that arrives on a heartbeat response. The SDK acks a command once your handler returns without erroring, and the ack rides the next heartbeat.

GroupSpace.CommandReceived:Connect(function(cmd)
  if cmd.type == "kick" then
    local target = Players:GetPlayerByUserId(tonumber(cmd.payload.externalId))
    if target then target:Kick(cmd.payload.reason) end
  elseif cmd.type == "door_set_state" then
    setDoorState(cmd.payload.doorKey, cmd.payload.state)
  end
end)

Handle the types your game supports and ignore the rest. An unhandled type is still acked, because from the moderator’s side a command that silently sat in a queue is worse than one that is reported as done-and-ignored. If you need to signal genuine failure, error out of the handler and the command is marked failed rather than acked.

GetPosition

The position a user holds in the organization, from the cache the heartbeat maintains. Use it for in-game titles, callsigns, spawn logic and anything else that should follow the org chart rather than a Roblox rank.

local position = GroupSpace:GetPosition(plr)
if position then
  nameplate.Text = position.name
end

It returns nil for a user who has not linked their account or is not a member. Treat nil as “civilian”, not as an error.

Init

Covered above. It is the only call that is mandatory.

What the SDK handles

None of this is magic and all of it is in the API reference. It is here so you do not have to write it.

ConcernBehaviour
BatchingEvents accumulate locally and ship on the heartbeat. At a 20-second interval the whole telemetry stream costs three requests a minute against a 500-per-minute budget.
BackoffExponential with jitter on 5xx and network failure. A 4xx that will not succeed on retry is logged once and not retried.
Local queueBounded. Events are held until a heartbeat returns 200, then dropped from the queue — success, not send, is what clears them.
Drop policyWhen the queue hits its cap the oldest events go first, and the SDK logs how many were dropped so the loss is visible rather than silent.
Command ackingAcks are collected and piggybacked on the next heartbeat. Re-acking is safe and the SDK will re-send an ack it is unsure about.
Door cacheThe full door set for a user is fetched on join and cached for the TTL the API returns. Normal play makes no door requests at all.
Position cacheMaintained from the heartbeat. Invalidated by a refresh_permissions command.
Shutdown flushBindToClose flushes the queue and closes sessions, so the last events of a shutting-down server are not lost.
Setup diagnosticsHttpService disabled, a malformed key and a missing scope each produce a distinct, named error rather than a bare 403.
The queue cap is a real cap. If your game generates events faster than they can be shipped, the SDK drops the oldest rather than growing memory without bound in someone’s live server. If you see drop warnings, either lower your event volume or shorten the heartbeat interval — do not raise the cap and hope.

When we are unreachable

Your game must keep working when GROUPSPACE does not answer. The SDK degrades on purpose rather than throwing.

  • Door decisions fall back to the door’s configured fail mode: closed for restricted doors, open for public ones. That choice is made per door in GROUPSPACE, not in your code.
  • A cached decision is used past its TTL rather than denying, while the SDK is in a failure state. A stale allow is usually better than locking your whole player base out of a public building.
  • Events keep queueing up to the cap and ship when the connection returns.
  • Commands simply do not arrive. There is nothing to handle — the moderator sees the command expire rather than believing it landed.
  • GetPosition serves the last known value and returns nil for users who joined during the outage.
Decide your fail modes before you need them. The default for a new door is fail closed. That is the right default for an armory and the wrong one for a front entrance. Go through the door list once, deliberately, rather than discovering the answer during an outage.

Versions and pinning

The SDK does not auto-update, and that is a decision rather than an omission. A surprise behaviour change in a live server with 200 people in it is worse than being one release behind.

  • Pin an exact version in Wally, or keep the model you inserted. Nothing rewrites it for you.
  • The version you are running is reported as sdkVersion on every heartbeat and shown per instance in the live server view, so an operator can see which servers are stale.
  • Upgrades are a deliberate step: bump the pin, test in your test environment, publish.
  • Register a separate game with environment: test and its own key rather than pointing a test place at live data.
Compatibility warnings are planned, not shipped. The intent is for the heartbeat response to tell you when your version is behind. That field is not in the response today. For now, check the release notes.

Not on Roblox

There is one SDK and it is Roblox-only. Steam, FiveM, Minecraft, a Unity or Source server, or a script on a box somewhere all integrate by calling the REST API directly. That is not a lesser path — the HTTP API is the contract and the SDK is a client for it.

Nothing in the game surface is Roblox-shaped. externalServerId is a Roblox job id, a Steam server id or a UUID your game generates at boot, and GROUPSPACE does not care which.

To integrate without the SDK you need to do five things: sign every request, heartbeat on an interval with the full current roster, generate an event id per event, ack commands from the heartbeat response, and cache door decisions for the TTL you are given.

We are not shipping a second SDK at launch. One well-maintained SDK for the platform most of our customers are on, plus a properly documented API for everyone else, is more honest than two half-maintained libraries.