Bot API docs

Commands and components

Slash commands, right-click commands, buttons, select menus and forms work as on Discord: the same endpoints for registering commands, the same INTERACTION_CREATE event, the same callback types and follow-ups. discord.js and discord.py handle them with their usual APIs.

Registering commands

A bot has commands everywhere it is (global) and commands for one guild. Both are made with Discord's endpoints, using the bot's token:

EndpointNotes
GET /applications/{application}/commandsThe global commands.
POST /applications/{application}/commandsMakes one (201), or replaces the one with the same type and name (200, same id).
PUT /applications/{application}/commandsBulk overwrite: the commands become exactly the list given. Commands with the same type and name keep their ids. This is what discord.js's commands.set() and discord.py's tree.sync() use.
GET PATCH DELETE /applications/{application}/commands/{command}One command.
/applications/{application}/guilds/{guild}/commands…The same, for one guild the bot is in.
  • Types: 1 slash commands, 2 user commands and 3 message commands (right-click). Up to 100 slash, 5 user and 5 message commands per application, globally and per guild.
  • Discord's rules for names (lower case for slash commands, up to 32 characters), descriptions (1–100; none for user and message commands) and options: up to 25, required ones first, names unique, 8000 characters in all.
  • Option types 1–11: subcommands and subcommand groups (not mixed with other options), strings, integers, booleans, users, channels, roles, mentionables, numbers and attachments. Choices (up to 25) or autocomplete, min_value/max_value, min_length/max_length and channel_types.
  • default_member_permissions: a person needs all those permissions in the channel to see and use the command; "0" means administrators only. nsfw: true commands only show in NSFW channels.
  • Localised names and descriptions are accepted and given back, but people see the plain ones.
  • A guild command hides a global one with the same type and name in that guild. Per-command permission overrides (Discord's Integrations settings) don't exist: GET …/guilds/{guild}/commands/permissions is always empty.

People see commands in guild text channels, voice channels' chats and threads only. Slash commands come up when they type / in the message box. User and message commands are under Apps when they right-click (or long-press) someone or a message. Everyone needs Send Messages in the channel to use any command. A bot's commands go away when it leaves the guild.

Receiving interactions

When someone uses a command, clicks a button, picks from a select, types in an autocomplete option or submits a form, the bot gets INTERACTION_CREATE over the gateway. No intent is needed. If the bot has several sessions, one of them gets it. Voicaw has no HTTP interactions endpoint: interactions only come over the gateway, so a bot must be connected to receive them.

TypeWhatdata
2 Application commandA command was usedid, name, type, options (subcommands nested, as on Discord), target_id for user and message commands, resolved (users, members, roles, channels, messages, attachments named in it)
3 Message componentA button or select on the bot's messagecustom_id, component_type, values and resolved for selects. The interaction's message is the message.
4 AutocompleteSomeone is typing in an autocomplete optionAs type 2, with the option being typed marked focused (its value is whatever is typed so far)
5 Modal submitA form the bot opened was submittedcustom_id and components: the form's structure with each text input's value and each select's values

The interaction also has id, token, application_id, guild_id, guild, channel_id, channel, member (with their permissions in the channel), app_permissions (the bot's), locale (en-GB), context: 0 and attachment_size_limit. There's never a user field on its own: interactions always come from a guild.

Voicaw checks everything before the bot hears of it: option values against the command (types, choices, limits, and only users, roles and channels the person can see), select values against the select, and forms against the form that was opened. Disabled components can't be used.

Responding

Answer once, within 3 seconds, at POST /interactions/{interaction.id}/{interaction.token}/callback (no bot token needed). If the bot doesn't answer in time, or isn't connected, the person sees "The application did not respond" and the interaction is gone.

TypeDoesAllowed for
4 Channel messageReplies with a message (data: content, embeds, files, components, allowed_mentions, flags)Commands, components, form submits
5 Deferred channel messageShows "<bot> is thinking…" until you follow up (data.flags: 64 for an ephemeral one)Commands, components, form submits
6 Deferred updateAcknowledges without showing anythingComponents, form submits
7 Update messageEdits the message the component is onComponents, form submits from a component
8 Autocomplete resultdata.choices: up to 25 { name, value }Autocomplete
9 ModalOpens a form (see Forms)Commands, components
  • Files go as multipart/form-data, with the callback's JSON ({ type, data }) in payload_json.
  • ?with_response=true answers with { interaction, resource }, the message included, instead of 204.
  • Answering twice fails with 40060; too late, or an unknown interaction, with 10062. A type that isn't allowed for that interaction fails with 50035.
  • Replies show "<person> used /<command>" above them, and carry interaction_metadata (and the older interaction) in the API. Replies to commands have Discord's message types 20 (slash) and 23 (user and message commands).

Ephemeral replies

With flags: 64, a reply or follow-up is only for the person who used the interaction: "Only you can see this". As on Discord, it is never stored for the channel. It goes only to that person's open app, which keeps it until they reload or dismiss it. The bot can edit or delete it, and its components work, for 15 minutes. It is also lost if Voicaw's API restarts in that time (the bot then gets 10008 Unknown Message).

Deferring and follow-ups

For 15 minutes after the interaction, its token works as a webhook at /webhooks/{application.id}/{interaction.token} (no bot token needed), once the bot has answered:

EndpointNotes
POST /webhooks/{application}/{token}A follow-up message (flags: 64 for ephemeral). After a deferred reply (type 5), the first follow-up fills the "thinking…" message instead.
GET PATCH DELETE /webhooks/{application}/{token}/messages/@originalThe reply. Editing a deferred reply fills it (discord.js's editReply).
GET PATCH DELETE /webhooks/{application}/{token}/messages/{message}A follow-up.

Components

Bots can put components on any message they send or edit (components in the body), on replies and on follow-ups. Webhooks' components are ignored.

  • Up to 5 action rows (type: 1), each holding up to 5 buttons or one select.
  • Buttons (type: 2): styles 1 primary, 2 secondary, 3 success, 4 danger and 5 link (with a url, http or https, and no custom_id). A label (up to 80 characters), an emoji, or both. disabled. Premium buttons (style 6) aren't supported.
  • Selects: string (3, up to 25 options with label, value, description, emoji and default), user (5), role (6), mentionable (7) and channel (8, with channel_types). placeholder, min_values and max_values (0–25), default_values, disabled.
  • custom_id: 1–100 characters, unique in the message.
  • Discord's newer layout components (the IS_COMPONENTS_V2 flag: sections, containers, text displays…) aren't supported. Sending them fails with 50035.

Forms

A form (callback type 9) has a custom_id, a title (up to 45 characters) and 1–5 fields:

  • An action row holding one text input (type: 4): label, style 1 (short) or 2 (paragraph), min_length/max_length (up to 4000), required, value and placeholder.
  • A label component (type: 18, with label and description) holding a text input or a string select.

A form can be submitted once, within 15 minutes, by the person it was opened for. Its answers are checked against it (required fields, lengths, select options) before the bot gets the submit.

discord.js example

Checked with discord.js 14.27, set up as in discord.js, discord.py and HTTP:

import {
  Client, GatewayIntentBits, Events, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder,
  ButtonStyle, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags,
} from 'discord.js';

const client = new Client({
  intents: [GatewayIntentBits.Guilds],
  rest: { api: 'https://api.voicaw.com/api', cdn: 'https://api.voicaw.com/api/cdn' },
});

client.once(Events.ClientReady, async () => {
  await client.application.commands.set([
    new SlashCommandBuilder().setName('ping').setDescription('Ping'),
    new SlashCommandBuilder().setName('menu').setDescription('Buttons and a select'),
    new SlashCommandBuilder().setName('feedback').setDescription('Opens a form'),
    new SlashCommandBuilder().setName('slow').setDescription('Thinks first'),
    new SlashCommandBuilder().setName('city').setDescription('Pick a city')
      .addStringOption((o) => o.setName('name').setDescription('City').setRequired(true).setAutocomplete(true)),
  ]);
});

client.on(Events.InteractionCreate, async (i) => {
  if (i.isAutocomplete()) {
    return i.respond([{ name: 'London', value: 'london' }, { name: 'Lisbon', value: 'lisbon' }]);
  }
  if (i.isChatInputCommand()) {
    if (i.commandName === 'ping') return i.reply({ content: 'Pong!', flags: MessageFlags.Ephemeral });
    if (i.commandName === 'menu') {
      return i.reply({
        content: 'Choose',
        components: [
          new ActionRowBuilder().addComponents(
            new ButtonBuilder().setCustomId('yes').setLabel('Yes').setStyle(ButtonStyle.Success),
            new ButtonBuilder().setLabel('Docs').setURL('https://voicaw.com').setStyle(ButtonStyle.Link),
          ),
          new ActionRowBuilder().addComponents(
            new StringSelectMenuBuilder().setCustomId('fruit')
              .addOptions({ label: 'Apple', value: 'apple' }, { label: 'Pear', value: 'pear' }),
          ),
        ],
      });
    }
    if (i.commandName === 'feedback') {
      return i.showModal(new ModalBuilder().setCustomId('fb').setTitle('Feedback').addComponents(
        new ActionRowBuilder().addComponents(
          new TextInputBuilder().setCustomId('text').setLabel('Thoughts').setStyle(TextInputStyle.Paragraph),
        ),
      ));
    }
    if (i.commandName === 'slow') {
      await i.deferReply();
      await i.editReply('Done thinking');
      return i.followUp({ content: 'Only you see this', flags: MessageFlags.Ephemeral });
    }
    if (i.commandName === 'city') return i.reply(`You chose ${i.options.getString('name')}`);
  }
  if (i.isButton()) return i.update({ content: 'You said yes', components: [] });
  if (i.isStringSelectMenu()) return i.reply({ content: `Picked ${i.values.join(', ')}`, flags: MessageFlags.Ephemeral });
  if (i.isModalSubmit()) return i.reply(`Thanks for "${i.fields.getTextInputValue('text')}"`);
});

client.login(process.env.VOICAW_TOKEN);

discord.py example

Checked with discord.py 2.7.1. Set the three addresses first, as in discord.js, discord.py and HTTP (the gateway line matters: without it, discord.py sends your token to Discord).

import os
import discord, yarl
from discord import app_commands
from discord.http import Route

Route.BASE = 'https://api.voicaw.com/api/v10'
discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL('wss://api.voicaw.com/api/gateway/')
discord.asset.Asset.BASE = 'https://api.voicaw.com/api/cdn'

GUILD = discord.Object(id=int(os.environ['VOICAW_GUILD_ID']))

class Yes(discord.ui.View):
    @discord.ui.button(label='Yes', style=discord.ButtonStyle.success, custom_id='yes')
    async def yes(self, interaction, button):
        await interaction.response.edit_message(content='You said yes', view=None)

class Feedback(discord.ui.Modal, title='Feedback'):
    thoughts = discord.ui.TextInput(label='Thoughts', custom_id='thoughts')

    async def on_submit(self, interaction):
        await interaction.response.send_message(f'Thanks for "{self.thoughts.value}"')

client = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(client)

@tree.command(name='hello', description='Says hello', guild=GUILD)
async def hello(interaction, name: str):
    await interaction.response.send_message(f'Hello {name}', view=Yes())

@tree.command(name='feedback', description='Opens a form', guild=GUILD)
async def feedback(interaction):
    await interaction.response.send_modal(Feedback())

@client.event
async def on_ready():
    await tree.sync(guild=GUILD)

client.run(os.environ['VOICAW_TOKEN'])