Roblox SDK
The Junjo.io SDK for Roblox is the Luau client for Roblox game servers. It wraps HttpService for outbound REST calls, mirrors the TypeScript SDK’s JunjoConfig shape, exposes per-namespace methods that match the TS SDK surface, and surfaces server errors as a typed JunjoError table.
Install the prebuilt Junjo.rbxm model attached to each GitHub release tagged roblox-vX.Y.Z (starting with roblox-v0.1.0): download it and insert it into Studio, or sync it with Rojo. You can also install from source: the module root is packages/sdk-roblox/src, and default.project.json builds the tree as a single ModuleScript named Junjo (point your own Rojo project at the source, or build Junjo.rbxm yourself with rojo build). The package is not on npm.
Either way, mount the module in ServerStorage (or ServerScriptService) and require it from server Scripts only. Never place the module or your API key in ReplicatedStorage or any other container that replicates to clients: the per-game jk_ key grants full control of your game’s Junjo data, and a client that can read it can act as your server.
Surface area
| Surface | Notes |
|---|---|
Junjo.new(config) factory | Constructs a Junjo client. |
HTTP wrapper (junjo.http:get / :post / :patch / :put / :delete) | Auto-encodes JSON bodies, parses JSON responses, throws JunjoError on non-2xx. |
Junjo.Null sentinel for explicit JSON nulls in PATCH bodies | See “Sending JSON null in PATCH bodies” below. |
HttpService:GetSecret(...) lookup with apiKey fallback | Pass apiKeySecret to read from the Roblox secret store, with apiKey as a literal fallback. |
junjo.groups:create / get / list / update / delete / restore | Group CRUD + soft-delete + restore. |
junjo.groups membership: inviteByUserId / inviteByCode / inviteByLink / bulkInvite / acceptInvitation / declineInvitation / join / leave / kick | Full membership lifecycle, including open join for public groups (with optional passcode). |
junjo.groups moderation: ban / unban / banHistory / banHistoryAll | Per-group bans (distinct from kick: a banned user cannot rejoin) plus the group’s ban-event timeline. |
junjo.groups relationships: setRelationship / clearRelationship / getRelationship / listRelationships | Directed and mutual relationships between groups. |
junjo.groups sub-groups: setParent / listChildren | Sub-group / alliance hierarchy. |
junjo.members:get / getById / list / listAll / listForUser / setMetadata / setNotes / assignRole / removeRole / overridePermission / clearPermissionOverride / listPermissionOverrides | Member surface, parity with the TS SDK. list takes an optional status filter (e.g. { "active" }, { "banned" }). |
junjo.roles:create / get / list / update / delete / grantPermission / revokePermission | Role CRUD + permission grants. |
junjo.invitations:list / get / revoke | Invitation listing and revocation. |
junjo.audit:list | Paginated audit feed for a group. |
junjo.webhooks.endpoints:create / list / listAll / update / delete | Webhook endpoint CRUD (no receiver-side helpers; see below). list is cursor-paginated and returns the { items, nextCursor } envelope. |
junjo.bans:add / remove / get / list / listAll / history / historyAll | Game-wide bans (every group in the game), with optional expiry and an append-only per-user history. |
junjo.friends:list / listAll / remove / getRelationship / suggestions plus the requests, blocks, tags, and visibility sub-namespaces | Per-user social graph: friend requests, blocks, friend tags, list visibility, mutual-friend suggestions, and a single-pair relationship probe. |
junjo:can(userId, groupId, permission) and junjo:check(...) | Permission check helpers. |
junjo:keyInfo() | Which game the configured API key belongs to (GET /v1/whoami); useful as a setup / health probe. |
Junjo.pageAll(fetchPage) and the namespace listAll / banHistoryAll / historyAll variants | Generic-for iterators over cursor-paginated lists; pages are fetched lazily. |
Junjo.RobloxUserIdAdapter(opts?) | Built-in user-id adapter for Roblox Player instances. |
groups.subscribe (SSE) and webhooks:verify / :middleware (receiver-side) | Not supported. Roblox HttpService cannot hold streaming connections, and a Roblox game server cannot accept inbound HTTP, so it is never a webhook receiver. For live updates, register webhook endpoints that deliver to your own backend and relay into the game from there. |
Construct a client
From a server Script, with the module in ServerStorage:
local Junjo = require(game:GetService("ServerStorage").Junjo)
local junjo = Junjo.new({
apiKey = game:GetService("HttpService"):GetSecret("JUNJO_API_KEY"),
})Or let the SDK do the secret lookup for you. apiKey then acts as a fallback for local Studio testing where the secret is not registered; if you use one, it must be a real per-game key of jk_<prefix>.<secret> shape (the server rejects anything else), and it must be a separate low-privilege key minted for a dev game, never your production key. A literal key in source eventually leaks (version control, place files, screen shares), so the fallback’s blast radius has to be a throwaway dev game. The SDK warns once when the fallback is actually used, naming the secret that failed to resolve:
local junjo = Junjo.new({
apiKeySecret = "JUNJO_API_KEY",
-- Dev fallback for Studio sessions without a registered secret.
-- Use a low-privilege key for a dev game, never production:
-- apiKey = "jk_devprefix.devsecret",
})| Option | Required | Notes |
|---|---|---|
apiKey | yes (or apiKeySecret) | A prefix.secret string OR a Secret userdata returned by HttpService:GetSecret. A string is concatenated into the Authorization: Bearer ... header; a Secret is composed via Secret:AddPrefix("Bearer "), the documented API for building header values around a Secret, and Roblox interpolates the actual secret value at request time. |
apiKeySecret | no | A Roblox secret-store name. The SDK calls HttpService:GetSecret(apiKeySecret). If the call errors (the secret is not registered, HttpService is disabled, etc.) the SDK falls back to apiKey when present, otherwise raises invalid_config. |
baseUrl | no | Override the API root. Trailing slashes are trimmed. Defaults to https://api.junjo.io. |
inviteBaseUrl | no | Base URL used by groups:inviteByLink. Defaults to baseUrl. Trailing slashes are trimmed. |
httpService | no | Override the HttpService reference. Used to inject a fake during unit testing of higher-level wrappers; production reads game:GetService("HttpService"). |
retries | no | Opt-in transport retries: { maxAttempts, backoffSeconds }. maxAttempts is the total attempt cap including the first request (default 1, meaning no retry); backoffSeconds is the base for exponential backoff with jitter (default 1). See “HTTP behavior” below for the policy. |
The constructor validates the key shape the same way the TypeScript SDK does: a cross-game admin token (jadm_*) raises invalid_config immediately, and a key that does not match the jk_<prefix>.<secret> shape logs a one-time warning (the server remains the source of truth and rejects genuinely bad keys with 401).
Junjo.VERSION is a semver string (kept in sync with each release) you can log from your game for support and bug reports:
print("Junjo SDK " .. Junjo.VERSION)HTTP behavior
Requests go through HttpService:RequestAsync, which has a fixed timeout of roughly 30 seconds and no configurable timeout option; a hung request yields the calling thread until Roblox gives up, so call Junjo from a dedicated thread (task.spawn) when latency would stall game logic.
Roblox also enforces an HttpService budget of 500 requests per minute per server, shared by everything the server does over HTTP. Because of that budget, retries are opt-in and off by default:
local junjo = Junjo.new({
apiKey = game:GetService("HttpService"):GetSecret("JUNJO_API_KEY"),
retries = { maxAttempts = 3, backoffSeconds = 1 },
})The policy is deliberately conservative. 429 responses retry for any method (the server rejected the request before doing any work) and honor the Retry-After response header when it exceeds the computed backoff. 5xx responses and transport failures retry for GET requests only: a write that failed mid-flight may already have been applied server-side, so POST / PATCH / PUT / DELETE never retry on those. Every other status (including non-429 4xx) never retries. Backoff is exponential with jitter starting from backoffSeconds, and the final failed attempt raises exactly the JunjoError the non-retry path raises.
Errors
Every method that talks to the server raises a JunjoError-shaped Lua error table when the response is non-2xx. Catch it with pcall and branch on code:
local Junjo = require(game:GetService("ServerStorage").Junjo)
local ok, result = pcall(function()
return junjo.groups:create({
kind = "guild",
name = "",
})
end)
if not ok then
if Junjo.JunjoError.is(result) then
print(result.code) -- "bad_request"
print(result.status) -- 400
print(result.message) -- "name: too short"
else
-- Lua-level error (typo in your code, etc.)
error(result)
end
endBranch on error.code, not on error.message. Codes are stable; messages are not.
The JunjoError table is a Lua object with name, message, code, status, requestId, and retryAfterSeconds fields plus a __tostring metamethod, so print(err) and tostring(err) produce a readable summary.
requestIdis the server’sx-request-idfor the failing response (nilwhen the header is absent or no response was received). It is worth quoting in bug reports so the request can be traced in the server logs.retryAfterSecondsis the integer seconds from aRetry-Afterresponse header, set primarily on429responses (nilotherwise). Honor it in your own backoff before retrying. The opt-in retry policy already honors it internally, so this matters mainly when you drive retries yourself.
User ids on Roblox
The TypeScript SDK treats user ids as opaque strings. Roblox’s Player.UserId is a number; convert to a string with tostring(...) at the call site so the cross-runtime user-id contract holds (the server stores them as strings; mixing numeric and string ids creates duplicate ExternalIdentity rows).
local userId = tostring(player.UserId)
local allowed = junjo:can(userId, groupId, "invite_member")RobloxUserIdAdapter
Junjo.RobloxUserIdAdapter(opts?) is the built-in adapter that resolves a Roblox Player (or a numeric UserId) to the opaque-string user id Junjo expects. The tostring(player.UserId) rule above is exactly what the adapter encapsulates; using it everywhere a route call needs a user id keeps the conversion in one place and removes the chance of a forgotten tostring creating a numeric / string duplicate in ExternalIdentity.
local Junjo = require(game:GetService("ServerStorage").Junjo)
local adapter = Junjo.RobloxUserIdAdapter()
local function onJoin(player)
local userId = adapter:resolve(player)
junjo.groups:acceptInvitation(invite.code, userId)
endThe :resolve(value?) method accepts four call shapes:
| Argument | Behavior |
|---|---|
Player (a real Roblox Player instance, or a stub table with a numeric UserId field) | Reads value.UserId, converts to string. Throws invalid_config if the field is missing or not a positive integer. |
| number (positive integer) | Returns tostring(value). Throws invalid_config for zero, negative, or non-integer values. |
| string (non-empty) | Returns the string verbatim (treated as already-resolved). Empty strings throw invalid_config. |
| nil (no argument) | Reads Players.LocalPlayer.UserId. Throws invalid_config on the server side, where LocalPlayer is nil; pass the Player explicitly from server scripts. |
The adapter is purely a renderer; it does not call the Junjo API and never throws a JunjoError with a non-invalid_config code. There is no token to verify (Roblox does not give the dev’s backend a session token for the player; the trust boundary is the Roblox game server itself, which already trusts the Player instance it received), so the adapter is intentionally narrower than the TypeScript AuthAdapter interface (which is async and returns null on verification failure).
Options
local adapter = Junjo.RobloxUserIdAdapter({
explicitUserId = "12345", -- optional; tests / scripted contexts
players = mockPlayersService, -- optional; inject for unit testing
})| Option | Notes |
|---|---|
explicitUserId | Hard-coded id returned by every :resolve() call regardless of input. Accepts a non-empty string OR a positive integer (which is rendered with tostring). Use only in tests or scripted automation; a production deployment with this option set returns the same id for every player. |
players | Inject a fake Players service for unit tests. Defaults to game:GetService("Players"). The fake just needs a LocalPlayer field carrying a UserId. |
Server-side vs client-side
Roblox scripts run on either the server (Script instances) or the client (LocalScript instances). Players.LocalPlayer is only populated on the client. Pattern by context:
- Server scripts (the only place Junjo runs): always pass the
Playerreference you already have on hand from aPlayers.PlayerAddedcallback or aRemoteEventhandler.adapter:resolve(player)does thetostring(player.UserId)conversion for you. - Client scripts: never require Junjo from a
LocalScript; the module lives in a non-replicated container, so clients cannot reach it (and must not, since the process would need the API key). Clients send intent throughRemoteEvents only, and the server derives identity from thePlayerargument Roblox passes to the handler. See “Calling Junjo from player actions” below. - Tests: construct with
explicitUserIdso the adapter never touches the RobloxPlayersservice.
Calling Junjo from player actions
Clients never call Junjo. A LocalScript sends intent through a RemoteEvent, and the server-side handler derives WHO is asking from the Player argument Roblox passes as the first parameter to OnServerEvent (the one input a client cannot spoof). Everything else in the payload is attacker-controlled and must be validated before it reaches Junjo:
-- ServerScriptService/FriendActions.server.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Junjo = require(ServerStorage.Junjo)
local junjo = Junjo.new({ apiKeySecret = "JUNJO_API_KEY" })
local userIds = Junjo.RobloxUserIdAdapter()
local acceptFriendRequest = ReplicatedStorage.AcceptFriendRequest -- RemoteEvent
acceptFriendRequest.OnServerEvent:Connect(function(player, requestId)
task.spawn(function()
-- Identity comes from `player`, never from the payload.
local actorId = userIds:resolve(player)
-- `requestId` is client-supplied: ownership-validate it before
-- use. The request must actually be addressed to this player.
if type(requestId) ~= "string" or requestId == "" then
return
end
local requests = junjo.friends.requests:list(actorId, { direction = "in" })
for _, request in ipairs(requests.inbound) do
if request.id == requestId then
junjo.friends.requests:accept(requestId)
return
end
end
-- Not addressed to this player: ignore (or log) the attempt.
end)
end)The same rules apply to every RemoteEvent handler that reaches Junjo:
- The actor is always the handler’s
Playerargument (player.UserId, via the adapter). Never read the acting user id out of the payload; a client can put any id there. - Any client-supplied id (
requestId,tagId, …) is ownership-validated before use, as above: confirm the object belongs to, or is addressed to, the calling player. - Moderation actions gate behind a server-side permission check first:
junjo:can(actorId, groupId, "kick_member")beforejunjo.groups:kick(...). - Never pass a client-supplied target user id into
junjo.bans,junjo.groups:ban, orjunjo.friendsmutations without a server-side rule that decides the target is legitimate. Without one, any exploiter can ban or befriend arbitrary users through yourRemoteEvent.
Sending JSON null in PATCH bodies
Lua tables treat nil as “key absent”, so a literal table cannot express a JSON null. Use the Junjo.Null sentinel for fields that must be cleared:
junjo.groups:update(groupId, {
defaultRoleId = Junjo.Null, -- sends `"defaultRoleId": null`
})A field set to nil is omitted from the request body entirely (matching every other Junjo PATCH route’s “absent means no change” convention). Use Junjo.Null only when you specifically want to clear a server-side value.
groups:setParent is the one exception: passing either nil OR Junjo.Null clears the parent (the server requires the field to be present, so the SDK substitutes Null when the caller passes nil):
junjo.groups:setParent(groupId, nil) -- clears parent
junjo.groups:setParent(groupId, Junjo.Null) -- clears parent (explicit)
junjo.groups:setParent(groupId, parentId) -- sets parentNamespace surface at a glance
-- Groups
local group = junjo.groups:create({ kind = "guild", name = "Crimson Wolves" })
local g = junjo.groups:get(groupId) -- nil on 404
local page = junjo.groups:list({ limit = 50 })
junjo.groups:update(groupId, { name = "Renamed" })
junjo.groups:delete(groupId) -- soft delete
junjo.groups:delete(groupId, { hard = true }) -- bypass undo window
junjo.groups:restore(groupId)
-- Membership
junjo.groups:inviteByUserId(groupId, userId, { roleId = "member" })
local invite = junjo.groups:inviteByCode(groupId, { roleId = "member", expiresIn = "7d" })
local result = junjo.groups:inviteByLink(groupId) -- { invitation, url }
junjo.groups:bulkInvite(groupId, "user_alpha\nuser_beta\n", { roleId = "member" })
junjo.groups:acceptInvitation(invite.code, userId)
junjo.groups:declineInvitation(invite.code)
junjo.groups:declineInvitation(invite.code, { userId = userId }) -- attribute who declined
junjo.groups:join(groupId, userId) -- public groups only
junjo.groups:join(groupId, userId, { passcode = "hunter2" })
junjo.groups:leave(groupId, userId)
junjo.groups:kick(groupId, userId, { reason = "afk" })
-- Per-group bans (blocks rejoin; distinct from kick)
junjo.groups:ban(groupId, userId, { reason = "griefing", expiresAt = "2026-09-01T00:00:00Z" })
junjo.groups:unban(groupId, userId, { actorUserId = modId })
local page = junjo.groups:banHistory(groupId, { limit = 50 })
for entry in junjo.groups:banHistoryAll(groupId) do
print(entry.kind, entry.userId)
end
-- Group relationships
junjo.groups:setRelationship(allyA, allyB, "alliance", { mutual = true })
junjo.groups:clearRelationship(allyA, allyB, { mutual = true })
local rel = junjo.groups:getRelationship(allyA, allyB) -- nil on 404
local rels = junjo.groups:listRelationships(allyA)
-- Sub-groups
junjo.groups:setParent(childId, parentId)
junjo.groups:setParent(childId, nil) -- clear parent
junjo.groups:listChildren(parentId)
-- Members
local m = junjo.members:get(groupId, userId) -- nil on 404
local m2 = junjo.members:getById(memberId) -- nil on 404
junjo.members:list(groupId, { limit = 50 })
junjo.members:list(groupId, { status = { "banned" } }) -- status filter
for member in junjo.members:listAll(groupId, { status = { "active" } }) do
print(member.userId)
end
junjo.members:listForUser(userId)
junjo.members:setMetadata(groupId, userId, { rank = "officer" })
junjo.members:setNotes(groupId, userId, { notesPublic = "Recruited by Alex" })
junjo.members:assignRole(groupId, userId, roleId)
junjo.members:removeRole(groupId, userId, roleId)
junjo.members:overridePermission(groupId, userId, "vault.withdraw", true)
junjo.members:clearPermissionOverride(groupId, userId, "vault.withdraw")
junjo.members:listPermissionOverrides(groupId, userId)
-- Roles
junjo.roles:create(groupId, { name = "Officer", priority = 100, color = "#ff0000" })
junjo.roles:get(roleId) -- nil on 404
junjo.roles:list(groupId)
junjo.roles:update(roleId, { name = "Captain" })
junjo.roles:delete(roleId)
junjo.roles:grantPermission(roleId, "invite_member")
junjo.roles:revokePermission(roleId, "invite_member")
-- Invitations
junjo.invitations:list(groupId, { limit = 50, includeExpired = true })
junjo.invitations:get(code) -- nil on 404
junjo.invitations:revoke(code)
-- Audit
local page = junjo.audit:list(groupId, { limit = 50 })
local next = junjo.audit:list(groupId, { before = page.nextCursor, limit = 50 })
-- Game-wide bans
junjo.bans:add(userId, { reason = "cheating" }) -- permanent
junjo.bans:add(userId, { expiresAt = "2026-09-01T00:00:00Z" }) -- time-bounded
junjo.bans:remove(userId, { actorUserId = modId })
local ban = junjo.bans:get(userId) -- nil when not banned
local page = junjo.bans:list({ limit = 50, includeExpired = true })
for entry in junjo.bans:historyAll(userId, { scope = "game" }) do
print(entry.kind, entry.eventAt)
end
-- Friends
local page = junjo.friends:list(userId, { limit = 50, viewer = viewerId })
for friend in junjo.friends:listAll(userId, { tagId = tagId }) do
print(friend.junjoUserId)
end
junjo.friends:remove(userId, otherUserId)
local rel = junjo.friends:getRelationship(viewerId, otherUserId)
-- { state = "friends" | "none" | "request_outgoing" | ..., since = ... }
junjo.friends:suggestions(userId, { limit = 10 })
-- Friend requests / blocks / tags / visibility
local sent = junjo.friends.requests:send(userId, targetUserId)
-- sent.status is "pending" (sent.request) or "auto-accepted" (sent.friendship)
junjo.friends.requests:list(userId, { direction = "in" })
junjo.friends.requests:accept(requestId)
junjo.friends.requests:decline(requestId)
junjo.friends.requests:cancel(requestId)
junjo.friends.blocks:add(userId, targetUserId)
junjo.friends.blocks:remove(userId, otherUserId)
junjo.friends.blocks:list(userId) -- at most 100 rows; see caveats below
local tag = junjo.friends.tags:create(userId, { name = "IRL", color = "#00ff00" })
junjo.friends.tags:assign(userId, otherUserId, { tag.id })
junjo.friends.visibility:set(userId, "friends-only")
-- Webhooks (endpoint CRUD only; no receiver-side helpers)
local endpoint = junjo.webhooks.endpoints:create({
url = "https://example.com/junjo-hook",
events = { "member.joined", "member.left" },
})
local page = junjo.webhooks.endpoints:list({ limit = 50 }) -- { items, nextCursor }
local next = junjo.webhooks.endpoints:list({ cursor = page.nextCursor })
junjo.webhooks.endpoints:update(endpoint.id, { disabled = true })
junjo.webhooks.endpoints:delete(endpoint.id)
-- Top-level permission checks
local allowed = junjo:can(userId, groupId, "invite_member")
local result = junjo:check(userId, groupId, "invite_member")
-- { allowed = true, source = "role", viaRoleId = "..." }
-- Top-level key info (GET /v1/whoami)
local info = junjo:keyInfo()
print(info.gameId)Friends caveats
junjo.friends.blocks:listreturns at most the server’s default page size (100 rows) and accepts no cursor, so there is currently no way to read past that (a server-side gap, not an SDK one). If your game lets a user accumulate more than 100 blocks, track blocks in your own store until the route grows pagination.junjo.friends:list(userId, { viewer = ... })with a viewer the owner’s visibility settings block surfaces as anot_founderror, not as an empty page; treatnot_foundfrom that route as “this viewer cannot see the list”, not “the user does not exist”.
Pagination
Every paginated list returns a { items, nextCursor } page; nextCursor is nil on the last page and feeds back in as opts.cursor for the next one. For “walk everything” loops, the namespaces expose iterator variants (listAll, banHistoryAll, historyAll) built on Junjo.pageAll, which wraps any cursor-paginated fetch into a generic-for iterator. Pages are fetched lazily, so a break stops paying for pages you never read:
for member in junjo.members:listAll(groupId) do
if member.userId == targetId then
print("found", member.id)
break
end
end
-- Junjo.pageAll works for any { items, nextCursor } endpoint,
-- including raw junjo.http calls:
for group in Junjo.pageAll(function(cursor)
return junjo.groups:list({ cursor = cursor, limit = 100 })
end) do
print(group.name)
endResponse shapes
Every namespace method returns the parsed server response verbatim (a Lua table, string, number, or nil). The SDK does not deserialize timestamp fields into a Roblox-specific type: ISO 8601 strings stay as strings on the wire, so callers who want a DateTime value call DateTime.fromIsoDate(s) themselves.
local group = junjo.groups:create({ kind = "guild", name = "Crimson Wolves" })
print(group.id) -- "grp_..."
print(group.kind) -- "guild"
print(group.createdAt) -- "2026-04-28T00:00:00.000Z"
print(DateTime.fromIsoDate(group.createdAt):FormatLocalTime("LL", "en-us"))Error codes you may see
code | When |
|---|---|
invalid_config | Junjo.new(config) was called without apiKey or apiKeySecret, with a non-table argument, with an empty / non-string baseUrl / inviteBaseUrl, or with an invalid retries table. |
network | HttpService:RequestAsync itself raised (HttpService is disabled, the URL is not in the allowed-origins list, DNS / TLS failure, etc.). |
internal | A non-2xx response whose body could not be parsed as the canonical { code, status, message } envelope, or a 2xx response whose body was not valid JSON. |
<server-supplied> | Any value the server returned in the JSON envelope (not_found, bad_request, permission_denied, invitation_used, parent_cycle, etc.). The full code list is documented in the per-route pages under API. |
Methods that return nil on 404 (groups:get, groups:getRelationship, members:get, members:getById, roles:get, invitations:get, bans:get) catch the not_found error inside the SDK and translate it to nil. Every other error code (bad_request, permission_denied, etc.) re-throws verbatim so callers can branch on it via pcall.
Manual verification
A lune-based Luau suite (packages/sdk-roblox/tests/) runs in CI on every Roblox-touching change, covering construction and key validation, nil-input guards, URL encoding, Junjo.Null round-trips, and the HTTP error paths against a fake HttpService. The Junjo.rbxm model is built by rojo and attached to each GitHub release under the roblox-v* tags. What CI cannot exercise is the real Roblox environment, so the following still need a manual pass in Roblox Studio after substantial changes:
Junjo.new({ apiKey = "..." })constructs without error in Roblox Studio.junjo.groups:create({ kind = "guild", name = "Test" })returns a parsed Lua table whoseidis a string.junjo.groups:get("does-not-exist")returnsnil(not an error).junjo.groups:get(group.id)returns the same table (round-trip).junjo.members:list(group.id, { limit = 5 })returns a{ items, nextCursor }table.junjo:can(userId, group.id, "some_permission")returns a Lua boolean.- A 4xx response is surfaced as a Lua error whose
code,status, andmessagefields match the server envelope (assert viapcall+Junjo.JunjoError.is(err)). Junjo.new({ apiKeySecret = "JUNJO_API_KEY" })reads fromHttpService:GetSecretwhen the secret is registered.- A
GetSecret-sourced key completes one real authenticated request end to end (for examplejunjo:keyInfo()returns the game id), proving theSecret:AddPrefixAuthorization path works against the live API, not just in the unit suite. Junjo.new({ apiKeySecret = "MISSING", apiKey = "fallback" })falls back to the literal apiKey when the secret is missing and emits the one-time[junjo-sdk]fallback warning naming the secret.junjo.groups:update(group.id, { defaultRoleId = Junjo.Null })clears the field (verify by re-readingjunjo.groups:get(group.id).defaultRoleId == nil).junjo.groups:setParent(child, nil)clears the parent (verify same way).Junjo.RobloxUserIdAdapter():resolve(player)returnstostring(player.UserId)for a realPlayerinstance triggered byPlayers.PlayerAdded.- A
RemoteEventhandler wired per “Calling Junjo from player actions” derives the actor from the handler’splayerargument (never from the payload) and completes a Junjo call for a real client-triggered action. Junjo.RobloxUserIdAdapter():resolve()from a server-sideScriptraises aJunjoErrorwithcode = "invalid_config"(becausePlayers.LocalPlayerisnilserver-side).Junjo.RobloxUserIdAdapter({ explicitUserId = "12345" }):resolve()returns"12345"regardless of context.Junjo.RobloxUserIdAdapter():resolve(0)raisesJunjoError({ code = "invalid_config" })(zero is not a positive integer).