The game API. Five endpoints, one signing scheme, and a transport shaped by the fact that a Roblox server may make 500 outbound requests per minute.
Last updated 10 August 2026
Overview
Everything under /api/v1/game/ is the game integration surface. It is platform-neutral by construction: no field in it is Roblox-shaped, and the Luau SDK is a client for this API rather than a privileged path into it.
Base URL is https://api.groupspace.app/api/v1. All requests are HTTPS, all bodies are JSON, all timestamps are RFC 3339 in UTC unless the field name says otherwise.
POST
/api/v1/game/heartbeat
Register or refresh a live server and post presence, events and command acks in one envelope.
heartbeat · events · commands
POST
/api/v1/game/doors/check
Single synchronous access decision for one door and one user.
doors
POST
/api/v1/game/doors/bulk
Every door decision for one user. This is the call you make on join.
doors
GET
/api/v1/game/doors/manifest
Door definitions and current states, fetched once on server start.
doors
POST
/api/v1/game/bans/check
Blocklist lookup for platforms with no persistent-ban API. Called on connect.
heartbeat
This is the whole surface. There is no command-fetch endpoint, no event endpoint and no session endpoint. Commands come down on the heartbeat response, events go up in the heartbeat body, and sessions are derived from the presence list. Anything you see documented elsewhere that is not on this page does not exist yet.
Workspace REST API — planned, not shipped. A general-purpose workspace-scoped REST API (organizations, records, operations, payroll) is planned and is what will back the data-export promise. It is not built. Do not design against it yet.
Authentication and signing
Requests authenticate with a server key that belongs to a game, not to a person. Keys are created in the workspace, shown exactly once, and scoped.
Key format
A key looks like gsk_live_<prefix>.<secret>. The prefix is a public, non-secret identifier that appears in logs, the audit trail and the key list, so you can tell which integration is misbehaving without ever seeing a secret. The secret is hashed with argon2id and is not recoverable — a lost key is revoked and replaced.
Scopes
Scope
Grants
heartbeat
Register a server and post the heartbeat envelope.
events
Ingest player events carried inside the heartbeat.
doors
Door check, bulk check and manifest.
commands
Receive remote commands and post acks.
records:read
Read a user's records for in-game gating.
chat
Forward platform-filtered chat. Only usable where the workspace has opted in to chat history.
Signing
Every request carries an HMAC-SHA256 signature over the timestamp concatenated with the raw request body, computed with the key secret. Roblox HttpServicecan send custom headers, so this works from an in-experience script.
Sign the raw body bytes, not a re-serialised copy. Key ordering and whitespace matter.
The acceptance window is 300 seconds either side of server time. Outside it the request is rejected regardless of whether the signature is valid.
A replay cache keyed on (keyId, signature) rejects a repeated signature inside that window. Retries must be identical requests, which is why event idempotency is handled separately.
Unsigned requests are rejected outright. There is no unsigned mode and no development bypass.
Clock skew is the usual culprit. Every response carries serverTime. If you see signature failures that come and go, compare it to your own clock before suspecting the HMAC.
Errors and status codes
Status
Meaning
What to do
200
Accepted and processed.
Continue.
400
Malformed body, or a field failed validation.
Fix the payload. Do not retry unchanged.
401
Missing, malformed, expired or revoked key, or a bad signature.
Do not retry. Surface a setup error to the operator.
403
The key is valid but lacks the scope for this endpoint. On Roblox, also what a disabled HttpService looks like from inside the engine.
Check scopes first, then HttpEnabled.
404
Unknown game, door key or server.
Do not retry. The reference is wrong.
409
Timestamp outside the 300-second window, or a replayed signature.
Re-sign with a fresh timestamp and retry once.
413
Envelope too large.
Shrink the batch. Split events across two heartbeats.
429
Rate limited.
Back off. Respect Retry-After if present.
5xx
Our fault.
Retry with exponential backoff and jitter. Queue locally in the meantime.
Error bodies are JSON and carry a stable machine-readable code alongside a human message.
Error bodyHTTP
{
"error": {
"code": "scope_missing",
"message": "This server key does not carry the 'doors' scope.",
"requestId": "req_7f13c2a9"
}
}
The error code vocabulary is not frozen. The envelope above is stable, but the set of code values will grow before v1 is declared general availability. Branch on the HTTP status; log the code rather than switching on it.
Idempotency
Heartbeats get retried. A game server times out, resends, and the original request turns out to have landed. Without protection that retry double-counts playtime — which corrupts payroll and quotas, the two things people care most about being correct, and it does so silently.
So every event carries a client-generatedeventId, and ingest is upsert-on-conflict. Send the same event twice and the second one is a no-op.
Generate the id when the event happens, not when the heartbeat is assembled. An id created at send time is a new id on every retry and defeats the whole mechanism.
Ids must be unique within the game. A UUID, or a hash of server id, user id, type and timestamp — anything deterministic for the same logical event.
Do not reuse an id for a different event. A collision drops the second event without an error, because from the server side it looks like a retry.
Presence is reconciled by state, not by delta. The players array is the full current roster for that server, so a dropped heartbeat self-corrects on the next one.
Acks are idempotent too. Re-acking a command that is already acked succeeds.
Keep the event in your local queue until the heartbeat returns 200. Idempotent ingest means the safe failure mode is sending twice, never sending zero times. Drop from the queue on success, not on send.
Rate limits
The binding constraints are the platform’s, not ours. These figures are Roblox’s and they are what the transport is designed around.
Limit
Value
Consequence
HttpService outbound
500 requests per minute per game server
One request per event is impossible. Batch into the heartbeat. At a 20-second interval the whole telemetry stream costs 3 requests a minute.
HttpService to Open Cloud
2,500 per minute per server, counted separately
Only relevant if you call Roblox APIs directly. Prefer routing through GROUPSPACE.
HttpService protocol
HTTPS only; no ports below 1024 except 80 and 443
No impact on this API.
MessagingService receive
40 + 80 × server count, per topic per minute
One topic per game. Never per server, never per command type.
MessagingService payload
1 KiB
The nudge carries no command data. Commands are fetched, never inlined.
Open Cloud user restrictions
10 requests per second, but 2 per minute per user
Bans are queued and debounced. Rapid toggling of one user will not go through.
Limits on our side
Server-side limits are derived from your configured heartbeat interval rather than published as a fixed per-key number. In practice: heartbeat at the interval the response tells you to, and door checks are governed by the cache TTL. A server that respects both will not be throttled.
A 429 includes Retry-After in seconds. Honour it rather than backing off on your own schedule.
Persistent throttling on one game shows up in the workspace as an ingest health warning. It is visible to the operator, not just to you.
Published per-key quotas are not final. Concrete numeric limits for GROUPSPACE-side rate limiting are still being tuned against real ingest volume. Build backoff on the status code, not on a number you hardcode.
Heartbeat
POST
/api/v1/game/heartbeat
The spine. One request per server per interval carries presence, events and acks up; commands and config come back down.
heartbeat
Default interval is 20 seconds and it is configurable between 15 and 60. The response tells you the interval to use — follow it rather than your local setting, because that is how an operator changes the cadence without redeploying your game.
Request
Field
Type
Notes
externalServerId
string
Whatever your platform calls a running instance. A Roblox jobId, a Steam server id, or a UUID you generate at boot. Required.
externalScopeId
string?
The place, map or world this instance is running. Optional.
playerCount
number
Current occupancy.
maxPlayers
number
Configured capacity.
sdkVersion
string
Your integration's version. Reported in the live server view and used for compatibility warnings.
uptimeSeconds
number
Seconds since this instance booted.
region
string?
Optional free-form region label.
players[]
array
Full current roster, not a delta. Each entry has externalId, username, optional team and joinedAt.
events[]
array
Batched since the last heartbeat. Each entry has eventId, type, optional externalId, occurredAt and optional payload.
acks[]
string[]
Ids of commands this server has finished executing.
The event type is a plain string and is deliberately generic. join, leave, team_change, death and spawn are understood by the analytics layer; anything else is a workspace-defined custom type and is stored and queryable as-is. Put your own structure in payload.
Presence drives more than the live view. Sessions derived from this roster feed analytics, auto clock-in, payout accrual and quota progress. An orphaned session is closed at the last heartbeat it appeared in, not at the time we notice it is gone.
Commands and acks
Commands ride the heartbeat response. There is no polling loop, no websocket and no endpoint to fetch them from. Worst-case delivery latency equals your heartbeat interval, which is acceptable for moderation and door changes and is the price of staying inside the request budget.
Types
Type
What the game should do
kick
Remove the user from this instance with the supplied reason.
mute
Suppress the user's chat for the given duration.
unmute
Lift a mute.
freeze
Immobilise the user.
teleport
Move the user to the position or place in the payload.
message
Show a message to one user or to the whole server.
door_set_state
Force a door locked or unlocked, or return it to auto. Overrides policy until returned to auto.
refresh_permissions
Invalidate cached door and position data and refetch.
shutdown
Close the instance. Flush your queue first.
custom
Workspace-defined. Your integration decides what the payload means.
Lifecycle
A command moves through pending → dispatched → acked. If it is never acked before its expiry it becomes expired; if your integration reports a failure it becomes failed. Both are surfaced to the moderator who issued it, so a command that quietly did nothing does not look like one that worked.
Ack after the effect has actually happened, not on receipt. An ack is a claim that the thing is done.
Acks are batched into the next heartbeat, so an ack costs no extra request.
Re-acking is safe. If you are unsure whether an ack landed, send it again.
Do not execute a command twice. Track ids you have already handled for at least one heartbeat interval.
Urgent delivery
Where the platform supports it, GROUPSPACE can send an out-of-band nudge that tells a live server to heartbeat early. On Roblox this is a MessagingService message on a single per-game topic, capped at 1 KiB, carrying no command data — only “check in now”. Platforms without push capability simply wait for the next scheduled heartbeat. That is latency, not loss.
Never assume a nudge arrived. MessagingService delivery is not guaranteed and its receive budget is shared. The heartbeat is the delivery mechanism; the nudge is an optimisation on top of it.
Doors
Door checks are the one exception to batching, because an access decision has to be immediate. They are on a separate low-latency path and they are aggressively cacheable. The intended shape is: fetch the manifest on boot, bulk-check each user on join, cache for the TTL, and make no further requests during normal play.
POST
/api/v1/game/doors/check
One door, one user. Use when a user reaches a door whose decision is not in cache.
doors
POST
/api/v1/game/doors/bulk
Every door for one user. The common path — call it once on join.
doors
GET
/api/v1/game/doors/manifest
Door definitions and current forced states. Fetch on server start and after refresh_permissions.
reason is a short machine-ish string that is also fit to show a moderator. It is written to the access log for both grants and denials — the denial log is what tells an operator their policy is wrong.
Policies target a subject: a set of positions, a set of units (optionally including descendants), a set of organizations, named users, or everyone.
Policies may carry conditions: clocked in, during an operation of a given type, no active record of a given type, inside a time window, or a minimum tenure in days.
Evaluation order is deny wins, then highest priority, then the door’s default behaviour.
A door forced to locked or unlocked overrides policy evaluation entirely until it is returned to auto. That is the lockdown path.
failMode is what your integration should do when GROUPSPACE is unreachable: closed for restricted doors, open for public ones.
Doors generalise. The same model covers weapon lockers, vehicle spawners and zone access. If you are gating anything on who someone is, use a door key for it rather than inventing a parallel check.
Bans fallback
POST
/api/v1/game/bans/check
Blocklist lookup for platforms that cannot enforce a ban outside the running session.
heartbeat
Some platforms can enforce a ban themselves. Roblox can, through Open Cloud user restrictions, and GROUPSPACE drives that directly — queued and debounced, because the limit is two writes per minute for a single user. Steam, FiveM and custom servers cannot: the game owns its own ban list.
For those, GROUPSPACE stays the source of truth and enforcement is local. Call /bans/check when a user connects and disconnect them yourself if the answer says so.
scope is server, game or workspace — a ban can cover this instance, one registered game, or every game in the workspace.
A null expiresAt means permanent.
This is a connect-time call, so it sits outside the batching rules. Do not poll it.
Fail open on a network error unless your game is one where a missed ban is worse than a wrongly-refused connection. That is your call, not ours.
The response body here is the least settled part of this page. The endpoint and its purpose come straight from the platform-agnosticism contract, but the exact field names above are still being finalised. Read defensively: check banned, and treat everything else as informational.
There is no cross-workspace blocklist. Bans are scoped to the workspace that issued them. A shared player reputation graph across customers is prohibited by Roblox’s third-party app policy, and we are not building one.
Versioning and deprecation
The version is in the path. /api/v1/ is the current major version and it is the only one. Additive changes — a new optional request field, a new response field, a new event type, a new command type — happen inside v1 and are not breaking. Your parser must ignore fields it does not recognise.
What counts as breaking
Removing or renaming a field, or narrowing its type.
Changing the meaning of an existing value.
Adding a required request field.
Removing an endpoint.
Any of those means a new major version path, announced in advance, with the old one kept running through a documented deprecation period rather than switched off on a date we picked.
SDK versions
The Luau SDK does not auto-update. Version pinning is deliberate — surprise behaviour changes in a live server are worse than being a release behind. Report your version in sdkVersion on every heartbeat; it is shown in the live server view so an operator can see which instances are stale.
Compatibility warnings are planned, not shipped. The intent is for the heartbeat response to carry a compatibility warning when your reported sdkVersion is behind. That field does not exist in the response yet — today the response is exactly commands, config and serverTime. Do not write code that expects it.