GROUPSPACE
Developers · reference

REST API

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

ScopeGrants
heartbeatRegister a server and post the heartbeat envelope.
eventsIngest player events carried inside the heartbeat.
doorsDoor check, bulk check and manifest.
commandsReceive remote commands and post acks.
records:readRead a user's records for in-game gating.
chatForward 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.

Request headers HTTP
Authorization: Bearer gsk_live_<prefix>.<secret>
X-GS-Timestamp: 1786310400                    unix seconds
X-GS-Signature: hex(hmac_sha256(secret, timestamp .. rawBody))
Content-Type:   application/json
  • 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

StatusMeaningWhat to do
200Accepted and processed.Continue.
400Malformed body, or a field failed validation.Fix the payload. Do not retry unchanged.
401Missing, malformed, expired or revoked key, or a bad signature.Do not retry. Surface a setup error to the operator.
403The 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.
404Unknown game, door key or server.Do not retry. The reference is wrong.
409Timestamp outside the 300-second window, or a replayed signature.Re-sign with a fresh timestamp and retry once.
413Envelope too large.Shrink the batch. Split events across two heartbeats.
429Rate limited.Back off. Respect Retry-After if present.
5xxOur 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 body HTTP
{
  "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-generated eventId, 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.

LimitValueConsequence
HttpService outbound500 requests per minute per game serverOne 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 Cloud2,500 per minute per server, counted separatelyOnly relevant if you call Roblox APIs directly. Prefer routing through GROUPSPACE.
HttpService protocolHTTPS only; no ports below 1024 except 80 and 443No impact on this API.
MessagingService receive40 + 80 × server count, per topic per minuteOne topic per game. Never per server, never per command type.
MessagingService payload1 KiBThe nudge carries no command data. Commands are fetched, never inlined.
Open Cloud user restrictions10 requests per second, but 2 per minute per userBans 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

FieldTypeNotes
externalServerIdstringWhatever your platform calls a running instance. A Roblox jobId, a Steam server id, or a UUID you generate at boot. Required.
externalScopeIdstring?The place, map or world this instance is running. Optional.
playerCountnumberCurrent occupancy.
maxPlayersnumberConfigured capacity.
sdkVersionstringYour integration's version. Reported in the live server view and used for compatibility warnings.
uptimeSecondsnumberSeconds since this instance booted.
regionstring?Optional free-form region label.
players[]arrayFull current roster, not a delta. Each entry has externalId, username, optional team and joinedAt.
events[]arrayBatched 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.
POST /api/v1/game/heartbeat HTTP
{
  "externalServerId": "job-8f2a41c0",
  "externalScopeId": "606849621",
  "playerCount": 23,
  "maxPlayers": 50,
  "sdkVersion": "1.0.0",
  "uptimeSeconds": 4210,
  "region": "us-east",
  "players": [
    {
      "externalId": "1234567",
      "username": "ava",
      "team": "Police",
      "joinedAt": "2026-08-10T14:02:11Z"
    }
  ],
  "events": [
    {
      "eventId": "5f9b1c2e-7a44-4f0d-9b2a-1c3d4e5f6a7b",
      "type": "arrest_made",
      "externalId": "1234567",
      "occurredAt": "2026-08-10T14:07:45Z",
      "payload": { "suspect": "7654321", "code": "PC-451" }
    }
  ],
  "acks": ["cmd_abc123", "cmd_def456"]
}

Response

200 OK HTTP
{
  "commands": [
    {
      "id": "cmd_ghi789",
      "type": "kick",
      "payload": { "externalId": "7654321", "reason": "Code 4.2 — exploiting" }
    }
  ],
  "config": { "heartbeatSeconds": 20, "doorCacheSeconds": 30 },
  "serverTime": "2026-08-10T14:08:00Z"
}

Event types

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

TypeWhat the game should do
kickRemove the user from this instance with the supplied reason.
muteSuppress the user's chat for the given duration.
unmuteLift a mute.
freezeImmobilise the user.
teleportMove the user to the position or place in the payload.
messageShow a message to one user or to the whole server.
door_set_stateForce a door locked or unlocked, or return it to auto. Overrides policy until returned to auto.
refresh_permissionsInvalidate cached door and position data and refetch.
shutdownClose the instance. Flush your queue first.
customWorkspace-defined. Your integration decides what the payload means.

Lifecycle

A command moves through pending dispatchedacked. 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.
doors

Single check

POST /api/v1/game/doors/check HTTP
{ "doorKey": "pd_armory", "externalUserId": "1234567" }

{ "allowed": true, "reason": "position:sergeant", "ttl": 30 }

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.

Bulk on join

POST /api/v1/game/doors/bulk HTTP
{ "externalUserId": "1234567" }

{
  "doors": { "pd_armory": true, "pd_evidence": false, "cityhall_council": false },
  "ttl": 30
}

Re-fetch on cache expiry, on a refresh_permissions command, or when the user’s position changes. Everything else is served from cache.

Manifest

GET /api/v1/game/doors/manifest HTTP
{
  "doors": [
    {
      "key": "pd_armory",
      "name": "PD armory",
      "locationTag": "mission_row",
      "state": "auto",
      "defaultBehavior": "deny",
      "failMode": "closed"
    }
  ]
}

How a decision is reached

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

POST /api/v1/game/bans/check HTTP
{ "externalUserId": "1234567" }

{
  "banned": true,
  "reason": "Code 4.2 — exploiting",
  "scope": "workspace",
  "expiresAt": "2026-09-01T00:00:00Z"
}
  • 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.