Build a giveaway bot

Timed giveaways with an Enter button and a random winner — saved to SQLite so a restart mid-giveaway doesn't lose the timer or the entries.

A giveaway bot posts a prize, collects entrants through a button, waits out a timer, and picks a random winner. The tricky part isn't the button — it's the timer. If your bot restarts while a giveaway is running (a redeploy, a session timer expiring), a giveaway kept only in memory vanishes. This recipe stores giveaways in SQLite so they survive restarts: on boot, the bot re-arms every giveaway that was still running.

At a glance
You need A working bot from Host a discord.js bot
Plan Free or premium — no premium features required
Time About thirty minutes

New here? Build the base bot with Host a discord.js bot first.

Add the package

This recipe uses better-sqlite3 — a real database in a single file, with no separate server to run. It ships prebuilt binaries, so it installs cleanly on the Node.js application. Open the Packages page in your server menu, install better-sqlite3, and restart. (More on the trade-offs in Storing data for your bot.)

The whole bot

Here's the complete index.js. /giveaway starts one; an Enter button collects entrants; a timer ends it and announces a winner.

require('dotenv').config();
const Database = require('better-sqlite3');
const {
  Client, GatewayIntentBits, Events,
  ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, SlashCommandBuilder,
} = require('discord.js');

const db = new Database('giveaways.db');
db.pragma('journal_mode = WAL');
db.exec(`
  CREATE TABLE IF NOT EXISTS giveaways (
    message_id TEXT PRIMARY KEY,
    channel_id TEXT NOT NULL,
    prize      TEXT NOT NULL,
    ends_at    INTEGER NOT NULL,
    ended      INTEGER NOT NULL DEFAULT 0
  );
  CREATE TABLE IF NOT EXISTS entries (
    message_id TEXT NOT NULL,
    user_id    TEXT NOT NULL,
    PRIMARY KEY (message_id, user_id)
  );
`);

const addGiveaway = db.prepare('INSERT INTO giveaways (message_id, channel_id, prize, ends_at) VALUES (?, ?, ?, ?)');
const addEntry = db.prepare('INSERT OR IGNORE INTO entries (message_id, user_id) VALUES (?, ?)');
const getEntrants = db.prepare('SELECT user_id FROM entries WHERE message_id = ?');
const getGiveaway = db.prepare('SELECT * FROM giveaways WHERE message_id = ?');
const markEnded = db.prepare('UPDATE giveaways SET ended = 1 WHERE message_id = ?');
const openGiveaways = db.prepare('SELECT * FROM giveaways WHERE ended = 0');

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

function parseDuration(text) {
  const m = /^(\d+)(s|m|h|d)$/.exec(text.trim());
  if (!m) return null;
  return Number(m[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
}

async function endGiveaway(messageId) {
  const row = getGiveaway.get(messageId);
  if (!row || row.ended) return;
  markEnded.run(messageId);

  const channel = await client.channels.fetch(row.channel_id).catch(() => null);
  const entrants = getEntrants.all(messageId).map((e) => e.user_id);
  const winner = entrants.length ? entrants[Math.floor(Math.random() * entrants.length)] : null;
  if (channel) {
    await channel.send(
      winner
        ? `🎉 The giveaway for **${row.prize}** is over — congratulations <@${winner}>!`
        : `The giveaway for **${row.prize}** ended with no entries.`,
    );
  }
}

function schedule(messageId, endsAt) {
  setTimeout(() => endGiveaway(messageId), Math.max(0, endsAt - Date.now()));
}

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set([
    new SlashCommandBuilder()
      .setName('giveaway')
      .setDescription('Start a giveaway')
      .addStringOption((o) => o.setName('prize').setDescription('What to give away').setRequired(true))
      .addStringOption((o) => o.setName('duration').setDescription('e.g. 30m, 2h, 1d').setRequired(true))
      .toJSON(),
  ]);

  // Re-arm every giveaway that was still running when the bot last stopped.
  for (const row of openGiveaways.all()) schedule(row.message_id, row.ends_at);

  console.log(`Listening as ${c.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand() && interaction.commandName === 'giveaway') {
    const prize = interaction.options.getString('prize');
    const ms = parseDuration(interaction.options.getString('duration'));
    if (!ms) {
      await interaction.reply({ content: 'Duration must look like `30m`, `2h`, or `1d`.', ephemeral: true });
      return;
    }
    const endsAt = Date.now() + ms;
    const embed = new EmbedBuilder()
      .setTitle('🎉 Giveaway!')
      .setDescription(`Prize: **${prize}**\nEnds <t:${Math.floor(endsAt / 1000)}:R>\nClick the button to enter.`)
      .setColor(0x5865f2);
    const row = new ActionRowBuilder().addComponents(
      new ButtonBuilder().setCustomId('giveaway:enter').setLabel('Enter').setStyle(ButtonStyle.Success).setEmoji('🎉'),
    );
    const message = await interaction.reply({ embeds: [embed], components: [row], fetchReply: true });
    addGiveaway.run(message.id, message.channelId, prize, endsAt);
    schedule(message.id, endsAt);
    return;
  }

  if (interaction.isButton() && interaction.customId === 'giveaway:enter') {
    addEntry.run(interaction.message.id, interaction.user.id);
    await interaction.reply({ content: 'You are entered. Good luck! 🍀', ephemeral: true });
  }
});

client.login(process.env.DISCORD_TOKEN);

Restart and run /giveaway prize:"Steam key" duration:1m.

How it works

  • Two tables. giveaways holds each prize and its end time; entries holds one row per (giveaway, user). The PRIMARY KEY (message_id, user_id) plus INSERT OR IGNORE means clicking Enter twice counts once — no double entries.
  • The message ID is the key. The button's giveaway is identified by the message it sits on, so there's nothing to look up but interaction.message.id.
  • Restarts are survivable. Every giveaway is written to disk the moment it starts. On ClientReady, the bot reads every giveaway where ended = 0 and calls schedule again. If one was already past due while the bot was down, the timer delay clamps to 0 and it ends immediately.
  • <t:…:R> renders a live "ends in 3 minutes" countdown that Discord updates for every viewer, in their own timezone.

💡 Tip: Picking the winner is one line: a random index into the entrants array. To add a reroll, keep the giveaway row after it ends and add a /reroll command that reads the same entries and picks again.

⚠️ Heads up: setTimeout can only wait about 24.8 days. For giveaways longer than that, don't schedule the whole span at once — run a small check every few minutes that ends any giveaway whose ends_at has passed. A scheduled task is a clean way to do the sweep.

Where the data lives

giveaways.db sits in /home/container, on the server. It survives restarts — that's the whole point — but a reinstall or an application switch wipes it, like any file. For giveaways that must outlive that, use a managed database instead. Add giveaways.db to your .gitignore so it never lands in a repo.

Verify it works

Run a one-minute giveaway, click Enter from a couple of accounts, and wait — the bot should announce a winner. Now the real test: start a giveaway, restart the server before it ends, and confirm the bot still announces a winner when the timer runs out. That proves the SQLite persistence is doing its job.

Troubleshooting

Symptom Fix
Cannot find module 'better-sqlite3' Install it from the Packages page, then restart.
Giveaway never ends after a restart Confirm the re-arm loop runs on ClientReady and that giveaways.db wasn't deleted (a reinstall wipes it).
Same person enters many times They can't — the PRIMARY KEY (message_id, user_id) blocks duplicates. If you removed it, add it back.
"This interaction failed" An error was thrown in the handler before replying. Check the console; wrap risky work in try/catch — see Crash-proof your bot.

Next steps

Was this guide helpful?