NapNodes

← Discord bot guides

Discord bots · updated 24 October 2026

How to Make a Discord Ticket Bot in Python (discord.py)

A ticket bot puts a button in a channel: whoever clicks it gets a private channel that only they and your team can see, and it's deleted when closed. It's what nearly every community Discord server uses for support, reports or staff applications. Here you build it yourself in Python, in a single file, with no ads and no paid features.

What this bot does

An admin types /ticket-panel in the channel they want and the bot posts a message with the “Open ticket” button. When someone clicks it, the bot creates a ticket-name channel inside the Tickets category, visible only to that person, your team's role and the bot itself. Inside there's a “Close ticket” button that deletes the channel after 5 seconds.

Each person can only have one ticket open: if they click again, the bot shows them the one they already have. And the buttons keep working even if the bot restarts, which is exactly what breaks in most of the examples going around.

Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.

1. Set up the bot and your Discord server

  1. Create the application and get the token

    Go to discord.com/developers/applications, “New Application”; in the “Bot” tab, “Reset Token” and copy it. If this is your first bot, the Python Discord bot guide explains it more slowly.

  2. Invite it with just the permissions it needs

    In “OAuth2” → “URL Generator” tick “bot” and “applications.commands” and, under permissions, “Manage Channels”, “View Channels”, “Send Messages”, “Embed Links”, “Attach Files” and “Read Message History”. Without “Manage Channels” it can't create or delete the ticket channels.

  3. Create the team role

    On your Discord server, create a role called Support (or change the name in the code) and give it to whoever will handle tickets. Admins see every channel anyway. So the role gets pinged on every new ticket, turn on “Allow anyone to @mention this role” in its settings.

2. The code

Three files in one folder: requirements.txt with the libraries, .env with the token (no quotes) and app.py with the bot. The category name and the role name are set at the top of app.py.

requirements.txt
discord.py
python-dotenv
.env
DISCORD_TOKEN=paste-your-token-here
app.py
import asyncio
import os

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()  # reads the token from the .env file

CATEGORY = "Tickets"  # the category they open in (created automatically)
STAFF_ROLE = "Support"  # the role that handles tickets: create it on your server


class Close(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)  # never expires: keeps working after the bot restarts

    @discord.ui.button(label="Close ticket", emoji="🔒", style=discord.ButtonStyle.danger, custom_id="ticket:close")
    async def close(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_message("The ticket closes in 5 seconds.")
        await asyncio.sleep(5)
        await interaction.channel.delete(reason=f"Ticket closed by {interaction.user}")


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

    @discord.ui.button(label="Open ticket", emoji="🎫", style=discord.ButtonStyle.primary, custom_id="ticket:open")
    async def open(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.defer(ephemeral=True, thinking=True)
        guild = interaction.guild
        category = discord.utils.get(guild.categories, name=CATEGORY) or await guild.create_category(CATEGORY)

        # One per person: the channel topic stores the id of whoever opened it.
        for channel in category.text_channels:
            if channel.topic == str(interaction.user.id):
                await interaction.followup.send(f"You already have one open: {channel.mention}", ephemeral=True)
                return

        overwrites = {
            guild.default_role: discord.PermissionOverwrite(view_channel=False),
            interaction.user: discord.PermissionOverwrite(view_channel=True, send_messages=True, attach_files=True),
            guild.me: discord.PermissionOverwrite(view_channel=True, send_messages=True, manage_channels=True),
        }
        staff = discord.utils.get(guild.roles, name=STAFF_ROLE)
        if staff:
            overwrites[staff] = discord.PermissionOverwrite(view_channel=True, send_messages=True, attach_files=True)

        channel = await guild.create_text_channel(
            f"ticket-{interaction.user.name}", category=category, overwrites=overwrites, topic=str(interaction.user.id)
        )
        ping = staff.mention if staff else ""
        await channel.send(f"{interaction.user.mention} {ping} Tell us what's going on. Once it's sorted, click the button.", view=Close())
        await interaction.followup.send(f"Ticket opened: {channel.mention}", ephemeral=True)


class Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        self.add_view(Open())  # listens again to the buttons already posted
        self.add_view(Close())
        await self.tree.sync()  # publishes /ticket-panel


bot = Bot()


@bot.tree.command(name="ticket-panel", description="Posts the button to open tickets in this channel")
@app_commands.default_permissions(manage_guild=True)
@app_commands.guild_only()
async def ticket_panel(interaction: discord.Interaction):
    embed = discord.Embed(
        title="Need help?",
        description="Click the button and a private channel between you and the team will open.",
        color=0x5B4AE8,
    )
    await interaction.channel.send(embed=embed, view=Open())
    await interaction.response.send_message("Done: the ticket panel is posted.", ephemeral=True)


@bot.event
async def on_ready():
    print(f"Logged in as {bot.user}")


bot.run(os.environ["DISCORD_TOKEN"])

Why the buttons keep working after a restart

A Discord button stores nothing: when someone clicks it, Discord tells the bot the button's identifier (custom_id) and the bot decides what to do. If the bot restarts and doesn't know what to do with that identifier, Discord shows that the interaction failed.

That's why both views have timeout=None (they don't expire after 3 minutes, which is what they do by default), each button has a fixed custom_id and setup_hook registers them with add_view at startup. With that, the “Open ticket” message you posted a month ago keeps working after every bot update.

The bot doesn't keep a list of open tickets either: what ties each channel to a person is the channel topic, where it writes the id of whoever opened it. So it needs no database and nothing is lost on restart.

3. Try it

With Python 3.10 or newer, in the folder: “pip install -r requirements.txt” and “python app.py”. When it says “Logged in as…”, go to the channel where you want the button (for example, #support) and type /ticket-panel. Only people who can manage the server see the command; the first time it can take a few minutes to show up.

Click “Open ticket” with an account that has no role: a new channel should appear in the Tickets category that this account can see and the rest can't. If you get a permissions error (Missing Permissions in the console), the bot is missing “Manage Channels” or one of the permissions it gives itself in the channel.

Taking it further

Several ticket types: instead of one button, add several (Support, Reports, Staff application) each with its own custom_id, or a dropdown menu (discord.ui.Select), and use a different category or role for each.

Saving the conversation before deleting: go through the messages with channel.history(oldest_first=True), write them to a .txt and send it to a log channel with discord.File. To read the text of the messages, the bot needs the “Message Content” intent: turn it on in the portal's “Bot” tab and add intents.message_content = True in the code.

Only the team can close: in close(), check whether interaction.user has the role before deleting the channel and, if not, reply with a message only they can see (ephemeral=True).

4. Keep it online all the time (for free)

A ticket bot has to be connected when someone clicks the button: if it runs on your computer and you turn it off, nobody can open tickets. To keep it online all the time it has to be hosted on a server.

On NapNodes: create the free bot, switch it to Python in “Settings” → “Language” (it starts out on Node.js), upload the three files in “Files” (or set your GitHub repository, without the .env) and click “Start”. The libraries in requirements.txt install themselves at startup and app.py is the startup file that comes set.

The free bot has 256 MB, more than enough for this bot even if your Discord server is big, and you only have to renew it with one click every week. It doesn't need any open port: it's the bot that connects to Discord.

Frequently asked questions

How do I make a ticket system on Discord?

With a bot that posts a button and, when clicked, creates a private channel between that person and your team. With discord.py it's about 90 lines in a single file, and you can keep it online for free.

Why does my bot's button say the interaction failed?

Because the bot is offline or, after restarting, doesn't know what to do with that button. Views need timeout=None, a fixed custom_id on each button, and to be registered with add_view at startup.

What permissions does a ticket bot need?

“Manage Channels” to create and delete the channels, plus “View Channels”, “Send Messages”, “Embed Links”, “Attach Files” and “Read Message History” to write in them.

Can I save the ticket conversation before closing it?

Yes: go through channel.history(), write it to a .txt and send it to a log channel. To read the text of the messages, turn on the “Message Content” intent in the Developer Portal.

Shall we set it up for you?

Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.

Keep reading

All Discord bot guides, sorted →