To make a custom ticket bot on Discord, you create a bot application, post a message with an "Open a ticket" button, and have the bot answer each click by creating a private channel that only that member and your staff role can see. A Close button in the ticket saves the conversation as a transcript and deletes the channel. With discord.js v14 that is about 270 lines of JavaScript in four files, and this guide gives you all of them.
If you only need a standard support panel, you don't have to write code: Ticket Tool and Tickets both have free tiers with ticket panels and transcripts (as of September 2026). Build your own when tickets need to use your own data, follow your own routing rules, or when you want to own the code and the transcripts.
I tested every file on this page on 27 September 2026 with discord.js 14.27.0 on Node.js 24.15.0. How I tested it is described further down.
The custom ticket bot you'll build
- A
/ticket-panelslash command that posts a panel with an Open a ticket button. Only members with Manage Server see the command by default. - A button handler that creates
#ticket-<username>in a tickets category, visible only to that member, your staff role and the bot, and stops members from opening a second ticket. - A Close ticket button that the owner or any staff member can press. It builds a plain-text transcript of the whole channel, posts it to a staff-only log channel, sends a copy to the member by DM, then deletes the channel.
- Configuration in a
.envfile, so no IDs or tokens are hard-coded.
Later sections add a "reason" form in a pop-up modal and show the private-thread version of the open step.
Private channel or private thread?
I use a private channel per ticket in this guide, because the permission model is explicit (one overwrite for the member, one for the staff role) and staff see every open ticket in one category. Threads are the better choice when you expect many open tickets at once.
| Private channel per ticket | Private thread per ticket | |
|---|---|---|
| Server limits | Counts toward the 500-channel cap (categories included), and one category holds at most 50 channels | Threads don't count toward the channel cap; Discord has a separate server-wide limit on active threads |
| Who can see it | Whoever the permission overwrites allow | Members added to the thread, plus anyone with Manage Threads on the parent channel |
| Giving a staff role access | One role overwrite | Give the role Manage Threads on the parent channel, which also lets them archive and delete any thread there, or add staff one by one |
| Where it shows | Listed under the tickets category | Tucked under the parent channel, and hidden after 1 hour, 24 hours, 3 days or 7 days without activity |
| Bot permission to create it | Manage Channels | Create Private Threads |
One more thread detail: if you @mention a role inside a private thread, Discord only adds that role's members when the role has fewer than 100 members. A small staff role fits; a large helper role may not.
Before you start
You need Node.js 22 or newer (the --env-file flag used below has existed since Node 20.6 and is marked stable from 22.21 and 24.10), a Discord server where you have Manage Server, and a code editor. If you have never made a bot before, my discord.js getting-started guide walks through the basics in more detail.
Step 1: Create the bot and turn on Message Content
- Open the Discord Developer Portal, click New Application and give it a name.
- On General Information, copy the Application ID. That is your
CLIENT_ID. - On the Bot page, click Reset Token and copy the token. That is your
DISCORD_TOKEN. Anyone with it controls your bot, so it only ever goes in.env. - On the same page, under Privileged Gateway Intents, turn on Message Content Intent.
The transcript needs that last step. Without the Message Content intent, Discord returns empty content, embeds and attachments for other people's messages, and that applies to the HTTP API the bot uses to read history, not only to live events. Messages the bot sent itself, and messages that mention it, are exempt. Apps seen by fewer than 10,000 users just flip the switch; above that, Discord reviews your access.
The bot doesn't need to list Message Content in its code. Discord's docs say HTTP restrictions are independent of the intents a bot sends when it connects, so GatewayIntentBits.Guilds is the only intent in index.js. Server Members and Presence stay off.
Step 2: Invite it with the right permissions
The bot needs these six permissions, and nothing more. Administrator would work too, but a ticket bot has no reason to hold it.
| Permission | Why the ticket bot needs it |
|---|---|
| Manage Channels | Create and delete ticket channels |
| View Channels | See the panel, ticket and log channels |
| Send Messages | Post the panel, the welcome message and the transcript |
| Embed Links | Send embeds |
| Attach Files | Upload the transcript file |
| Read Message History | Read the ticket's messages for the transcript |
Together they make the permissions integer 117776. Build the invite link in the permissions calculator, or use this URL with your application ID:
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&scope=bot&permissions=117776You don't need to add the applications.commands scope separately: Discord includes it with the bot scope.
When a bot creates a channel with permission overwrites, it can only allow or deny permissions it has itself in the server. The ticket overwrites grant View Channels, Send Messages, Read Message History, Attach Files and Embed Links, so the bot's role must have all five, or Discord refuses with Missing Permissions.
Step 3: Prepare the server and copy the IDs
Create three things in your server:
- A Staff role for the people who answer tickets. If you want the staff ping in each new ticket to notify them, turn on "Allow anyone to @mention this role" in the role's settings.
- A Tickets category. New tickets are created inside it.
- A staff-only text channel for transcripts, for example
#ticket-transcripts. Make sure the bot can see it.
Then turn on Developer Mode (User Settings, then Advanced, then Developer Mode) so you can right-click things and copy their IDs: the server icon for GUILD_ID, the category for TICKET_CATEGORY_ID, the log channel for TRANSCRIPT_CHANNEL_ID, and the Staff role in Server Settings, Roles for STAFF_ROLE_ID.
Step 4: Set up the project
Create a folder called ticket-bot with this package.json, then run npm install in it.
{
"name": "ticket-bot",
"private": true,
"type": "commonjs",
"scripts": {
"deploy": "node --env-file=.env deploy-commands.js",
"start": "node --env-file=.env index.js"
},
"dependencies": {
"discord.js": "^14.27.0"
}
}The two scripts use Node's built-in --env-file flag to load .env, so you don't need the dotenv package. Create .env from this template and fill in your values:
DISCORD_TOKEN=your-bot-token
CLIENT_ID=your-application-id
GUILD_ID=your-server-id
STAFF_ROLE_ID=role-that-answers-tickets
TICKET_CATEGORY_ID=category-new-tickets-go-in
TRANSCRIPT_CHANNEL_ID=staff-only-channel-for-transcriptsAdd .env and node_modules to a .gitignore before your first commit. A token pushed to a public repository can be copied and used by anyone who finds it.
Step 5: Load and check the config
config.js reads the six values and stops with a clear error if one is missing, instead of failing later with an unhelpful Discord error.
const required = [
'DISCORD_TOKEN',
'CLIENT_ID',
'GUILD_ID',
'STAFF_ROLE_ID',
'TICKET_CATEGORY_ID',
'TRANSCRIPT_CHANNEL_ID',
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing from .env: ${missing.join(', ')}`);
}
module.exports = {
token: process.env.DISCORD_TOKEN,
clientId: process.env.CLIENT_ID,
guildId: process.env.GUILD_ID,
staffRoleId: process.env.STAFF_ROLE_ID,
ticketCategoryId: process.env.TICKET_CATEGORY_ID,
transcriptChannelId: process.env.TRANSCRIPT_CHANNEL_ID,
};Step 6: Write the ticket logic
This is the whole ticket system: the panel command, opening, closing and the transcript. The notes after it explain the less obvious decisions.
const {
ActionRowBuilder,
AttachmentBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
EmbedBuilder,
MessageFlags,
OverwriteType,
PermissionFlagsBits,
SlashCommandBuilder,
} = require('discord.js');
const config = require('./config');
const OPEN_ID = 'ticket:open';
const CLOSE_ID = 'ticket:close';
const OWNER_PREFIX = 'ticket-owner:';
// What the member, the staff role and the bot can do inside a ticket channel.
const TICKET_PERMISSIONS = [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.AttachFiles,
PermissionFlagsBits.EmbedLinks,
];
// Guards against double clicks: one ticket being created per user,
// one close in progress per channel.
const opening = new Set();
const closing = new Set();
const panelCommand = new SlashCommandBuilder()
.setName('ticket-panel')
.setDescription('Post the ticket panel in this channel')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
async function sendPanel(interaction) {
const embed = new EmbedBuilder()
.setTitle('Need help from staff?')
.setDescription('Press the button to open a private channel that only you and the staff team can see.')
.setColor(0x5865f2);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(OPEN_ID).setLabel('Open a ticket').setStyle(ButtonStyle.Primary),
);
await interaction.channel.send({ embeds: [embed], components: [row] });
await interaction.reply({ content: 'Ticket panel posted.', flags: MessageFlags.Ephemeral });
}
// The opener's user ID lives in the channel topic, so the bot needs no database
// and still knows who owns each ticket after a restart.
function ticketOwnerId(channel) {
const topic = channel?.topic ?? '';
return topic.startsWith(OWNER_PREFIX) ? topic.slice(OWNER_PREFIX.length) : null;
}
function findOpenTicket(guild, userId) {
return guild.channels.cache.find(
(channel) => channel.parentId === config.ticketCategoryId && ticketOwnerId(channel) === userId,
);
}
async function openTicket(interaction, reason) {
const { guild, user } = interaction;
const existing = findOpenTicket(guild, user.id);
if (existing) {
await interaction.reply({ content: `You already have an open ticket: ${existing}`, flags: MessageFlags.Ephemeral });
return;
}
if (opening.has(user.id)) {
await interaction.reply({ content: 'Your ticket is already being created.', flags: MessageFlags.Ephemeral });
return;
}
opening.add(user.id);
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const channel = await guild.channels.create({
name: `ticket-${user.username}`,
type: ChannelType.GuildText,
parent: config.ticketCategoryId,
topic: `${OWNER_PREFIX}${user.id}`,
reason: `Ticket opened by ${user.username}`,
permissionOverwrites: [
// The @everyone role has the same ID as the server.
{ id: guild.id, type: OverwriteType.Role, deny: [PermissionFlagsBits.ViewChannel] },
{ id: config.staffRoleId, type: OverwriteType.Role, allow: TICKET_PERMISSIONS },
{ id: user.id, type: OverwriteType.Member, allow: TICKET_PERMISSIONS },
{ id: interaction.client.user.id, type: OverwriteType.Member, allow: TICKET_PERMISSIONS },
],
});
const embed = new EmbedBuilder()
.setTitle('Ticket opened')
.setDescription(reason ? `Reason: ${reason}` : 'Describe your issue here and a staff member will reply.')
.setColor(0x5865f2);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(CLOSE_ID).setLabel('Close ticket').setStyle(ButtonStyle.Danger),
);
await channel.send({
content: `${user} <@&${config.staffRoleId}>`,
embeds: [embed],
components: [row],
allowedMentions: { users: [user.id], roles: [config.staffRoleId] },
});
await interaction.editReply(`Your ticket is open: ${channel}`);
} finally {
opening.delete(user.id);
}
}
async function fetchAllMessages(channel) {
const messages = [];
let before;
while (true) {
const batch = await channel.messages.fetch({ limit: 100, before, cache: false });
messages.push(...batch.values());
if (batch.size < 100) break;
before = batch.lastKey();
}
return messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp);
}
async function buildTranscript(channel, ownerId, closedBy) {
const messages = await fetchAllMessages(channel);
const lines = [
`Transcript of #${channel.name}`,
`Opened by user ID: ${ownerId}`,
`Closed by: ${closedBy.username} (${closedBy.id})`,
`Messages: ${messages.length}`,
'',
];
for (const message of messages) {
const time = message.createdAt.toISOString().replace('T', ' ').slice(0, 19);
lines.push(`[${time} UTC] ${message.author.username}: ${message.content}`);
for (const embed of message.embeds) {
lines.push(` [embed] ${embed.title ?? ''} ${embed.description ?? ''}`.trimEnd());
}
for (const attachment of message.attachments.values()) {
lines.push(` [file] ${attachment.name} ${attachment.url}`);
}
}
return lines.join('\n');
}
async function closeTicket(interaction) {
const { channel, member, user } = interaction;
const ownerId = ticketOwnerId(channel);
if (!ownerId) {
await interaction.reply({ content: 'This channel is not a ticket.', flags: MessageFlags.Ephemeral });
return;
}
const isStaff = member.roles.cache.has(config.staffRoleId);
if (user.id !== ownerId && !isStaff) {
await interaction.reply({ content: 'Only the ticket owner or staff can close this ticket.', flags: MessageFlags.Ephemeral });
return;
}
if (closing.has(channel.id)) {
await interaction.reply({ content: 'This ticket is already closing.', flags: MessageFlags.Ephemeral });
return;
}
closing.add(channel.id);
try {
await interaction.reply(`Ticket closed by ${user}. Saving the transcript, then this channel will be deleted.`);
const transcript = await buildTranscript(channel, ownerId, user);
const file = new AttachmentBuilder(Buffer.from(transcript, 'utf8'), { name: `${channel.name}.txt` });
const logChannel = await interaction.client.channels.fetch(config.transcriptChannelId);
await logChannel.send({
content: `Transcript of #${channel.name}, opened by <@${ownerId}>, closed by ${user}`,
files: [file],
allowedMentions: { parse: [] },
});
// Send the member a copy. This fails when they don't accept DMs, which is fine.
try {
const owner = await interaction.client.users.fetch(ownerId);
await owner.send({ content: `Transcript of your ticket in ${interaction.guild.name}:`, files: [file] });
} catch {
console.log(`Could not DM the transcript to ${ownerId}`);
}
await channel.delete(`Ticket closed by ${user.username}`);
} finally {
closing.delete(channel.id);
}
}
module.exports = {
OPEN_ID,
CLOSE_ID,
panelCommand,
sendPanel,
findOpenTicket,
openTicket,
closeTicket,
};What's going on in there:
- The owner lives in the channel topic.
ticket-owner:<user id>in the topic tells the bot who opened each ticket, so it needs no database and still knows after a restart, because Discord sends every channel, topic included, when the bot connects. The catch: a staff member who edits the topic breaks that ticket's ownership. - The @everyone overwrite uses the server ID. The @everyone role always has the same ID as the server. Denying it View Channels hides the ticket from everyone, and the three allow overwrites let the member, the staff role and the bot back in. The bot needs its own overwrite: the @everyone deny applies to the bot too, and Discord's rule is that without View Channels you implicitly lose every other permission in that channel.
- Overwrites say whether the ID is a role or a member. Without
type, discord.js has to find the ID in its cache to work that out, and throwsSupplied parameter is not a cached User or Role.when it can't. - It defers before creating the channel. Discord gives a bot 3 seconds to acknowledge an interaction.
deferReplyacknowledges it at once and buys 15 minutes to create the channel and answer witheditReply. - Two in-memory sets stop double clicks. Without
opening, a member who double-clicks gets two tickets, because the first channel doesn't exist yet when the second click arrives. allowedMentionscontrols who gets pinged. The welcome message pings exactly the member and the staff role. The transcript post pings nobody, even though it contains a mention.- The transcript reads history in pages of 100. That is the most Discord returns per request.
cache: falsekeeps a long ticket from filling the bot's memory, and discord.js waits out rate limits for you. - Attachments are listed as links. Discord's attachment URLs are signed and expire, so an old transcript's file links will stop working. The production fix is further down.
The buttons keep working after a restart because their custom IDs are fixed strings handled by one global listener in index.js, not by a collector that lives in memory. A panel you post today still works next year.
Step 7: Register the slash command
Slash commands are registered with Discord once, separately from running the bot. This script registers /ticket-panel in your server only. Guild commands update instantly, which is what you want for a one-server bot. Note that this call replaces all of the application's commands in that server, so add any other commands to the same body array. My slash commands guide covers global commands and permissions in more depth.
const { REST, Routes } = require('discord.js');
const config = require('./config');
const { panelCommand } = require('./tickets');
const rest = new REST().setToken(config.token);
rest
.put(Routes.applicationGuildCommands(config.clientId, config.guildId), {
body: [panelCommand.toJSON()],
})
.then((commands) => console.log(`Registered ${commands.length} command(s) in your server.`))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) in tickets.js hides the command from members without Manage Server. Admins can change who sees it under Server Settings, Integrations.
Step 8: Wire up the events
index.js logs in with only the Guilds intent and routes each interaction to the right function. If anything throws, the member gets a short private error instead of Discord's "This interaction failed".
const { Client, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
const config = require('./config');
const tickets = require('./tickets');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
try {
if (interaction.isChatInputCommand() && interaction.commandName === tickets.panelCommand.name) {
await tickets.sendPanel(interaction);
} else if (interaction.isButton() && interaction.customId === tickets.OPEN_ID) {
await tickets.openTicket(interaction);
} else if (interaction.isButton() && interaction.customId === tickets.CLOSE_ID) {
await tickets.closeTicket(interaction);
}
} catch (error) {
console.error(error);
const message = { content: 'Something went wrong. Please ask a staff member.', flags: MessageFlags.Ephemeral };
if (interaction.deferred || interaction.replied) {
await interaction.followUp(message).catch(console.error);
} else {
await interaction.reply(message).catch(console.error);
}
}
});
client.login(config.token);Step 9: Run it
npm run deploy
npm startThe first command prints Registered 1 command(s) in your server. The second prints Logged in as followed by your bot's name. Then, in Discord:
- Go to your support channel and run
/ticket-panel. The panel appears and you get a private "Ticket panel posted." confirmation. - Click Open a ticket from a test account without admin rights (Administrator overrides channel permissions, so an admin sees every ticket anyway). A
#ticket-<username>channel appears in the Tickets category with a welcome message and a Close ticket button. - Click Open a ticket again. The bot links to the existing ticket instead of opening a second one.
- Send a few messages and an image, then click Close ticket. The transcript lands in your log channel as
ticket-<username>.txt, a copy arrives by DM, and the channel is deleted.
A transcript looks like this:
Transcript of #ticket-member
Opened by user ID: 1553707808325636103
Closed by: staffer (1553707816714244105)
Messages: 131
[2026-09-27 10:00:18 UTC] ticketbot: <@1553707808325636103> <@&1553707783159812097>
[embed] Ticket opened Describe your issue here and a staff member will reply.
[2026-09-27 10:00:29 UTC] member: message 1
[2026-09-27 10:00:30 UTC] staffer: message 2
...To keep the bot running when your computer is off, put it on a host. My guide to hosting a Discord bot 24/7 compares the free and paid options.
How I tested this code
I can't log in to Discord from a test script without a real token, so I tested the published files unchanged against a fake gateway. The harness replaces client.login and every Discord HTTP request with a recorder, then feeds interaction payloads shaped like Discord's into discord.js's own interaction handling. That way the real builders, permission resolution, message fetching and attachment handling all run.
With discord.js 14.27.0 on Node.js 24.15.0, these cases passed:
| Case | What was checked |
|---|---|
npm run deploy |
One PUT to the guild commands route; /ticket-panel with default_member_permissions of 32 (Manage Server) |
/ticket-panel |
Panel posted with the ticket:open button; private confirmation |
| Open a ticket | Deferred first; channel created in the category with the owner topic and all four overwrites (deny 1024 for @everyone, allow 117760 for staff, member and bot); welcome message pings only the member and staff role |
| Second click | Private "You already have an open ticket" reply, no new channel |
| Double click | Two clicks at once create exactly one channel |
| Close by a stranger | Refused; nothing fetched or deleted |
| Close by staff, 131 messages | Two history pages fetched; transcript in chronological order with embeds and files; posted to the log channel without pings; DM copy sent; channel deleted last |
| Close by owner, DMs closed | Transcript still posted and channel still deleted |
| Missing Permissions on create | Error logged; member gets the private error message |
| Reason form (next section) | Modal sent; submitted reason appears in the ticket and the transcript |
| Private thread snippet | Creates a private, non-invitable thread and adds the member |
No deprecation warnings were printed. What this doesn't prove is how Discord's own servers respond to your particular server setup, which is what the troubleshooting table below is for.
Add a reason form (modal)
Staff answer faster when the first message says what the ticket is about. A modal asks before the channel exists. In tickets.js, extend the require at the top:
const {
ActionRowBuilder,
AttachmentBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
EmbedBuilder,
LabelBuilder,
MessageFlags,
ModalBuilder,
OverwriteType,
PermissionFlagsBits,
SlashCommandBuilder,
TextInputBuilder,
TextInputStyle,
} = require('discord.js');Add the form's ID next to the other two:
const FORM_ID = 'ticket:form';Add this function above openTicket. It checks for an open ticket first, so nobody fills in a form for nothing:
async function showTicketForm(interaction) {
const existing = findOpenTicket(interaction.guild, interaction.user.id);
if (existing) {
await interaction.reply({ content: `You already have an open ticket: ${existing}`, flags: MessageFlags.Ephemeral });
return;
}
const modal = new ModalBuilder()
.setCustomId(FORM_ID)
.setTitle('Open a ticket')
.addLabelComponents(
new LabelBuilder()
.setLabel('What do you need help with?')
.setTextInputComponent(
new TextInputBuilder()
.setCustomId('reason')
.setStyle(TextInputStyle.Paragraph)
.setMinLength(10)
.setMaxLength(1000),
),
);
await interaction.showModal(modal);
}Add FORM_ID and showTicketForm to module.exports. Then, in index.js, make the open button show the form, and open the ticket when the form comes back:
if (interaction.isChatInputCommand() && interaction.commandName === tickets.panelCommand.name) {
await tickets.sendPanel(interaction);
} else if (interaction.isButton() && interaction.customId === tickets.OPEN_ID) {
await tickets.showTicketForm(interaction);
} else if (interaction.isModalSubmit() && interaction.customId === tickets.FORM_ID) {
await tickets.openTicket(interaction, interaction.fields.getTextInputValue('reason'));
} else if (interaction.isButton() && interaction.customId === tickets.CLOSE_ID) {
await tickets.closeTicket(interaction);
}openTicket already takes the reason as its second argument and puts it in the welcome embed, so nothing else changes. The form uses Discord's Label component, which is how current modals are built. Discord's docs mark the older pattern, a text input inside an action row, as deprecated. A modal holds up to five components, so you can ask for an order number, a match ID or a screenshot upload in the same form.
If you'd rather use private threads
To open tickets as private threads under the panel channel instead of channels in a category, the create step looks like this:
const { ChannelType } = require('discord.js');
async function openTicketThread(interaction) {
const { user } = interaction;
const thread = await interaction.channel.threads.create({
name: `ticket-${user.username}`,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket opened by ${user.username}`,
});
await thread.members.add(user.id);
return thread;
}
module.exports = { openTicketThread };invitable: false stops members from adding other members. The member you add needs Send Messages in Threads on the panel channel, and your staff role needs Manage Threads there to see every ticket. In the invite permissions, swap Manage Channels for Create Private Threads, Send Messages in Threads and Manage Threads. The rest of the bot (owner lookup, closing and deleting) has to be adapted too, because a thread has no topic: store the owner in the thread name or in a small database instead.
Troubleshooting
| What you see | Likely cause and fix |
|---|---|
| "This interaction failed" in Discord | The bot isn't running, or it didn't answer within 3 seconds. Check the terminal for an error. |
DiscordAPIError[50013]: Missing Permissions when opening a ticket |
The bot's role lacks one of the six permissions, or the Tickets category denies it View Channels or Manage Channels. |
DiscordAPIError[50001]: Missing Access |
The bot can't see the channel it's posting to, usually the transcript channel. |
/ticket-panel doesn't appear |
npm run deploy wasn't run, GUILD_ID is wrong, or your account lacks Manage Server. |
| Transcript lines show names but no text | Message Content Intent is off in the Developer Portal. |
| The staff role is mentioned but nobody is notified | The role isn't set to "Allow anyone to @mention this role". |
| You can see every ticket, including other people's | Your account has Administrator, which overrides channel overwrites. Test with a normal member account. |
Missing from .env: ... on start |
A value is empty, or you started the bot with node index.js instead of npm start. |
For errors not listed here, my bot troubleshooting guide covers the general cases.
What a production ticket bot adds
This bot is deliberately small. These are the things I add when a server depends on its tickets:
- A database. Ticket numbers, who claimed what, first-response times, reopen after close, and an audit log that survives when the channel is deleted. The topic trick doesn't scale past that.
- HTML transcripts that keep their files. The open-source
discord-html-transcriptspackage (Apache-2.0) renders a channel as an HTML page that looks like Discord, and itssaveImagesoption embeds images, so they outlive Discord's expiring attachment links. - Ticket types and routing. A select menu on the panel (up to 25 options), a category and staff role per type, and a claim button so two people don't answer the same ticket.
- Adding and removing people. A
/addcommand edits the ticket's permission overwrites, which requires the Manage Roles permission. - Overflow handling. One category holds 50 channels, so a busy server needs overflow categories or threads.
- Reminders and auto-close. Ping the on-call role when a ticket waits too long, and close tickets where the member went quiet.
- Integrations. A row in Google Sheets per ticket, a copy in your helpdesk, or an order or account lookup from your own API when the ticket opens.
If you'd rather have that built and hosted for you, I build custom Discord ticket bots on your own bot account, with the code handed over. The ticket bot I built for an esports tournament organizer had support, match and custom tickets, HTML transcripts and a full action log. Or send a short brief and I'll come back with a fixed quote.