Building a Ticket Bot That Survives Restarts
Design a Discord ticket bot whose buttons, open tickets and transcripts survive restarts — using persistent components, a database and startup reconciliation.
On this page
Ticket bots have a particular way of failing. Everything works in testing, then the bot restarts for a deploy and the “Open ticket” button in your support channel stops responding, open tickets are forgotten, and nobody can close them. The cause is almost always the same: state kept in memory instead of somewhere durable.
This article shows how to build a ticket bot that doesn’t care how often it restarts.
Why ticket bots break on restart
A restart wipes everything in memory. If your bot:
- tracks open tickets in a
Mapor dictionary, - relies on component collectors that only live as long as the process,
- or uses buttons that expire,
then a restart — from a deploy, a crash or a plan upgrade — silently breaks it. On a host with automatic restarts and push-to-deploy, restarts are routine, so the design has to assume them.
Principle 1: buttons are routed by custom ID
Discord doesn’t care whether your bot remembers creating a button. When someone clicks it, Discord sends an interaction containing the button’s custom_id. If your bot recognises that ID, the button works — even if it was posted months ago.
So give every long-lived button a stable, meaningful custom ID and handle it in your global interaction handler, not in a collector.
In discord.js:
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, Events } = require('discord.js');
// Posting the panel (run once, e.g. from a /setup command)
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('ticket:open').setLabel('Open ticket').setStyle(ButtonStyle.Primary)
);
await channel.send({ content: 'Need help? Open a ticket.', components: [row] });
// Handling clicks — works across restarts
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isButton()) return;
const [scope, action, id] = interaction.customId.split(':');
if (scope !== 'ticket') return;
if (action === 'open') return openTicket(interaction);
if (action === 'close') return closeTicket(interaction, Number(id));
});
Encoding data in the ID (ticket:close:42) lets one handler serve every ticket. Custom IDs can be up to 100 characters, so keep them short.
In discord.py, the equivalent is a persistent view: set timeout=None, give each item a custom_id, and register the view at startup so the library knows to route those IDs:
class TicketPanel(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(label="Open ticket", style=discord.ButtonStyle.primary, custom_id="ticket:open")
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
await open_ticket(interaction)
class MyBot(commands.Bot):
async def setup_hook(self):
self.add_view(TicketPanel()) # re-attach after every restart
For buttons that carry a per-ticket ID, discord.py 2.4+ offers DynamicItem, which matches custom IDs with a regular expression and rebuilds the item from the ID.
Principle 2: tickets live in a database
Store every ticket as a row. A minimal schema:
CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL UNIQUE,
opener_id BIGINT NOT NULL,
status ENUM('open', 'closed') NOT NULL DEFAULT 'open',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
INDEX (guild_id, status),
INDEX (opener_id, status)
);
That’s MySQL syntax; in PostgreSQL use BIGSERIAL for the ID and a CHECK constraint or enum type for status. Store Discord IDs as BIGINT — they don’t fit in a 32-bit integer — or as strings.
Every operation reads and writes the database: opening a ticket inserts a row, closing updates status and closed_at, and “does this user already have an open ticket?” is a query rather than a lookup in memory. Every Kerit Cloud plan includes a MySQL database, and paid plans back it up daily. For SQLite users, see SQLite vs a managed database for when to switch.
Principle 3: reconcile on startup
While the bot was offline, the world kept moving. A staff member may have deleted a ticket channel by hand, or a server may have removed the bot. On startup, compare the database with reality:
client.once(Events.ClientReady, async () => {
const open = await db.query("SELECT id, channel_id FROM tickets WHERE status = 'open'");
for (const t of open) {
const channel = await client.channels.fetch(String(t.channel_id)).catch(() => null);
if (!channel) {
await db.query("UPDATE tickets SET status = 'closed', closed_at = NOW() WHERE id = ?", [t.id]);
}
}
});
For large bots, do this gradually or per guild as guilds become available, rather than fetching thousands of channels at once.
Transcripts
When a ticket closes, users and staff usually want a record. Fetch the channel’s messages in pages of 100, render them to text or HTML, and store the file:
async function fetchAll(channel) {
const all = [];
let before;
while (true) {
const batch = await channel.messages.fetch({ limit: 100, before });
if (batch.size === 0) break;
all.push(...batch.values());
before = batch.last().id;
}
return all.reverse();
}
Send the transcript file to a log channel or the ticket opener, and record where it went in the database. Save the transcript before deleting the channel — if the bot crashes mid-close, you want the data, not an empty channel list. Note that reading message content requires the Message Content intent for messages that don’t mention the bot.
Make closing idempotent
Two staff members click “Close” at the same moment, or a click lands during a restart and Discord retries. Make the close operation safe to run twice:
UPDATE tickets SET status = 'closed', closed_at = NOW()
WHERE id = ? AND status = 'open';
Check the affected-row count: if it’s zero, the ticket was already closed, so reply politely and do nothing else.
Respond fast, work after
Interactions must be acknowledged within three seconds. Creating a channel, setting permissions and writing to a database can take longer on a busy day. Defer first:
await interaction.deferReply({ ephemeral: true });
// create channel, insert row, set permissions...
await interaction.editReply(`Your ticket: ${channel}`);
Permissions and privacy
Ticket channels often contain personal details, payment questions or reports about other members, so get permissions right from the first message.
Create each ticket channel with explicit permission overwrites rather than inheriting from the category: deny ViewChannel for @everyone, allow it for the ticket opener and your staff role, and allow the bot itself. If you inherit and someone later loosens the category, every ticket becomes visible at once.
Store the staff role ID per guild in your database, set through a /setup command, instead of hard-coding a role name. Role names change; IDs don’t. And never put personal data in custom IDs or channel names — channel names are visible in audit logs and to anyone who later gains access to the category.
When a ticket closes, decide whether to delete the channel or archive it by moving it to a closed category with the opener’s access removed. Deleting is tidier; archiving keeps context for staff. Either way, the transcript you saved is the permanent record.
Scaling to many servers
A ticket bot that serves one community has different needs from one that serves thousands. As you grow:
- Index the queries you run on every click. “Does this user have an open ticket in this guild?” should hit the
(opener_id, status)index, not scan the table. See indexes 101. - Limit open tickets per user. One or two per guild prevents spam and accidental duplicates from double-clicks.
- Watch the channel limit. A Discord server can have at most 500 channels, and a category holds 50. Busy servers should archive or delete closed tickets promptly, or use private threads instead of channels.
- Store transcripts outside the database. Large HTML transcripts bloat a relational database. Keep them as files and store only the location and metadata in the table.
- Plan storage. Transcripts accumulate. Paid plans include more NVMe storage and backups; choose a tier with room to grow — see choosing the right Discord bot plan.
Summary
A restart-proof ticket bot routes buttons by stable custom IDs (persistent views in discord.py), keeps every ticket in a database, reconciles stored state with Discord on startup, saves transcripts before deleting channels, and makes closing idempotent. Build it that way and deploys, crashes and plan upgrades stop being events your users notice.