Build a ticket support system

A button that opens a private ticket channel for each user, a close button that cleans it up, and one-ticket-per-person built in — no database needed, because Discord itself holds the state.

A ticket system turns "please DM me your problem" into a tidy button: a member clicks it, gets a private channel only they and your staff can see, and closes it when they're done. This recipe builds the whole flow in discord.js — a panel with an Open a ticket button, a private channel per user, and a Close button. The neat part: it needs no database, because the state (who has a ticket) lives in the channels themselves.

At a glance
You need A working bot from Host a discord.js bot
Plan Free or premium — no premium features required
Time About twenty minutes

New here? Build the base bot first with Host a discord.js bot, then come back — this recipe assumes you already have index.js, .env, and a bot that logs in.

The whole bot

Here's the complete index.js. It registers one command, /ticketpanel, which posts the button panel; everything else is button handling.

require('dotenv').config();
const {
  Client, GatewayIntentBits, Events,
  ChannelType, PermissionFlagsBits,
  ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} = require('discord.js');

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

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set([
    { name: 'ticketpanel', description: 'Post the ticket panel' },
  ]);
  console.log(`Listening as ${c.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand() && interaction.commandName === 'ticketpanel') {
    const panel = new EmbedBuilder()
      .setTitle('Need help?')
      .setDescription('Click the button below to open a private ticket.')
      .setColor(0x5865f2);
    const row = new ActionRowBuilder().addComponents(
      new ButtonBuilder().setCustomId('ticket:open').setLabel('Open a ticket').setStyle(ButtonStyle.Primary).setEmoji('🎫'),
    );
    await interaction.reply({ embeds: [panel], components: [row] });
    return;
  }

  if (!interaction.isButton()) return;

  if (interaction.customId === 'ticket:open') {
    const existing = interaction.guild.channels.cache.find((ch) => ch.topic === `ticket:${interaction.user.id}`);
    if (existing) {
      await interaction.reply({ content: `You already have a ticket open: ${existing}`, ephemeral: true });
      return;
    }
    const channel = await interaction.guild.channels.create({
      name: `ticket-${interaction.user.username}`,
      type: ChannelType.GuildText,
      topic: `ticket:${interaction.user.id}`,
      permissionOverwrites: [
        { id: interaction.guild.roles.everyone, deny: [PermissionFlagsBits.ViewChannel] },
        { id: interaction.user.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages] },
        { id: client.user.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages] },
      ],
    });
    const closeRow = new ActionRowBuilder().addComponents(
      new ButtonBuilder().setCustomId('ticket:close').setLabel('Close ticket').setStyle(ButtonStyle.Danger).setEmoji('🔒'),
    );
    await channel.send({ content: `Hi ${interaction.user}, a staff member will be with you soon.`, components: [closeRow] });
    await interaction.reply({ content: `Your ticket is ready: ${channel}`, ephemeral: true });
    return;
  }

  if (interaction.customId === 'ticket:close') {
    await interaction.reply('Closing this ticket in 5 seconds…');
    setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
  }
});

client.login(process.env.DISCORD_TOKEN);

Save, restart, and run /ticketpanel in a channel. Click the button and a private ticket-yourname channel appears.

How it works

  • Custom IDs route the buttons. Every button carries a customId (ticket:open, ticket:close). The one InteractionCreate handler checks that ID and acts. Because the ID is baked into the button — not stored in memory — the panel keeps working after a restart. That's why this recipe needs no database.
  • The channel's topic is the record. When a ticket opens, its topic is set to ticket:<userId>. The "do you already have one?" check just searches channels for that topic. Discord stores it, so it survives restarts and even a reinstall.
  • Permission overwrites make it private. @everyone is denied ViewChannel; the opener and the bot are allowed. Nobody else can see the channel.

Let staff see tickets

The code above lets in the opener and the bot — add your staff role so your team can read and reply. Put the role's ID in the overwrites:

{ id: 'YOUR_STAFF_ROLE_ID', allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages] },

💡 Tip: Right-click a role (with Developer Mode on) to Copy ID. To stop random members from posting the panel, restrict the command: new SlashCommandBuilder().setName('ticketpanel').setDescription('…').setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild).

🎯 Good to know: The bot needs the Manage Channels permission in the server to create and delete ticket channels. Invite it with that permission, or grant its role Manage Channels.

Verify it works

Run /ticketpanel, click Open a ticket, and confirm a private channel is created that only you (and staff, once added) can see. Click Close ticket and it deletes itself after five seconds. Click Open again while a ticket is open and you should get "You already have a ticket open."

Troubleshooting

Symptom Fix
"Missing Permissions" when opening The bot lacks Manage Channels. Grant it to the bot's role, or re-invite with that permission.
Staff can't see tickets Add your staff role to permissionOverwrites (above) with ViewChannel.
Button does nothing The InteractionCreate handler must be running and the customId must match exactly. Check the console for errors.
TokenInvalid on start The token in .env is wrong or was reset — see Bot appears offline.

⚠️ Heads up: This recipe deletes the channel — and its whole conversation — on close. If you want a saved transcript, fetch the messages before deleting and post them to a log channel. Building a message logger? See Build a logging bot.


Next steps

Was this guide helpful?