Only show guides for your app
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.
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.
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.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.@everyone is denied ViewChannel; the opener and the bot are allowed. Nobody else can see the channel.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.
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."
| 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.