ArrStudio API

How ArrStudio kits running in a Roblox place talk to this website. A kit reads its license key from its own Config module and calls one endpoint on server start. Everything else — accounts, licenses, kits — is managed on the website.

Base URL
https://arrstudioweb.vercel.app
Format
JSON over HTTPS
Auth (kits)
License key in body

Connect from Roblox

  1. 1. Enable HTTP requests

    In Roblox Studio: Game Settings → Security → Allow HTTP Requests. The kit can't reach the API without it.

  2. 2. Add the license module to the kit

    Copy roblox/ArrLicense.lua from this repository into the kit as a ModuleScript (e.g. ClubKit/Core/ArrLicense) and set its URL:

    ArrLicense.lua
    ArrLicense.API_URL = "https://arrstudioweb.vercel.app"
  3. 3. The buyer pastes their key into Config

    ClubKit/Config
    -- ClubKit/Config (ModuleScript) — the only file buyers edit
    return {
    	LicenseKey = "ARR-XXXX-XXXX-XXXX",
    
    	-- point the kit at what your place already uses
    	DataStore = "PlayerData_v3",
    	GroupId = 3401192,
    	Gamepasses = { VIP = 71829301, DJ = 71829455 },
    	Leaderstats = { Currency = "Coins" },
    }
  4. 4. Verify before starting the kit

    Run this from a server Script. Never require the module from a LocalScript — the key must not reach clients.

    ClubKit/Bootstrap (server)
    -- ClubKit/Bootstrap (Script, server)
    local Kit = script.Parent
    local Config = require(Kit.Config)
    local ArrLicense = require(Kit.Core.ArrLicense)
    
    local ok, result = ArrLicense.verify({
    	key = Config.LicenseKey,
    	kit = "clubkit",       -- kit id from /admin → Kits
    	version = "1.2.0",     -- the version of this kit build
    	onRevoked = function(code, message)
    		warn("[ClubKit] license stopped:", code, message)
    		Kit.Core:SetAttribute("Licensed", false)
    	end,
    })
    
    if not ok then
    	warn("[ClubKit] not starting:", result.message)
    	return
    end
    
    require(Kit.Core.Main).start(Config)
  5. 5. Test without Roblox

    placeId: "0" behaves like Studio: it validates the key but never binds it.

    Terminal
    curl -X POST https://arrstudioweb.vercel.app/api/licenses/verify \
      -H "Content-Type: application/json" \
      -d '{"key":"ARR-7F2K-M4QX-Q9RD","kit":"clubkit","placeId":"0"}'

Verify flow

  1. 1

    Server starts

    Bootstrap calls ArrLicense.verify with Config.LicenseKey, the kit id and version.

  2. 2

    POST /api/licenses/verify

    The API checks the key format and rate limits (30/min per key, 120/min per IP).

  3. 3

    Transaction in Firestore

    Key exists? Active? Right kit? If the key has no place yet, it binds to this place.

  4. 4

    Response

    valid: true + latestVersion. The module warns in the output if a newer version is available.

  5. 5

    Every 30 minutes

    The module re-checks in the background and calls onRevoked only when the key is explicitly rejected.

Handling results in Lua

result.codeWhat the kit should do
(ok)Start the kit
HTTP_DISABLEDAsk the owner to enable Allow HTTP Requests
INVALID_KEY / INVALID_KEY_FORMATAsk the owner to check Config.LicenseKey
PLACE_MISMATCHAsk the owner to move the license in the dashboard
WRONG_KITThe key belongs to another kit
LICENSE_REVOKEDDo not start the kit
NETWORK / RATE_LIMITED / HTTP_5xxAlready retried 3× (2s/4s/8s) — decide whether to run in a limited mode

Security

  • Call the API only from the server. Keep the key out of ReplicatedStorage and LocalScripts.
  • A key binds to one place, so a leaked key doesn't work in someone else's game.
  • Binding runs in a Firestore transaction — two servers starting at once can't bind one key to two places.
  • The browser only reads data allowed by firestore.rules; all writes go through this API.

Licensing (Roblox)

The only endpoint a kit calls. No login — the license key is the credential.

POST/api/licenses/verifyRoblox server

Verify a license on server start

Called by ArrLicense.lua from a Roblox game server. The first call from a published place binds the key to that place. Studio (placeId 0) is allowed for testing and never binds.

FieldTypeDescription
key*stringConfig.LicenseKey
kit*stringKit id from /admin → Kits, e.g. summitkit
placeId*stringgame.PlaceId — "0" in Studio
jobIdstringgame.JobId, for logs
versionstringInstalled kit version, e.g. 1.2.0
Request body
{
  "key": "ARR-7F2K-M4QX-Q9RD",
  "kit": "clubkit",
  "placeId": "13284790215",
  "jobId": "a1b2c3d4-…",
  "version": "1.2.0"
}
200 Response
{
  "valid": true,
  "kit": "clubkit",
  "placeId": "13284790215",
  "studio": false,
  "newlyBound": false,
  "latestVersion": "1.2.0",
  "checkedAt": "2026-09-29T10:01:00.000Z"
}

Errors:INVALID_KEYINVALID_KEY_FORMATLICENSE_REVOKEDWRONG_KITPLACE_MISMATCHRATE_LIMITED

Authentication

The website uses an httpOnly __session cookie. Other clients can send Authorization: Bearer <Firebase ID token>.

POST/api/auth/sessionPublic

Exchange a Firebase ID token for a session

Creates the profile on first sign-in. The ID token must come from a sign-in less than 5 minutes old.

Request body
{ "idToken": "eyJhbGciOi…", "displayName": "kyzo_dev" }
200 Response
{ "user": { "uid": "abc123", "email": "you@studio.com", "role": "user", … } }

Errors:INVALID_TOKENSTALE_LOGINRATE_LIMITED

DELETE/api/auth/sessionSigned in

Sign out

200 Response
{ "ok": true }
GET/api/auth/discord?next=/dashboardPublic

Sign in with Discord (redirect)

Redirects to Discord, then back to /api/auth/discord/callback, which sets the session and redirects to next.

GET/api/auth/firebase-tokenSigned in

Custom token for realtime reads

Lets the browser sign in to Firebase so onSnapshot listeners pass firestore.rules. Used automatically by the dashboard and admin.

200 Response
{ "token": "eyJhbGciOi…" }

Account & licenses

GET/api/accountSigned in

Current user

200 Response
{ "user": { "uid": "abc123", "displayName": "kyzo_dev", "robloxUsername": null, "role": "user", … } }
PATCH/api/accountSigned in

Update profile

Request body
{ "displayName": "Kyzo", "robloxUsername": "kyzo_dev" }
GET/api/licensesSigned in

My licenses

200 Response
{
  "licenses": [
    {
      "key": "ARR-7F2K-M4QX-Q9RD",
      "kit": "clubkit",
      "kitName": "ClubKit Pro",
      "places": ["13284790215", "98765432101"],
      "maxPlaces": 3,
      "status": "active",
      "lastKitVersion": "1.2.0",
      "verifyCount": 184,
      "releaseAvailableAt": null
    }
  ]
}
DELETE/api/licenses/:key/places/:placeIdSigned in

Remove a place from a license

One key works in several places (maxPlaces). New places link themselves on their first server start while a slot is free; remove a place you no longer use to free its slot — once every 30 days.

Errors:LICENSE_NOT_FOUNDLICENSE_REVOKEDPLACE_NOT_BOUNDRELEASE_COOLDOWN

Admin

Requires a user with role "admin". Every change is pushed to the site and dashboards in realtime.

GET/api/admin/kitsAdmin

All kits, including drafts

POST/api/admin/kitsAdmin

Create a kit

Add ?seed=1 to import the built-in ClubKit Pro and Summit Kit instead. Prices are whole Rupiah.

Request body
{
  "id": "summitkit",
  "name": "Summit Kit",
  "tag": "Live events",
  "tagline": "Run launches like a festival.",
  "description": "Stage control, lighting presets, …",
  "version": "1.0.0",
  "price": 299000,
  "status": "active",
  "icon": "mountain",
  "features": ["Stage & lighting presets"],
  "integrations": ["Group ranks", "Gamepasses"],
  "attributes": { "systems": 85, "integration": 90, "setup": 92 },
  "configPath": "SummitKit/Config",
  "rating": 4.8,
  "order": 2
}

Errors:VALIDATION_FAILEDKIT_EXISTS

PATCH/api/admin/kits/:idAdmin

Update a kit (any subset of fields)

Request body
{ "version": "1.3.0", "price": 349000 }
DELETE/api/admin/kits/:idAdmin

Delete a kit without licenses

Errors:KIT_HAS_LICENSES

POST/api/admin/licensesAdmin

Issue licenses to a user

The buyer must have an account. Keys appear in their dashboard instantly.

Request body
{ "kit": "summitkit", "ownerEmail": "buyer@studio.com", "count": 1, "note": "Order #1042" }
200 Response
{ "keys": ["ARR-H7KQ-2MXP-9TVA"] }

Errors:USER_NOT_FOUNDKIT_NOT_FOUND

GET/api/admin/licenses?ownerUid=&kit=Admin

List licenses (max 100)

PATCH/api/admin/licenses/:keyAdmin

Revoke, restore or change place slots

Request body
{ "status": "revoked" }  // or { "maxPlaces": 5 }
DELETE/api/admin/licenses/:keyAdmin

Delete a revoked license

Permanent. Only revoked licenses can be deleted — revoke first.

Errors:LICENSE_NOT_FOUNDLICENSE_ACTIVE

PATCH/api/admin/settings/pricingAdmin

Studio bundle pricing

Request body
{ "bundleEnabled": true, "bundlePrice": 599000, "bundlePlaces": 3 }

Error codes

Every error has the same shape. Match on code; show message to people.

Error response
{
  "error": {
    "code": "PLACE_MISMATCH",
    "message": "This license is bound to a different place.",
    "boundPlaceId": "13284790215"
  }
}
HTTPcodeMeaning
400VALIDATION_FAILEDBody is invalid — see issues[]
400INVALID_KEY_FORMATKey is not ARR-XXXX-XXXX-XXXX
400MISSING_PLACE_IDplaceId missing (call from a game server)
401UNAUTHENTICATEDNot signed in / session expired
403FORBIDDENAdmin role required
403LICENSE_REVOKEDLicense was revoked
403WRONG_KITKey belongs to another kit
403PLACE_MISMATCHKey is bound to another place
404INVALID_KEYKey does not exist
404KIT_NOT_FOUNDKit id does not exist
409KIT_HAS_LICENSESKit has licenses — set it to Draft instead
429RATE_LIMITEDToo many requests — see retryAfter (s)
429REBIND_COOLDOWNPlace can change once per 30 days — see retryAt
500INTERNALServer error