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 oneInteractionCreatehandler 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
topicis the record. When a ticket opens, its topic is set toticket:<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.
@everyoneis deniedViewChannel; 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.