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=30 requires stats:read.
  • GET /api/v1/stats/members?period=30d&granularity=auto requires stats:read. The member-count series for a graph: one point per bucket, each the last server_stats snapshot in that bucket, with member_count, online_count, human_count and recorded_at. period is 24h, <N>d, 1y or all; granularity is auto (hourly to 2 days, daily to 60, weekly beyond), hourly, daily or weekly. 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=30 requires members:read. One member's own counts — see below.
  • GET /api/v1/settings requires settings:read.
  • GET /api/v1/channels?type=text requires channels:read. The public channel directory — see below.
  • GET /api/v1/member-content?user_id= requires content:read. What members keep with /about — see member content.
  • GET /api/v1/commands?built_in=only|exclude requires commands:read. Both SpaceBot's built-in commands and the ones the guild defined, built-ins first, each marked with is_built_in. limit/offset page across the two together.
  • GET /api/v1/automations requires automations:read.
  • GET /api/v1/integrations/status reads integration status.
  • GET /api/stats/:guildId/export?format=json|csv exports 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.

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 @everyone role can view. A private staff channel is not filtered out of this response — it was never written.
  • /api/channels/sync drops 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: false with 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.
  • rank is null when they did none of that thing, with population still 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_channels is never counted, because those events were never recorded. unrecorded_channels says how many there are, so the caller can say so rather than present a total that reads too low.
  • recorded is not a lifetime. Raw events are pruned at 90 days, so it is the full retained history and nothing older. joined_at is 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"
}