{"openapi":"3.0.3","info":{"title":"Praun Bot — Game-Server API","version":"2.0.0","description":"HTTP API the **praun bot** exposes for Roblox game servers and external services.\n\nEvery endpoint on this page is authenticated with a per-**workspace** bearer key\nminted from the dashboard (`/api-keys`). A tenant can hold multiple workspaces;\neach workspace owns one Roblox group + one PayPal subscription + one API key.\nRequests using a workspace key operate against that workspace's Roblox group only.\n\n## Authentication\n\nSend the key in an `Authorization` header on every request. The\nRFC 6750 `Bearer` scheme is **optional** — both forms below are\naccepted (case-insensitive on the scheme), so legacy v1 clients\nthat sent the raw key keep working without code changes:\n\n```http\nAuthorization: Bearer praun_xxxxxxxxxxxxxxxxxxxxxx\nAuthorization: praun_xxxxxxxxxxxxxxxxxxxxxx\n```\n\n### Legacy body auth (deprecated)\n\nFor seamless transition from v1 (`pron-bot`), POST endpoints also\naccept the key as a `security` field in the JSON body when no\n`Authorization` header is present. **Header always wins** if both\nare sent. Each call that uses the body fallback logs a\n`legacy_auth_body_security` warning so operators can chase migrations.\nNew integrations must use the header — body-field auth is kept only\nso live v1 game scripts keep dispatching while their owners update.\n\n```json\n{ \"method\": \"Embed\", \"cid\": \"...\", \"embedData\": { ... },\n  \"security\": \"praun_xxxxxxxxxxxxxxxxxxxxxx\" }\n```\n\n## Rate limits\n\nDefault **500 req/min/key**. Operators can override per-key from the admin\ndashboard. Going over returns `429 Too Many Requests` with a `Retry-After`\nheader.\n\n## Response envelope\n\nEvery JSON response wraps the payload:\n\n```json\n{ \"success\": true,  \"data\":  { /* endpoint-specific */ } }\n{ \"success\": false, \"error\": { \"code\": \"...\", \"message\": \"...\" } }\n```\n\n## Workspace state\n\nA workspace whose `tenant_workspaces.status` is not `active` or `grace_period`\nwill have **every** request rejected with `403 SUBSCRIPTION_INACTIVE` regardless\nof which endpoint they hit. Renew billing for that specific workspace in the\ndashboard (`/billing`).","contact":{"name":"Praun Bot","url":"https://pron.bot"},"license":{"name":"Proprietary — internal use"}},"servers":[{"url":"https://pron.bot","description":"Production"}],"tags":[{"name":"Discord","description":"Push messages and member actions from Roblox into a Discord guild the tenant has configured. Members are resolved to Discord IDs via the `verifications` table — Roblox users who have not run `/verify` are silently skipped on action endpoints."},{"name":"Roblox group","description":"Tenant-side reads and bulk operations against the bound Roblox group. Read paths use Open Cloud (with cookie fallback for tenants who pre-date the OC migration); destructive paths (`/bulkexile`) still require a valid bot `.ROBLOSECURITY` cookie because Open Cloud has no exile primitive."},{"name":"Verification","description":"Discord ↔ Roblox account linking via Roblox OAuth 2.0. The bot `/verify` slash command starts the flow; the redirect target documented here completes it."}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"praun_<random>","description":"Per-workspace API key minted from the dashboard. Format: `praun_` prefix followed by random characters. Each key is permanently bound to one workspace (and therefore one Roblox group). Tenants with multiple workspaces hold one key per workspace. The `Bearer ` scheme prefix is optional — `Authorization: praun_…` is also accepted for parity with v1 clients."}},"schemas":{"SuccessEnvelope":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","enum":[true]},"data":{"description":"Endpoint-specific payload (see individual responses)."}}},"ErrorEnvelope":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean","enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable machine-readable error code.","example":"VALIDATION_ERROR"},"message":{"type":"string","description":"Human-readable explanation. Safe to log; do not parse."},"details":{"description":"Optional structured context (shape is endpoint-specific). `POST /bulkexile` includes `results`, `total`, `exiled`, `failed` on 404."}}}}},"Embed":{"type":"object","description":"A single Discord embed. At least one of `title`, `description`, `image`, `thumbnail`, `author`, or `footer` must be set or Discord will reject the message.","properties":{"title":{"type":"string","maxLength":256},"description":{"type":"string","maxLength":4096},"color":{"type":"integer","description":"24-bit RGB int (e.g. `0xC4B550` = `12891984`).","minimum":0,"maximum":16777215},"url":{"type":"string","format":"uri"},"image":{"type":"string","format":"uri"},"thumbnail":{"type":"string","format":"uri"},"author":{"description":"Author name. If an array is supplied the entries are joined with `, ` (legacy multi-line shape).","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"footer":{"description":"Footer text. If an array is supplied only the **last** entry is used (legacy quirk).","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"fields":{"type":"array","description":"Up to 25 inline/block fields per embed.","maxItems":25,"items":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","maxLength":256},"value":{"type":"string","maxLength":1024},"inline":{"type":"boolean","default":false}}}},"ping":{"type":"string","description":"Discord user ID to mention as `<@id>` text content alongside the embed. For `MultipleEmbeds`, only the first embed's `ping` is used.","example":"87610105498980352"}}},"Role":{"type":"object","required":["id","name","color"],"properties":{"id":{"type":"string","example":"1273245678901234567"},"name":{"type":"string","example":"Members"},"color":{"type":"integer","description":"24-bit RGB int (Discord role color).","example":12891984}}},"ExileResult":{"type":"object","required":["user","success","message","attempts"],"properties":{"user":{"type":"string","description":"The original input from the request (username or id)."},"success":{"type":"boolean"},"message":{"type":"string","description":"On failure, a Roblox-side error string suitable for logging."},"attempts":{"type":"integer","description":"How many times this user was attempted before bailing or succeeding (max 3). 0 means the input could not be resolved to a Roblox user at all.","minimum":0,"maximum":3}}},"GroupMember":{"type":"object","required":["userId","username","role"],"properties":{"userId":{"type":"string","example":"21609523"},"username":{"type":"string","description":"Empty string on the Open Cloud listing path — Roblox's `:listGroupMemberships` does not return usernames. Cookie fallback (legacy tenants without a `tenant_workspaces` row) fills this in.","example":""},"role":{"type":"string","example":"Members"}}}},"responses":{"Unauthorized":{"description":"Missing, malformed, or unknown bearer token. Repeated failures are logged to `warning_logs (category='auth_failure')` with the caller IP and the first 9 characters of the attempted key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid or missing API key."}}}}},"Forbidden":{"description":"Bearer is valid but the owning workspace is not allowed to make requests right now (suspended/cancelled subscription, etc). Other workspaces under the same tenant are unaffected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"SUBSCRIPTION_INACTIVE","message":"Subscription is not active."}}}}},"RateLimited":{"description":"Per-key minute budget exhausted. Default 500 req/min; operator override per key.","headers":{"Retry-After":{"description":"Seconds until the bucket refills.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"RATE_LIMITED","message":"Too many requests. Try again later."}}}}},"ValidationError":{"description":"Required field missing or value out of range.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"VALIDATION_ERROR","message":"Missing required fields: method, cid."}}}}},"InternalError":{"description":"Unhandled server error. Body shape still matches the envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"INTERNAL_ERROR","message":"An unexpected server error occurred."}}}}}}},"security":[{"BearerAuth":[]}],"paths":{"/webhook":{"post":{"tags":["Discord"],"summary":"Send a message or embed to a Discord channel","description":"Posts content to a Discord channel the bot has access to in any of the tenant's configured guilds. Successful posts are recorded in `moderation_logs (action='webhook')` for the tenant.\n\nPick the right `method`:\n- `Message` — plain text only. Use for short status pings.\n- `Embed` — single rich embed. Use for moderation events, leaderboards, etc.\n- `MultipleEmbeds` — array of embeds in one message (Discord max 10).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["method","cid"],"properties":{"method":{"type":"string","enum":["Message","Embed","MultipleEmbeds"],"description":"Which payload shape the request uses."},"cid":{"type":"string","description":"Discord channel id to post to.","example":"1234567890123456789"},"message":{"type":"string","description":"Required when `method=Message`. Plain text body."},"timestamp":{"type":"integer","description":"Optional Unix seconds. When supplied with `method=Message`, a Discord relative-time tag (`<t:…:R>`) is appended to the message."},"embedData":{"description":"Required when `method=Embed` (single object) or `method=MultipleEmbeds` (array of objects).","oneOf":[{"$ref":"#/components/schemas/Embed"},{"type":"array","items":{"$ref":"#/components/schemas/Embed"},"maxItems":10}]}}},"examples":{"methodMessage":{"summary":"method=Message — plain text with relative timestamp","value":{"method":"Message","cid":"1234567890123456789","message":"Server boot complete.","timestamp":1714579200}},"methodEmbed":{"summary":"method=Embed — single embed with fields + ping","value":{"method":"Embed","cid":"1234567890123456789","embedData":{"title":"Player exiled","description":"Reason: ToS violation","color":12891984,"ping":"87610105498980352","fields":[{"name":"Roblox","value":"norperz","inline":true},{"name":"Game","value":"build game","inline":true}]}}},"methodMultipleEmbeds":{"summary":"method=MultipleEmbeds — array of embeds in one message","value":{"method":"MultipleEmbeds","cid":"1234567890123456789","embedData":[{"title":"Round summary","description":"Round 4 of 12 — CT victory.","color":12891984,"ping":"87610105498980352"},{"title":"Top fragger","description":"`norperz` — 14 kills / 2 deaths","thumbnail":"https://example.com/avatar.png"}]}}}}}},"responses":{"200":{"description":"Message posted to Discord.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/SuccessEnvelope"},{"type":"object","properties":{"data":{"type":"object","required":["messageId"],"properties":{"messageId":{"type":"string","description":"Discord snowflake of the new message. Useful for follow-up edits or links.","example":"1304019283746550000"}}}}}]}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Channel id not found, or the bot has no access to it (e.g. missing `View Channel`/`Send Messages`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"NOT_FOUND","message":"Discord channel '1234567890123456789' not found or bot lacks access."}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"description":"Upstream Discord error not classified as 404.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"DISCORD_API_ERROR","message":"Failed to send message: <reason>"}}}}}}}},"/useraction":{"post":{"tags":["Discord"],"summary":"Apply a moderation/role action to a Discord member","description":"Resolves Roblox user id(s) to Discord member id(s) via the `verifications` table, then runs the requested member action in the supplied guild. Members who are unverified or no longer in the guild are **silently skipped** (the response is still 2xx) so the game side does not have to special-case verification state.\n\nField requirements depend on `method` — see the table:\n\n| Method        | Required fields                          | Notes |\n|---------------|------------------------------------------|-------|\n| `GetRoles`    | `userid`, `serverid`                     | Returns the member's roles. |\n| `Timeout`     | `userid` or `users[]`, `serverid`, `state` (ms) | `state=0`/falsy clears the timeout. |\n| `Mute`        | `userid` or `users[]`, `serverid`, `state` (bool) | Voice-channel mute. |\n| `Deafen`      | `userid` or `users[]`, `serverid`, `state` (bool) | Voice-channel deafen. |\n| `AddRoles`    | `userid`, `serverid`, `roles`            | `roles` = space-separated Discord role ids. |\n| `RemoveRoles` | `userid`, `serverid`, `roles`            | Same shape as `AddRoles`. |","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["method","serverid"],"properties":{"method":{"type":"string","enum":["GetRoles","Timeout","Mute","Deafen","AddRoles","RemoveRoles"]},"serverid":{"type":"string","description":"Discord guild id where the action runs."},"userid":{"type":"string","description":"Roblox user id (target). Required for `GetRoles`, `AddRoles`, `RemoveRoles`."},"users":{"type":"array","description":"Batch target list. Used by `Timeout`/`Mute`/`Deafen`.","items":{"type":"string"}},"state":{"description":"`Timeout`: milliseconds (number, `0` clears). `Mute`/`Deafen`: boolean.","oneOf":[{"type":"integer"},{"type":"boolean"}]},"roles":{"type":"string","description":"Space-separated Discord role ids. `AddRoles` PUTs each, `RemoveRoles` DELETEs each.","example":"1273245678901234567 1273245678901234568"}}},"examples":{"methodGetRoles":{"summary":"method=GetRoles — look up a member's Discord roles","value":{"method":"GetRoles","serverid":"1234567890123456789","userid":"21609523"}},"methodTimeout":{"summary":"method=Timeout — mute communication for 1 hour (3 600 000 ms)","value":{"method":"Timeout","serverid":"1234567890123456789","users":["21609523"],"state":3600000}},"methodTimeoutClear":{"summary":"method=Timeout — clear an active timeout (state=0)","value":{"method":"Timeout","serverid":"1234567890123456789","users":["21609523"],"state":0}},"methodMute":{"summary":"method=Mute — voice mute (state=true)","value":{"method":"Mute","serverid":"1234567890123456789","users":["21609523"],"state":true}},"methodDeafen":{"summary":"method=Deafen — voice deafen (state=true)","value":{"method":"Deafen","serverid":"1234567890123456789","users":["21609523"],"state":true}},"methodAddRoles":{"summary":"method=AddRoles — grant two roles in one call","value":{"method":"AddRoles","serverid":"1234567890123456789","userid":"21609523","roles":"1273245678901234567 1273245678901234568"}},"methodRemoveRoles":{"summary":"method=RemoveRoles — strip a single role","value":{"method":"RemoveRoles","serverid":"1234567890123456789","userid":"21609523","roles":"1273245678901234567"}}}}}},"responses":{"200":{"description":"`GetRoles` returns the member's roles. All other methods return `{ \"success\": true, \"data\": null }` — the action(s) ran best-effort and individual failures are logged server-side, not surfaced.","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","enum":[true]},"data":{"nullable":true,"type":"object","description":"`null` for action methods; for `GetRoles`, an object containing the member's Discord roles.","properties":{"roles":{"type":"array","items":{"$ref":"#/components/schemas/Role"}}}}}}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`GetRoles`: the Roblox id has no `verifications` row (`NOT_VERIFIED`), or the linked Discord member is not in the supplied guild (`NOT_FOUND`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"NOT_VERIFIED","message":"Roblox user 21609523 has no linked Discord account."}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"description":"Upstream Discord error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/bulkexile":{"post":{"tags":["Roblox group"],"summary":"Exile a list of Roblox users from the bound group","description":"Each user is attempted up to **3** times. Roblox `429` / rate-limit responses pause the loop for **90 seconds** before retrying; any other failure aborts that user immediately.\n\nSuccessful exiles are logged to `moderation_logs (action='bulk_exile')`.\n\nUnhandled exceptions during the batch return **`500`** with `error.code: BULKEXILE_ERROR` (JSON) instead of failing the request without a body.\n\n> **Cookie required.** Open Cloud has no `:exile` / membership delete primitive, so this endpoint always uses the bot's `.ROBLOSECURITY` cookie under the hood. Tenants who have not completed the cookie configuration step will see all users fail.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["users"],"properties":{"users":{"type":"array","minItems":1,"description":"Roblox usernames or numeric ids. Each entry is resolved via the standard username/id lookup; unresolvable entries appear in `results` with `attempts: 0`.","items":{"type":"string"}}}},"example":{"users":["norperz","21609523"]}}}},"responses":{"200":{"description":"At least one user was exiled. Per-user breakdown in `results`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/SuccessEnvelope"},{"type":"object","properties":{"data":{"type":"object","required":["results","total","exiled","failed"],"properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/ExileResult"}},"total":{"type":"integer","description":"Number of users attempted."},"exiled":{"type":"integer","description":"Successful exiles."},"failed":{"type":"integer","description":"Users that could not be exiled."}}}}}]}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Every user in the request failed. `error.details` mirrors the 200 payload (`results`, `total`, `exiled`, `failed`) so callers can inspect per-user `message` values (rate limits, caps, hierarchy, etc.).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"NOT_IN_GROUP","message":"None of the specified users could be exiled.","details":{"results":[{"user":"123","success":false,"message":"Roblox API error: …","attempts":3}],"total":1,"exiled":0,"failed":1}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/getgroupmembers":{"get":{"tags":["Roblox group"],"summary":"List every member of the bound Roblox group","description":"Open Cloud paginates `:listGroupMemberships` (100 per page); the api walks the cursor and joins each membership against the cached role table for the workspace's group. Workspaces without a persisted Open Cloud key (legacy / cookie-only) fall back to the cookie listing path.\n\n**Guest (rank 0)** members are omitted unless `includeGuestRank=1` (or `true` / `yes`).\n\n> Open Cloud listings do **not** include usernames — `username` is the empty string on that path. Resolve usernames separately if you need them. Cookie fallback fills `username` in.","parameters":[{"name":"includeGuestRank","in":"query","required":false,"schema":{"type":"string","enum":["1","0","true","false","yes","no"]},"description":"Include Guest (rank 0) memberships. Default off. Use `1` / `true` / `yes` to include."}],"responses":{"200":{"description":"Full member list for the bound group.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/SuccessEnvelope"},{"type":"object","properties":{"data":{"type":"object","required":["members","count"],"properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/GroupMember"}},"count":{"type":"integer"},"includeGuestRank":{"type":"boolean","description":"Echo of whether Guest (rank 0) rows were included."}}}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"description":"Roblox upstream error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"success":false,"error":{"code":"ROBLOX_API_ERROR","message":"Failed to fetch group members: <reason>"}}}}}}}},"/verify":{"get":{"tags":["Verification"],"summary":"Roblox OAuth redirect target (browser-only)","description":"Browser redirect target Roblox sends the user back to after they authorize the bot via `/verify`. **Not** part of the bearer-authed surface — this endpoint is hit by the user's browser, not by your game server.\n\nOn success: redirects to `/?verification=success` and the Discord ↔ Roblox link is written to `verifications`. On any failure (expired session, bad code, OAuth refusal): redirects to `/?verification=failed`.","security":[],"parameters":[{"in":"query","name":"state","required":true,"schema":{"type":"string"},"description":"`verification_sessions.session_token` minted by the bot when the user ran `/verify`. 24 hex chars, single-use, 10-minute TTL."},{"in":"query","name":"code","required":true,"schema":{"type":"string"},"description":"Roblox OAuth authorization code."}],"responses":{"302":{"description":"Always — success or failure both redirect to the dashboard root.","headers":{"Location":{"schema":{"type":"string"},"description":"`/?verification=success` or `/?verification=failed`."}}}}}}}}