A ticket system for a Discord bot, in discord.py and discord.js

A message with an Open a ticket button. Pressing it makes a private channel that only that member and your staff can see, and a Close button deletes it again. Here is the whole bot for each library, including the double click that quietly makes two tickets.

8 min readIntermediateUpdated Examples tested with discord.py 2.7.1 and discord.js 14.27

Each example below was run against a test copy of Discord: opening a ticket, double-clicking, opening a second one, another member opening theirs, closing, and opening again after closing.

What the bot needs

  • Manage Channels, to make and delete ticket channels.
  • The permissions it hands out. A bot can only allow, in a channel's permissions, what it has itself, so it needs View Channels, Send Messages and Attach Files too. With fewer, Discord refuses to make the channel (50013 Missing Permissions).
  • Two IDs, with Developer Mode on (User Settings, Advanced): the staff role that answers tickets, and the category tickets go in (right-click, Copy ID). The category is optional.

Each ticket is a text channel whose topic is ticket: and the member's ID. That is how the bot finds someone's open ticket, and how the Close button knows it is in one, with nothing to store.

discord.py

Python
import asyncio
import os

import discord
from discord import app_commands
from discord.ext import commands

STAFF_ROLE_ID = 123456789012345678       # the role that answers tickets
TICKET_CATEGORY_ID = 234567890123456789  # the category new tickets go in, or None

opening: set[int] = set()  # people whose ticket is being made right now

class TicketPanel(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(label="Open a ticket", style=discord.ButtonStyle.primary, custom_id="tickets:open")
    async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
        guild, user = interaction.guild, interaction.user
        mine = discord.utils.get(guild.text_channels, topic=f"ticket:{user.id}")
        if mine or user.id in opening:
            where = mine.mention if mine else "on its way"
            return await interaction.response.send_message(f"You already have a ticket: {where}.", ephemeral=True)
        opening.add(user.id)
        try:
            await interaction.response.defer(ephemeral=True)
            overwrites = {
                guild.default_role: discord.PermissionOverwrite(view_channel=False),
                user: discord.PermissionOverwrite(view_channel=True, send_messages=True, attach_files=True),
                guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True),
            }
            staff = guild.get_role(STAFF_ROLE_ID)
            if staff:
                overwrites[staff] = discord.PermissionOverwrite(view_channel=True, send_messages=True)
            category = guild.get_channel(TICKET_CATEGORY_ID) if TICKET_CATEGORY_ID else None
            channel = await guild.create_text_channel(
                f"ticket-{user.name}", topic=f"ticket:{user.id}", overwrites=overwrites,
                category=category if isinstance(category, discord.CategoryChannel) else None,
                reason=f"Ticket opened by {user}",
            )
            await channel.send(f"{user.mention}, tell us what is wrong and someone will be with you.",
                               view=CloseTicket())
            await interaction.followup.send(f"Your ticket: {channel.mention}", ephemeral=True)
        finally:
            opening.discard(user.id)

class CloseTicket(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    @discord.ui.button(label="Close", style=discord.ButtonStyle.danger, custom_id="tickets:close")
    async def close(self, interaction: discord.Interaction, button: discord.ui.Button):
        if not (interaction.channel.topic or "").startswith("ticket:"):
            return await interaction.response.send_message("This is not a ticket.", ephemeral=True)
        await interaction.response.send_message("Closing in 5 seconds.")
        await asyncio.sleep(5)
        await interaction.channel.delete(reason=f"Ticket closed by {interaction.user}")

class MyBot(commands.Bot):
    async def setup_hook(self):
        self.add_view(TicketPanel())  # both views answer after a restart too
        self.add_view(CloseTicket())
        await self.tree.sync()

bot = MyBot(command_prefix="!", intents=discord.Intents.default())

@bot.tree.command(description="Post the ticket button in this channel")
@app_commands.default_permissions(manage_channels=True)
@app_commands.guild_only()
async def ticketpanel(interaction: discord.Interaction):
    await interaction.channel.send("Need help? Open a ticket and talk to the team in private.", view=TicketPanel())
    await interaction.response.send_message("Posted.", ephemeral=True)

bot.run(os.environ["BOT_TOKEN"])
  • The double click. Making a channel takes a moment, and a second press in that moment would find no ticket yet and make another. opening remembers whose ticket is being made. In the test, the same double click on a slow connection made two channels without it and one with it.
  • The permissions. default_role is @everyone, which is denied View Channel. The member, the staff role and the bot itself are let in.
  • After a restart. Both views have no timeout and fixed custom IDs, and setup_hook registers them, so the buttons on old messages keep working.
  • The reply is deferred because making a channel can take longer than the three seconds Discord waits for an answer.

discord.js

JavaScript
const {
  ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, Events, GatewayIntentBits, MessageFlags,
  PermissionFlagsBits,
} = require("discord.js");

const STAFF_ROLE_ID = "123456789012345678";       // the role that answers tickets
const TICKET_CATEGORY_ID = "234567890123456789";  // the category new tickets go in, or null

const opening = new Set(); // people whose ticket is being made right now

const oneButton = (id, label, style) =>
  new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(id).setLabel(label).setStyle(style));

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

async function openTicket(interaction) {
  const { guild, user } = interaction;
  const mine = guild.channels.cache.find((c) => c.topic === `ticket:${user.id}`);
  if (mine || opening.has(user.id)) {
    const where = mine ? `${mine}` : "on its way";
    return interaction.reply({ content: `You already have a ticket: ${where}.`, flags: MessageFlags.Ephemeral });
  }
  opening.add(user.id);
  try {
    await interaction.deferReply({ flags: MessageFlags.Ephemeral });
    const talk = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages];
    const permissionOverwrites = [
      { id: guild.roles.everyone, deny: [PermissionFlagsBits.ViewChannel] },
      { id: user, allow: [...talk, PermissionFlagsBits.AttachFiles] },
      { id: guild.members.me, allow: talk },
    ];
    const staff = guild.roles.cache.get(STAFF_ROLE_ID);
    if (staff) permissionOverwrites.push({ id: staff, allow: talk });
    const category = guild.channels.cache.get(TICKET_CATEGORY_ID);
    const channel = await guild.channels.create({
      name: `ticket-${user.username}`,
      type: ChannelType.GuildText,
      topic: `ticket:${user.id}`,
      parent: category?.type === ChannelType.GuildCategory ? category : null,
      permissionOverwrites,
      reason: `Ticket opened by ${user.username}`,
    });
    await channel.send({
      content: `${user}, tell us what is wrong and someone will be with you.`,
      components: [oneButton("tickets:close", "Close", ButtonStyle.Danger)],
    });
    await interaction.editReply(`Your ticket: ${channel}`);
  } finally {
    opening.delete(user.id);
  }
}

async function closeTicket(interaction) {
  if (!interaction.channel.topic?.startsWith("ticket:")) {
    return interaction.reply({ content: "This is not a ticket.", flags: MessageFlags.Ephemeral });
  }
  await interaction.reply("Closing in 5 seconds.");
  setTimeout(() => {
    interaction.channel.delete(`Ticket closed by ${interaction.user.username}`).catch(console.error);
  }, 5000);
}

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand() && interaction.commandName === "ticketpanel") {
    await interaction.channel.send({
      content: "Need help? Open a ticket and talk to the team in private.",
      components: [oneButton("tickets:open", "Open a ticket", ButtonStyle.Primary)],
    });
    await interaction.reply({ content: "Posted.", flags: MessageFlags.Ephemeral });
  } else if (interaction.isButton() && interaction.inCachedGuild()) {
    if (interaction.customId === "tickets:open") await openTicket(interaction);
    if (interaction.customId === "tickets:close") await closeTicket(interaction);
  }
});

client.login(process.env.BOT_TOKEN);

Register /ticketpanel with the deploy script from the slash commands guide, with .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) and .setContexts(InteractionContextType.Guild) so only staff see it.

  • The same opening set stops the double click; without it, the test made two channels for one member.
  • Passing the role, user and member objects as id lets discord.js tell which overwrites are for roles and which for members.
  • The buttons are handled by custom ID in interactionCreate, so they keep working after a restart without anything else.

Before you rely on it

  • Transcripts. Deleting the channel deletes the conversation. To keep one, read the messages before deleting (channel.history() in discord.py, channel.messages.fetch() in discord.js; both need the Message Content intent to see what they say) and save or post them somewhere.
  • Who may close. Anyone who can see the ticket can press Close: the member and your staff. To leave it to staff, check for the staff role in the close handler.
  • Limits. A server holds at most 500 channels, and a category 50, so a busy server needs old tickets closed, or more than one category.

Or use a ready-made one

For transcripts, a claim button, staff statistics and a settings website, Discord Tickets is free and open source. On SnowServers it is one form in the panel's Bot library, next to your own bot on the same server.

Common problems

  • 50013 Missing Permissions when opening: the bot lacks Manage Channels, or one of the permissions it is trying to give.
  • Staff cannot see tickets: the staff role ID is wrong, so the overwrite for it was skipped.
  • "This interaction failed" on old buttons: in discord.py, the views were not added in setup_hook; in either library, the bot was not running.
  • Two tickets for one person: the opening check is missing.

Press / or Ctrl K on any page to search.