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
/playto 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
{
"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:
"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):
POST /api/interactions/{id}/{token}/callback
Content-Type: application/json
{ "type": "message", "content": "hello there hello there hello there" }type | Discord | What happens |
|---|---|---|
message | 4 | Posts content (1-2000 characters), embeds and/or components in the channel, with "name used /command" above it |
defer | 5 | Shows "Bot is thinking..." to the person and gives you 15 minutes to send a follow-up |
update | 7 | Clicks only: edits the message the component is on. See Message Components |
deferUpdate | 6 | Clicks only: acknowledges now; edit the message later with PATCH .../original |
modal | 9 | Opens 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:
{ "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:
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
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.
| Header | Value |
|---|---|
X-baff.gg-Timestamp | Unix time in seconds when we sent it |
X-baff.gg-Signature | Hex 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.
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));
}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:
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.