baff.gg Docs Developer Portal

Building bots

Interactions

Receive slash commands over the gateway or on your own HTTPS endpoint, and answer them.

When someone uses one of your slash commands, clicks one of your bot's buttons, picks something in its select menu or sends a form (modal) it opened, baff.gg creates an interaction and sends it to your bot. Your bot has 3 seconds to answer, or to say it needs longer.

Receiving them

On the gateway (the default): an INTERACTION_CREATE event arrives on your bot's WebSocket. If your bot isn't connected, the person is told it's offline and nothing is sent.

On an HTTP endpoint: set an Interactions Endpoint URL on the portal's General Information page. baff.gg then POSTs each interaction to that URL instead, which suits serverless hosts that can't keep a WebSocket open. See HTTP endpoints below.

Want /play to put your bot in a voice channel? Bots can join voice and play audio (they can't hear anyone). See Voice for bots.

The interaction object

json
{
	"type": "command",
	"id": "103745871234568192",
	"token": "6b0f...e91a",
	"applicationId": "103712345678901247",
	"channelId": "103700000000000001",
	"serverId": "103700000000000000",
	"user": {
		"id": "103600000000000000",
		"username": "andre",
		"displayName": "Andre",
		"avatarUrl": null,
		"roles": ["103700000000000123"]
	},
	"data": {
		"id": "103745000000000000",
		"name": "say",
		"options": [
			{ "name": "text", "type": "string", "value": "hello there" },
			{ "name": "times", "type": "integer", "value": 3 }
		]
	}
}

serverId is null when the command was used in a DM with your bot. Options that weren't filled in are left out.

Subcommands

For a command with subcommands, data.name is the command and data.options holds the one subcommand that was used, with its options inside (like Discord). For /partner add server: Baff Club:

json
"data": {
	"id": "103745000000000000",
	"name": "partner",
	"options": [
		{ "name": "add", "type": "subcommand", "options": [
			{ "name": "server", "type": "string", "value": "Baff Club" }
		] }
	]
}

A subcommand in a group is one level deeper: [{ "name": "settings", "type": "subcommand_group", "options": [{ "name": "show", "type": "subcommand", "options": [] }] }]. Your answer shows as "name used /partner add".

Answering

Post to the interaction's callback URL, using its id and token (no bot token needed):

http
POST /api/interactions/{id}/{token}/callback
Content-Type: application/json

{ "type": "message", "content": "hello there hello there hello there" }
typeDiscordWhat happens
message4Posts content (1-2000 characters), embeds and/or components in the channel, with "name used /command" above it
defer5Shows "Bot is thinking..." to the person and gives you 15 minutes to send a follow-up
update7Clicks only: edits the message the component is on. See Message Components
deferUpdate6Clicks only: acknowledges now; edit the message later with PATCH .../original
modal9Opens a form for the person. See Modals

Discord's shape works too: {"type": 4, "data": {"content": "...", "flags": 64}} (the fields in data are read as if they were at the top, and flag 64 means ephemeral).

A message can carry up to 10 embeds with "embeds": [...], with or without content:

json
{ "type": "message", "embeds": [{ "title": "Level 12", "color": 3065014, "fields": [{ "name": "Total XP", "value": "4,210", "inline": true }] }] }

Add "ephemeral": true to a message to show it only to the person who used the command. It isn't stored and disappears when they reload.

Each interaction takes one answer. A second one gets already_answered. Answering after 3 seconds without deferring gets interaction_expired, and the person sees that your bot didn't respond.

Follow-ups

After answering (or deferring), send more messages for 15 minutes:

http
POST /api/interactions/{id}/{token}/followup
Content-Type: application/json

{ "content": "Done! Here's your result.", "ephemeral": false }

Follow-ups take embeds too. After a defer, your first follow-up is the real answer.

Example: slow work

js
if (t === 'INTERACTION_CREATE' && d.data.name === 'report') {
	const url = `https://baff.gg/api/interactions/${d.id}/${d.token}`;
	const post = (path, body) =>
		fetch(url + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });

	await post('/callback', { type: 'defer' }); // within 3 seconds
	const report = await buildReport(); // can take minutes
	await post('/followup', { content: report });
}

Components and modals

Any answer or follow-up can carry components (buttons and select menus, or a whole layout with "flags": 32768), ephemeral or not. A click or a choice arrives as a new interaction with "type": "component"; a sent form arrives as "type": "modal_submit". Both have their own id and token and the same 3-second rule, and both are answered like a command. See Message Components for the payloads, the extra answer types (update, deferUpdate, modal) and PATCH /api/interactions/{id}/{token}/original.

HTTP endpoints

With an Interactions Endpoint URL set, each interaction is POSTed there as JSON (the same object as above). Your endpoint must be public HTTPS and answer within 3 seconds.

Answer in the response. Reply 200 with a callback body ({"type": "message", ...}, {"type": "defer"}, or any other answer type) and it's applied directly. You can also reply 200 with an empty body and answer through the callback URL instead.

Saving the URL sends {"type": "ping"} first. Answer {"type": "pong"} or the URL isn't saved.

Verifying requests

Every request is signed with your app's Signing Secret (portal, General Information). Check it, or anyone could send your endpoint fake commands.

HeaderValue
X-baff.gg-TimestampUnix time in seconds when we sent it
X-baff.gg-SignatureHex HMAC-SHA256 of timestamp + "." + body, keyed with the signing secret

Use the raw request body exactly as received, before parsing it. Reject timestamps more than 5 minutes old to stop replays.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, timestamp, signature, rawBody) {
	if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
	const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
	return signature?.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
python
import hmac, hashlib, time

def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

A complete endpoint

A Cloudflare Worker (free tier) that answers /ping:

js
export default {
	async fetch(request, env) {
		const body = await request.text();
		const ok = await verifyWorker(env.SIGNING_SECRET, request.headers.get('x-bafflux-timestamp'), request.headers.get('x-bafflux-signature'), body);
		if (!ok) return new Response('bad signature', { status: 401 });

		const interaction = JSON.parse(body);
		if (interaction.type === 'ping') return Response.json({ type: 'pong' });
		if (interaction.data?.name === 'ping') return Response.json({ type: 'message', content: 'Pong!' });
		return Response.json({ type: 'message', content: "I don't know that one.", ephemeral: true });
	}
};

// Same check as above, with the Web Crypto API that Workers have.
async function verifyWorker(secret, timestamp, signature, body) {
	if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
	const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
	const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`));
	const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
	return expected === signature;
}

Store the secret with wrangler secret put SIGNING_SECRET, then put the Worker's URL in the portal.