SpaceBot API
The stable external API lives under /api/v1/* and uses Authorization: Bearer sb_live_... API keys.
The machine-readable OpenAPI document is available at /api/openapi.json and can be regenerated into docs/generated/openapi.json with:
bun run docs:api
Error Shape
Errors return JSON with a top-level error string. Rate-limited requests return HTTP 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.
Endpoints
GET /api/v1/stats?days=30requiresstats:read.GET /api/v1/stats/members?period=30d&granularity=autorequiresstats:read. The member-count series for a graph: one point per bucket, each the lastserver_statssnapshot in that bucket, withmember_count,online_count,human_countandrecorded_at.periodis24h,<N>d,1yorall;granularityisauto(hourly to 2 days, daily to 60, weekly beyond),hourly,dailyorweekly. Anything else is a 400 rather than a silent fallback. Retention keeps 90 days of snapshots, so longer periods return what remains.GET /api/v1/members/:userId?days=30requiresmembers:read. One member's own counts — see below.GET /api/v1/settingsrequiressettings:read.GET /api/v1/channels?type=textrequireschannels:read. The public channel directory — see below.GET /api/v1/member-content?user_id=requirescontent:read. What members keep with/about— see member content.GET /api/v1/commands?built_in=only|excluderequirescommands:read. Both SpaceBot's built-in commands and the ones the guild defined, built-ins first, each marked withis_built_in.limit/offsetpage across the two together.GET /api/v1/automationsrequiresautomations:read.GET /api/v1/integrations/statusreads integration status.GET /api/stats/:guildId/export?format=json|csvexports admin-authenticated report data.
Integrations
Discord signed interactions at /api/discord/interactions are signature-verified and exempt from app rate limits. Runner WebSocket upgrade paths are also exempt.
Connect: one-click authorization
A site can get an API key without anyone minting and pasting one. SpaceBot acts as the authorization server: the site sends a server admin to a consent screen, they approve scopes for one of their servers, and the site exchanges a one-time code for a key over a server-to-server call.
The key never travels through a browser. What comes back through the redirect is a code, which is worthless without the client secret.
1. Register the application
One command, which prints the environment lines to paste:
bun run connect:register -- \
--client-id starspace-website \
--name "*Space" \
--redirect-uri https://starspace.group/admin/spacebot/callback \
--scope voice:read --scope stats:read
Add --local to register against the local D1 instead of production. There is
also Superadmin → Connect Apps for the same thing in the browser, plus
enabling, disabling and deleting.
Either way you provide a client id, a display name (this is what admins see and approve), the exact redirect URIs the app may receive a code at, and the scopes it may ever request. The client secret is shown once and stored only as a SHA-256 hash.
redirect_uris is matched exactly, not by prefix or host. That allowlist is
the whole defence against this flow being used to harvest keys — an
unregistered redirect URI is refused on the consent page and never redirected
to.
Changing what a client may request
Scopes and redirect URIs are editable under Superadmin → Connect Apps, without rotating the client secret. Open a client, tick the scopes, save.
This matters because the alternative is delete-and-recreate, which mints a new
secret — so adding one scope to a live integration would mean editing its
environment and redeploying it. A site that grows a feature needing
channels:read should not have to be redeployed to ask for it.
Requesting a scope the client is not registered for is refused on the consent page, before the admin is even asked to sign in, and the page names the scope. Widen the registration first, then the site can request it.
2. Send the admin to the consent screen
GET https://spacebot.starspace.group/connect
?client_id=<your client id>
&redirect_uri=<one of your registered URIs>
&scope=voice:read%20stats:read
&state=<random, held in an httpOnly cookie on your side>
state is required. Generate it per attempt, store it where only your server
can read it, and refuse a callback that does not match — without it, a link
someone else crafted can complete a connection to a SpaceBot you did not choose.
The admin picks which of their servers to grant, and may untick scopes. The request can never exceed the registration, and the approval can never exceed the request.
On approval SpaceBot redirects to redirect_uri?code=…&state=…. The code is
single-use and lives for 120 seconds.
3. Exchange the code for a key
POST /api/v1/connect/exchange
Content-Type: application/json
{
"client_id": "starspace-website",
"client_secret": "sbcs_…",
"code": "sbc_…",
"redirect_uri": "https://starspace.group/admin/spacebot/callback"
}
{
"api_key": "sb_live_…",
"guild_id": "123456789012345678",
"scopes": ["voice:read", "stats:read"]
}
redirect_uri must be the one the code was issued against. Every rejection
returns the same message, so probing cannot distinguish an unknown client from a
wrong secret from a spent code.
The key is created at this step, not at approval — an approval nobody redeems leaves no credential behind. It is an ordinary API key from then on, and is revoked from that server's API keys page.
GET /api/v1/channels
The server's public channel directory: what a visitor would see in the sidebar without joining anything — name, type, category, Discord's own ordering, and the channel's topic where one is set.
Requires channels:read.
| Parameter | |
|---|---|
type |
One of text, voice, announcement, stage, forum, media |
This response is safe to publish verbatim, which is the point of it: a community site can render "what the channels are for" without anyone keeping a second copy by hand. Two filters run before anything is stored, each where its data is:
- The gateway sends only channels the
@everyonerole can view. A private staff channel is not filtered out of this response — it was never written. /api/channels/syncdrops rooms members created with/room. Those are public in Discord's eyes but are not part of the directory, and listing them would publish who is sitting in what.
The directory is replaced wholesale on every sync — on gateway start-up, on any
channel create, update or delete, and on any role permission change, because
editing @everyone can hide a dozen channels at once and emits no channel event
for any of them. synced_at is when that last happened; a caller that has not
seen it move for a long time is looking at a gateway that is down.
Categories group the response and are never listed as channels of their own.
{
"guild_id": "123456789012345678",
"synced_at": "2026-09-07 12:00:00",
"count": 2,
"categories": [
{
"category": "Lobby",
"category_id": "111111111111111111",
"channels": [
{
"id": "222222222222222222",
"name": "general",
"type": "text",
"topic": "Say hello",
"category": "Lobby",
"category_id": "111111111111111111",
"position": 0
}
]
}
],
"channels": ["… the same channels, flat …"]
}
GET /api/v1/members/:userId
What one member did here, and whether they are still in the server. This is the endpoint behind "sign in and see your own stats" on a community site.
Requires members:read, which is not implied by stats:read. A key that
may graph the member count has no business reading what one named account did,
so an owner grants this one deliberately and a key issued before this endpoint
existed does not carry it.
| Parameter | |
|---|---|
days |
Window for activity and standing. Default 30, clamped to 90. |
Counts only. No message text, no channel ids, no names, and never a word about who else is ahead of them.
{
"guild_id": "…",
"user_id": "…",
"member": true,
"joined_at": "2026-01-04 12:00:00",
"bot": false,
"days": 30,
"retention_days": 90,
"unrecorded_channels": 1,
"activity": {
"messages": 12,
"channels": 3,
"last_message_at": "2026-09-20 10:00:00",
"voice_seconds": 3600,
"voice_sessions": 4,
"voice_channels": 2,
"last_voice_at": "2026-09-19 22:00:00",
"commands": 7
},
"recorded": { "… the same shape, over the full 90 days kept …": 0 },
"standing": {
"message_rank": 4,
"message_population": 37,
"voice_rank": 2,
"voice_population": 11
}
}
Four properties are worth knowing before you build on it:
- A user who is not in the server answers
member: falsewith empty counts, at HTTP 200. Not a 404 — a different status for a member and a stranger would let a caller probe ids for existence. Somebody who left is a stranger again, and their history stops being readable the moment the gateway notices. rankis null when they did none of that thing, withpopulationstill filled in. There is no rank among people you are not one of, and "last of 37" would be a claim about a contest nobody entered.- A channel in
excluded_channelsis never counted, because those events were never recorded.unrecorded_channelssays how many there are, so the caller can say so rather than present a total that reads too low. recordedis not a lifetime. Raw events are pruned at 90 days, so it is the full retained history and nothing older.joined_atis the one figure here that does reach back further.
The endpoint cannot verify that the person asking owns the account being asked about — a key with this scope may ask about any member. That is the trust being granted. The intended caller took the Discord id from its own OAuth session and never from a query parameter.
GET /api/v1/voice
Who is in voice right now, from the same live snapshot the dashboard panel draws.
Requires voice:read.
| Parameter | |
|---|---|
channel |
Filter to one channel, by id or by name (case-insensitive) |
include_bots |
true to include bots, which are excluded by default |
A channel that matches nothing returns an empty channels array rather than a
404: to a caller drawing a panel, "nobody is in there" and "no such channel" are
the same answer, and a 404 would make a quiet evening look like a broken
integration.
{
"guild_id": "123456789012345678",
"channels": [
{
"channelId": "987654321098765432",
"channelName": "Ten Forward",
"memberCount": 3,
"members": [
{
"userId": "…",
"displayName": "…",
"avatarUrl": "…",
"selfMute": false,
"streaming": true,
"selfVideo": false,
"isBot": false
}
]
}
],
"totalChannels": 1,
"totalUsers": 3,
"updatedAt": "2026-09-06T03:18:30.000Z"
}