SpaceBot Integration Protocol

This guide explains how external projects can integrate with SpaceBot to provide Discord slash commands, receive lifecycle events, and maintain a live connection.

Overview

SpaceBot integrations allow external projects (like *Space Game) to:

  1. Declare commands — Tell SpaceBot what slash commands your project provides
  2. Handle commands — Receive command invocations via webhook and respond to users
  3. Report status — Send heartbeats so SpaceBot knows your service is online
  4. Sync dynamically — Push manifest updates to add/remove/change commands without redeploying SpaceBot

How It Works

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│              │  sync   │              │ register │              │
│  Your        │ ──────► │   SpaceBot   │ ──────►  │   Discord    │
│  Project     │         │              │          │   API        │
│              │ ◄────── │              │ ◄──────  │              │
│              │ command │              │ interact │              │
└──────────────┘         └──────────────┘         └──────────────┘
  1. Your project sends its manifest to SpaceBot's sync API
  2. SpaceBot registers the commands with Discord for every guild that enabled your integration
  3. When a user runs one of your commands in Discord, SpaceBot proxies the interaction to your command handler webhook
  4. Your project processes the command and returns a response, which SpaceBot relays back to Discord

Getting Started

1. Get an Integration Token

An integration token is created by a SpaceBot admin when your integration is registered. The token format is:

sbi_<your-slug>_<random>

This token authenticates your project when communicating with SpaceBot. Keep it secret — treat it like an API key.

2. Create Your Manifest

Your manifest is a JSON object that describes your integration and its commands. Here's a complete example:

{
	"name": "My Awesome Game",
	"slug": "my-awesome-game",
	"version": "1.0.0",
	"description": "Adds game commands to your Discord server",
	"author": "Your Name",
	"author_url": "https://your-site.com",
	"icon": "🎮",
	"category": "gaming",
	"homepage": "https://your-game.com",
	"health_endpoint": "https://your-game.com/api/spacebot/health",

	"commands": [
		{
			"name": "game",
			"description": "Game commands",
			"type": 1,
			"options": [
				{
					"name": "stats",
					"description": "View your game stats",
					"type": 1,
					"options": [
						{
							"name": "player",
							"description": "Player to look up (defaults to yourself)",
							"type": 6,
							"required": false
						}
					]
				},
				{
					"name": "leaderboard",
					"description": "View the server leaderboard",
					"type": 1
				}
			]
		}
	],

	"webhooks": {
		"on_enable": "https://your-game.com/api/spacebot/on-enable",
		"on_disable": "https://your-game.com/api/spacebot/on-disable",
		"command_handler": "https://your-game.com/api/spacebot/command"
	},

	"config_schema": [
		{
			"key": "api_key",
			"label": "Game API Key",
			"type": "string",
			"required": false
		},
		{
			"key": "game_channel",
			"label": "Game Channel",
			"type": "channel",
			"required": false
		}
	]
}

3. Host the Manifest (Optional)

You can host your manifest as a static JSON file at a public URL:

https://your-game.com/spacebot-integration.json

SpaceBot can fetch this URL to discover your integration. This is useful for initial registration but the Sync API (below) is the primary way to keep your manifest up to date.

API Reference

All API endpoints are at https://spacebot.starspace.group/api/v1/integrations/.

For local development, use http://localhost:4269/api/v1/integrations/.

Authentication

All authenticated endpoints use Bearer token auth:

Authorization: Bearer sbi_your-slug_yourtoken123...

POST /api/v1/integrations/sync

Push your manifest to SpaceBot. This updates your integration's commands and metadata, then re-syncs Discord commands for all guilds that have your integration enabled.

Call this whenever your commands change — for example, during deployment or when you add new features.

Request:

POST /api/v1/integrations/sync
Authorization: Bearer sbi_your-slug_yourtoken123...
Content-Type: application/json

{
  "name": "My Awesome Game",
  "slug": "my-awesome-game",
  "version": "1.1.0",
  "description": "Adds game commands to your Discord server",
  "commands": [ ... ],
  "webhooks": {
    "command_handler": "https://your-game.com/api/spacebot/command"
  }
}

Response:

{
	"success": true,
	"integration": "my-awesome-game",
	"guilds_synced": 3,
	"sync_results": [
		{ "guild_id": "123456789", "success": true, "registered": 5 },
		{ "guild_id": "987654321", "success": true, "registered": 5 }
	]
}

Important: The slug in your manifest body must match the slug of the authenticated integration token. You cannot update another integration's manifest.


POST /api/v1/integrations/heartbeat

Tell SpaceBot your service is online. Send this every 2-3 minutes. Integrations that miss heartbeats for 5 minutes are automatically marked as offline.

Request:

POST /api/v1/integrations/heartbeat
Authorization: Bearer sbi_your-slug_yourtoken123...
Content-Type: application/json

{
  "version": "1.1.0",
  "uptime": 7200
}

The JSON body is optional — a heartbeat with no body is valid.

Response:

{
	"success": true,
	"integration": "my-awesome-game",
	"recorded_at": "2026-02-21T12:00:00.000Z",
	"next_expected_before": "2026-02-21T12:05:00.000Z"
}

GET /api/v1/integrations/status

Check the status of integrations. No authentication required.

Query Parameters:

  • slug (optional) — Filter to a specific integration

Request:

GET /api/v1/integrations/status?slug=my-awesome-game

Response (single):

{
	"slug": "my-awesome-game",
	"name": "My Awesome Game",
	"version": "1.1.0",
	"status": "online",
	"last_heartbeat_at": "2026-02-21T12:00:00.000Z",
	"category": "gaming",
	"is_official": false,
	"commands": 1
}

Response (all):

{
	"integrations": [
		{
			"slug": "starspace-game",
			"name": "*Space Game",
			"version": "1.0.0",
			"status": "online",
			"last_heartbeat_at": "2026-02-21T12:00:00.000Z",
			"category": "gaming",
			"is_official": true,
			"commands": 1
		}
	]
}

Status values: online, offline, unknown


Command Handler Webhook

When a user runs one of your integration's slash commands, SpaceBot will POST to your webhooks.command_handler URL with the following payload:

Request from SpaceBot

POST https://your-game.com/api/spacebot/command
Content-Type: application/json
X-SpaceBot-Integration: my-awesome-game
X-SpaceBot-Guild: 123456789012345678
{
	"type": "command",
	"command": "game",
	"options": [
		{
			"name": "stats",
			"type": 1,
			"options": [
				{
					"name": "player",
					"type": 6,
					"value": "234567890123456789"
				}
			]
		}
	],
	"guild_id": "123456789012345678",
	"user": {
		"id": "234567890123456789",
		"username": "player1",
		"discriminator": "0",
		"avatar": "abc123..."
	},
	"channel_id": "345678901234567890",
	"integration_slug": "my-awesome-game"
}

Your Response

You can respond in two ways:

Return a plain object with content and/or embeds:

{
	"content": "🏆 **player1's Stats**\nScore: 1,500 | Wins: 12 | Rank: #3",
	"ephemeral": false
}

Or with an embed:

{
	"content": "",
	"embeds": [
		{
			"title": "🏆 player1's Stats",
			"color": 5793266,
			"fields": [
				{ "name": "Score", "value": "1,500", "inline": true },
				{ "name": "Wins", "value": "12", "inline": true },
				{ "name": "Rank", "value": "#3", "inline": true }
			]
		}
	]
}

Option B: Full Discord interaction response

Return a complete Discord interaction response object for full control:

{
  "type": 4,
  "data": {
    "content": "🏆 Stats loaded!",
    "embeds": [ ... ],
    "components": [ ... ],
    "flags": 64
  }
}

(Type 4 = CHANNEL_MESSAGE_WITH_SOURCE. See Discord docs.)

Error Handling

  • Your handler should respond within 8 seconds (SpaceBot enforces a timeout)
  • If your handler returns a non-200 status or times out, SpaceBot shows an error message to the user
  • Return appropriate HTTP error codes (400, 500, etc.) for errors — SpaceBot will show a friendly fallback message

Lifecycle Webhooks

SpaceBot will call these URLs when a guild admin enables or disables your integration:

on_enable

POST https://your-game.com/api/spacebot/on-enable
Content-Type: application/json
X-SpaceBot-Integration: my-awesome-game

{
  "event": "integration.enabled",
  "guild_id": "123456789012345678",
  "enabled_by": "234567890123456789",
  "config": {}
}

Use this to provision resources, create database records, or send a welcome message.

on_disable

POST https://your-game.com/api/spacebot/on-disable
Content-Type: application/json
X-SpaceBot-Integration: my-awesome-game

{
  "event": "integration.disabled",
  "guild_id": "123456789012345678"
}

Use this to clean up resources.

Note: Lifecycle webhooks are fire-and-forget. SpaceBot does not wait for or validate the response.


Command Definition Reference

Commands follow the Discord Application Command structure:

Field Type Description
name string Command name (1-32 chars, lowercase, no spaces)
description string Command description (1-100 chars)
type number 1 for slash command (CHAT_INPUT)
options array Command options/subcommands (see below)

Option Types

Type Value Description
SUB_COMMAND 1 A subcommand
SUB_COMMAND_GROUP 2 A group of subcommands
STRING 3 Text input
INTEGER 4 Whole number
BOOLEAN 5 True/false
USER 6 Discord user selector
CHANNEL 7 Channel selector
ROLE 8 Role selector
MENTIONABLE 9 User or role
NUMBER 10 Decimal number
ATTACHMENT 11 File upload

Config Schema Types

The config_schema lets guild admins configure your integration from the SpaceBot dashboard:

Type Description
string Free text input
channel Discord channel selector
role Discord role selector
boolean Toggle switch
select Dropdown with predefined choices

Integration Actions

Beyond whole slash commands, an integration can contribute actions into SpaceBot's shared action system — the same one that powers the custom-command builder and automations. Server admins then compose their own commands (custom name, options, response formatting) that call your action, or use it as an automation step.

Declare actions in your manifest. Each action key must be namespaced with your slug (e.g. agapeverse.generate_poem):

{
	"actions": [
		{
			"key": "agapeverse.generate_poem",
			"name": "Generate Poem",
			"description": "Generate a poem from a theme",
			"icon": "📜",
			"configSchema": {
				"theme": {
					"type": "text",
					"label": "Theme",
					"supportsOptionRef": true,
					"required": true
				},
				"style": {
					"type": "select",
					"label": "Style",
					"choices": ["haiku", "sonnet", "free"]
				}
			},
			"returns": ["poem", "title"]
		}
	],
	"webhooks": {
		"action_handler": "https://your-app.example.com/api/spacebot/action"
	}
}

configSchema uses the same field types as built-in actions (text, select, boolean, channel, user_source, number_source, …). For convenience, SpaceBot also accepts the common aliases type: "string" (treated as text) and, on a select, choices: [...] (treated as options: [...]), so you can write whichever reads naturally. Options may be plain strings or { "value": …, "label": … } objects.

When the action runs, SpaceBot POSTs to webhooks.action_handler:

POST https://your-app.example.com/api/spacebot/action
Content-Type: application/json
X-SpaceBot-Integration: agapeverse
X-SpaceBot-Guild: 123456789012345678
{
	"type": "action",
	"action_key": "agapeverse.generate_poem",
	"config": { "theme": "the sea", "style": "free" },
	"guild_id": "123456789012345678",
	"channel_id": "345678901234567890",
	"user": { "id": "234567890123456789", "username": "player1" },
	"integration_slug": "agapeverse"
}

Your response — return the fields you promised in returns; they become {action.<field>} in the admin's response template:

{ "result": { "poem": "Roses are red…", "title": "Roses" } }

Or return content / embeds to control the reply directly (used when the admin didn't author a response template):

{ "content": "📜 **Roses**\nRoses are red…" }

Per-invocation visibility. On a direct content/embeds response you may also set ephemeral to choose whether the reply is private to the invoking user or posted to the whole channel — decided per invocation by your handler:

{ "embeds": [{ "title": "Ode", "description": "…" }], "ephemeral": false }

SpaceBot honors this generically (no per-integration logic). Because Discord fixes a reply's ephemerality when the command is deferred, the rule is:

  • Give the command a response template and it keeps its static ephemeral setting (your ephemeral on the response is not used).
  • Leave the command without a response template (so your direct content/embeds are used) and set the command's ephemeral: true. Then a response with ephemeral: true (or omitted) stays private, and a response with ephemeral: false is promoted to a public followup in the channel (the ephemeral reply becomes a short "posted" acknowledgement). This lets one command decide, per run, between a private reply and a public post.

The 8-second timeout and offline handling are the same as the command handler.

Authenticating the request. Like the command handler, SpaceBot identifies itself with the X-SpaceBot-Integration (your slug) and X-SpaceBot-Guild headers — there is no HMAC signature on this call. Because the action_handler URL is only ever known to SpaceBot (from your synced manifest), treat the URL as a shared secret: verify the X-SpaceBot-Integration header matches your slug, and if you want stronger assurance, embed an unguessable token in the handler URL itself (e.g. …/api/spacebot/action?k=<random>) and check it. The exact request contract is: POST, JSON body with type: "action", action_key, config (resolved), guild_id, channel_id, user ({id, username} or null), integration_slug.


Integration Events

An integration can declare event types and push them into SpaceBot's automation engine, so a server admin can build automations triggered by activity on your platform (a poem published, a new account created, …).

Declare events in your manifest. Each type must be namespaced with your slug:

{
	"events": [
		{
			"type": "agapeverse.poem_created",
			"label": "Poem created",
			"description": "A new poem was published on AgapeVerse",
			"icon": "📜",
			"category": "agapeverse",
			"fields": ["title", "author", "url"]
		}
	]
}

Platform-wide events route to the integration's official guild only — a SpaceBot superadmin designates that guild when registering your integration. Push events to:

POST /api/v1/integrations/events
Authorization: Bearer sbi_your-slug_yourtoken123...
Content-Type: application/json

{
	"type": "agapeverse.poem_created",
	"details": { "title": "Roses", "author": "player1", "url": "https://agapeverse.app/p/roses" },
	"summary": "player1 published \"Roses\""
}
  • The type must be namespaced and declared in your manifest (undeclared types are rejected).
  • SpaceBot routes the event to your official guild and runs any automations whose trigger matches the type.
  • details.* fields are available to automation action templates.

Response:

{
	"success": true,
	"event_type": "agapeverse.poem_created",
	"guild_id": "…",
	"automations_triggered": 2
}

Template Variables

An integration can contribute per-user template variables — placeholders admins insert from the message editor's {} Variable picker anywhere a message template is composed (command responses, automation messages, embeds). SpaceBot resolves them at send time by asking your service.

Declare variables in your manifest, plus a variables_handler webhook. Each key must be namespaced with your slug:

{
	"variables": [
		{
			"key": "agapeverse.account_url",
			"label": "Account link",
			"description": "Link to the user's AgapeVerse account page",
			"scope": "user"
		},
		{
			"key": "agapeverse.display_name",
			"label": "Display name",
			"scope": "user"
		},
		{
			"key": "agapeverse.member_count",
			"label": "Member count",
			"description": "Total AgapeVerse members",
			"scope": "global"
		}
	],
	"webhooks": {
		"variables_handler": "https://agapeverse.app/api/spacebot/variables"
	}
}
  • scope: "user" (the default) — the value is specific to the Discord user the message concerns; SpaceBot sends their Discord user ID and your handler maps it to an account (e.g. via your Discord OAuth link).
  • scope: "global" — the value doesn't depend on a user (site-wide stats, status, …).

In guilds where your integration is enabled, declared variables appear in the picker as their own group (grouped by your slug), and admins use them like built-ins: Welcome {user.mention} — manage your account at {agapeverse.manage_url}.

Resolution — the variables_handler webhook

When a message referencing your variables is about to send, SpaceBot makes one batched call per invocation:

POST https://agapeverse.app/api/spacebot/variables
Content-Type: application/json
X-SpaceBot-Integration: agapeverse
X-SpaceBot-Guild: 123456789

{
	"type": "variables",
	"integration_slug": "agapeverse",
	"guild_id": "123456789",
	"discord_user_id": "987654321",
	"keys": ["agapeverse.account_url", "agapeverse.display_name"]
}

Your response:

{
	"values": {
		"agapeverse.account_url": "https://agapeverse.app/account/123",
		"agapeverse.display_name": "StarPoet"
	}
}

Rules and behavior:

  • Only declared keys are ever requested — the manifest is the consent surface. Return only what the user's privacy settings allow; omit keys you can't or won't resolve.
  • Timeout is 2 seconds. A slow or failed handler never blocks the send — unresolved keys render as empty strings (never as a literal {agapeverse.x} in chat).
  • discord_user_id is null for global-only requests. User-scoped variables resolve to '' when there's no user in context or no linked account.
  • Values are cached ~2 minutes per (integration, guild, user) to absorb automation bursts.
  • SpaceBot stores nothing beyond that short-lived cache.

Command Templates

Ship ready-made commands a server owner can apply with one click, then modify. SpaceBot clones the template into the guild's own commands (created disabled, with server-specific references cleared) — there's no live link back, so the owner edits it like any other command.

Declare templates in your manifest. Each is a full command definition (built on one of your actions):

{
	"command_templates": [
		{
			"key": "agapeverse.verse",
			"name": "verse",
			"summary": "Generate a poem from a theme",
			"description": "Generate a poem",
			"options": [
				{
					"name": "theme",
					"type": 3,
					"description": "What to write about",
					"required": true
				}
			],
			"action_type": "agapeverse.generate_poem",
			"action_config": { "theme": "option:theme", "style": "free" },
			"response_type": "embed",
			"response_embed": { "title": "{action.title}", "description": "{action.poem}" }
		}
	]
}

Bind a command option into the action config with the string form "option:<name>". There is no webhook for templates — they are pure manifest data; SpaceBot's dashboard renders them and handles the clone.

Each clone records the template it came from (commands.source_integration_slug / source_template_key, migration 0058). That's provenance, not a live link — editing the command still never touches the template, and applying again makes another independent copy under a suffixed name. The dashboard uses it for two things: the success toast links straight to the command it just created, and every template lists the commands already applied from it (with their enabled/disabled state) so an owner can see what they've added instead of guessing. Commands created any other way — hand-made, imported, seeded — leave both columns NULL.

Option-driven visibility (ephemeral)

A template's ephemeral is normally a static boolean. It can instead be tied to the command's own options — a boolean option (private when it's on) or a choice option (private when one of the selected choices matches) — so the invoking user chooses per run whether the reply is private. Several conditions can be combined with ;, and the reply is private when any of them matches. Unlike the handler-return approach above (§ "Per-invocation visibility"), this is declared entirely in the manifest, needs no action_handler, is resolved before the defer decision (so it works with defer: true), and coexists with a response_type/response_embed template.

Authoring forms:

// Boolean option, shorthand: a string in `ephemeral` names the option. Private
// iff that option is truthy; public when the option is omitted.
{
  "name": "verse",
  "options": [{ "name": "private", "type": 5, "description": "Keep it to yourself" }],
  "ephemeral": "option:private"          // or just "private"; "!public" negates
}

// Boolean option, explicit: keep a boolean fallback for when the option is omitted.
{
  "name": "verse",
  "options": [{ "name": "public", "type": 5, "description": "Post it for everyone" }],
  "ephemeral": true,                      // default private when `public` is absent
  "ephemeral_option": "!public"           // public option ON → not ephemeral
}

// Choice option: private when the selected value matches. Use "<name>=<value>";
// list several with commas; a leading "!" inverts.
{
  "name": "verse",
  "options": [
    { "name": "visibility", "type": 3, "description": "Who sees it",
      "choices": [
        { "name": "Public",  "value": "public" },
        { "name": "Private", "value": "private" }
      ] }
  ],
  "ephemeral": "visibility=private"       // or "visibility=private,dm"; "!tier=free" inverts
}

// Several options at once: conditions are separated by ";" and OR'd — private
// when `publicity` is draft/community, OR when the `anonymous` toggle is on.
{
  "name": "verse",
  "options": [
    { "name": "publicity", "type": 3, "description": "Who sees it",
      "choices": [
        { "name": "Draft — only me",  "value": "draft" },
        { "name": "Community",        "value": "community" },
        { "name": "Listed",           "value": "listed" }
      ] },
    { "name": "anonymous", "type": 5, "description": "Hide my name" }
  ],
  "ephemeral": "publicity=draft,community;anonymous"
}

Grammar:

ref       := condition (';' condition)*
condition := ['!'] ['option:'] <name> ['=' <value> (',' <value>)*]

Rules:

  • Every referenced option must be declared on the same template, or sync rejects the manifest.
  • Boolean form (<name>): ephemeral iff the option is truthy. Choice/equality form (<name>=<value>[,<value>…]): ephemeral iff the selected value is one of the listed choices (case-insensitive; integer choice values compared as strings).
  • A leading ! negates a single condition; an option: prefix is optional.
  • Multiple conditions are OR'd — any match makes the reply private. There is no AND form.
  • A condition whose option the user omitted is skipped, so it neither forces nor blocks privacy. If every referenced option is omitted, the static ephemeral boolean is the fallback default.
  • !, option:, =, , and ; are structural, so an option name or choice value containing =, , or ; cannot be referenced.

The dashboard's command builder exposes the same binding — one row per condition, with checkboxes for a choice option's values — so an owner can add or change it on the cloned command afterward.


Example: Minimal Integration

Here's the simplest possible integration — a single command with no subcommands:

Manifest (spacebot-integration.json)

{
	"name": "Hello World",
	"slug": "hello-world",
	"version": "1.0.0",
	"description": "A simple hello world integration",
	"icon": "👋",
	"category": "utility",

	"commands": [
		{
			"name": "hello",
			"description": "Say hello!",
			"type": 1
		}
	],

	"webhooks": {
		"command_handler": "https://my-app.example.com/api/spacebot/command"
	}
}

Command Handler (Node.js/Express example)

app.post('/api/spacebot/command', (req, res) => {
	const { command, user } = req.body;

	if (command === 'hello') {
		return res.json({
			content: `👋 Hello, ${user.username}!`,
		});
	}

	res.status(404).json({ error: 'Unknown command' });
});

Heartbeat (using setInterval)

const SPACEBOT_URL = 'https://spacebot.starspace.group';
const TOKEN = process.env.SPACEBOT_INTEGRATION_TOKEN;

// Send heartbeat every 2 minutes
setInterval(
	async () => {
		try {
			await fetch(`${SPACEBOT_URL}/api/v1/integrations/heartbeat`, {
				method: 'POST',
				headers: {
					Authorization: `Bearer ${TOKEN}`,
					'Content-Type': 'application/json',
				},
				body: JSON.stringify({ version: '1.0.0' }),
			});
		} catch (err) {
			console.error('Heartbeat failed:', err.message);
		}
	},
	2 * 60 * 1000
);

Sync on Startup

async function syncManifest() {
	const manifest = await fs.readFile('spacebot-integration.json', 'utf-8');

	const res = await fetch(`${SPACEBOT_URL}/api/v1/integrations/sync`, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${TOKEN}`,
			'Content-Type': 'application/json',
		},
		body: manifest,
	});

	const result = await res.json();
	console.log('Manifest synced:', result);
}

// Sync when the server starts
syncManifest();

Example: *Space Game Integration

The *Space Game is the reference integration for SpaceBot. Here's how it works:

What URL
Manifest https://game.starspace.group/spacebot-integration.json
Command Handler https://game.starspace.group/api/spacebot/command
Health Check https://game.starspace.group/api/spacebot/health
On Enable https://game.starspace.group/api/spacebot/on-enable
On Disable https://game.starspace.group/api/spacebot/on-disable

Commands provided:

  • /game stats [player] — View player statistics
  • /game leaderboard [category] — Server leaderboard
  • /game play — Get the game link

The Game project syncs its manifest when it starts up and sends heartbeats every 2 minutes. When SpaceBot is offline or the Game project adds new commands, it calls the sync endpoint and all guilds automatically get the updated command list.


Security Considerations

  • Integration tokens are long-lived secrets. Store them in environment variables, never in source code.
  • Webhook URLs must use HTTPS in production.
  • SpaceBot identifies itself via the X-SpaceBot-Integration and X-SpaceBot-Guild headers. You can verify these match expected values.
  • Command handler responses are displayed to Discord users — sanitize any user-generated content in your responses.
  • SpaceBot enforces an 8-second timeout on command handler webhooks. For long-running operations, return an immediate acknowledgment and use Discord's follow-up message API.

Status & Monitoring

SpaceBot tracks integration health:

Status Meaning
online Heartbeat received within the last 5 minutes
offline No heartbeat for 5+ minutes
unknown Integration has never sent a heartbeat

When an integration is offline, SpaceBot will show users a friendly error message instead of silently failing when they try to use integration commands.

Guild admins can see integration status on the Integrations page in the SpaceBot dashboard.