Key takeaways

  • Store coins as integers with a cached balance plus one ledger row per change, and run every /daily claim, /pay transfer and postback credit inside a single database transaction so coins cannot be created or lost.
  • Alt-account farming is slowed by layers, not one rule: a database-backed daily cooldown, a minimum account age and a linked site account for transfers, and a rolling cap on coins sent.
  • Offer rewards are earned on the member's website account, never their Discord ID, and your server credits them to the same ledger only from verified postbacks, once per transaction ID.

How do you make a Discord economy bot with discord.js?

To make a Discord economy bot, register slash commands such as /balance, /daily, /pay and /leaderboard with discord.js v14, and store coins in a real database, not a JSON file or a Map. Every balance change is a row in a ledger table, and each daily claim or transfer runs inside one database transaction, so coins cannot be created or lost by a crash, a double click or two commands at once. Add cooldowns and transfer limits against alt accounts, and let members earn more coins on your website through an offerwall whose postbacks credit the same ledger.

This tutorial uses plain JavaScript with discord.js 14.27.0 and better-sqlite3 13.0.3, the latest versions on npm (checked on October 22, 2026). Every file was syntax-checked and the ledger was unit-tested. For the business side, read How to monetize a Discord bot.

What you build, and who does what

The bot has five commands. Sample values (the coin amounts, limits and cooldowns are yours to change):

CommandWhat it doesAbuse guard
/balance [member]Shows your balance, or another member’s, privatelyBots have no wallets
/dailyGives 100 coins, plus 20 per day of streak, up to 220 on day 7One claim per 24 hours, checked inside the database transaction
/pay member amountMoves coins from you to another member, all or nothingNo self-pay, no bots, accounts at least 7 days old, linked site account, 5,000 coins per 24 hours
/leaderboardLists the top 10 balancesNames without pings
/earnSends a private button to the earn page on your websiteEarning happens on the site account, never on the Discord ID

Three parts share one database:

  • The bot answers commands and calls ledger functions. It never holds offerwall secrets.
  • The ledger module owns every coin.
  • Your website owns member accounts, shows the offerwall, and runs the postback endpoint that credits the same ledger.

Step 1: set up the project

You need Node.js 22 or newer: discord.js 14 asks for Node.js 18 or newer, but better-sqlite3 13 declares Node.js 22 as its minimum. Create the bot in the Discord Developer Portal, copy its token and application ID, invite it to your server with the applications.commands and bot scopes, then install the two packages:

mkdir economy-bot && cd economy-bot
npm init -y
npm install [email protected] [email protected]
mkdir src test

Keep the token in an environment variable (DISCORD_TOKEN), never in the repository. Slash commands reach the bot as interactions, so it needs only the Guilds intent and no privileged intents such as Message Content.

Step 2: the ledger, with atomic transfers

An economy bot is a small bank, so design it like one. Two rules carry the whole design:

  1. Integers only. Store coins as whole numbers. In floating point, 0.1 + 0.2 is 0.30000000000000004.
  2. A ledger next to the balance. wallets keeps a cached balance for fast reads, and ledger gets one row for every change. A wallet’s ledger rows must always add up to its balance, which gives you an audit you can run any time.

SQLite or PostgreSQL?

SQLite with better-sqlite3PostgreSQL with pg
SetupOne file, no serverA database server or a managed service
Who can writeProcesses on the same machine, one writer at a timeMany processes on many machines
API styleSynchronous; a transaction is a plain functionAsynchronous; every statement of a transaction must use one client from pool.connect()
Atomic transferdb.transaction(fn).immediate()BEGIN, UPDATE ... WHERE balance >= $1, COMMIT
Good fitOne bot and one website on the same serverA sharded bot, a website on another host, several services

This guide uses SQLite. The same tables and the same WHERE balance >= ? pattern work in PostgreSQL; the node-postgres pattern for one-client transactions is in Offerwall postback in Node.js.

Save this as src/ledger.js:

const Database = require('better-sqlite3');
const { randomUUID } = require('node:crypto');

const DAY = 24 * 60 * 60 * 1000;
const DAILY_BASE = 100;
const DAILY_STEP = 20;
const DAILY_MAX_STREAK = 7;
const PAY_LIMIT_PER_DAY = 5000;

class EconomyError extends Error {
  constructor(code, message) {
    super(message);
    this.code = code;
  }
}

function openLedger(file = 'economy.db') {
  const db = new Database(file);
  db.pragma('journal_mode = WAL');
  db.pragma('busy_timeout = 5000');
  db.pragma('foreign_keys = ON');
  db.exec(`
    CREATE TABLE IF NOT EXISTS wallets (
      id           INTEGER PRIMARY KEY,
      discord_id   TEXT UNIQUE,
      site_user_id TEXT UNIQUE,
      balance      INTEGER NOT NULL DEFAULT 0,
      last_daily   INTEGER,
      streak       INTEGER NOT NULL DEFAULT 0
    );
    CREATE TABLE IF NOT EXISTS ledger (
      id         INTEGER PRIMARY KEY,
      wallet_id  INTEGER NOT NULL REFERENCES wallets(id),
      amount     INTEGER NOT NULL,
      kind       TEXT NOT NULL,
      ref        TEXT,
      created_at INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS ledger_wallet_kind ON ledger (wallet_id, kind, created_at);
    CREATE TABLE IF NOT EXISTS offer_tx (
      transaction_id TEXT PRIMARY KEY,
      wallet_id      INTEGER NOT NULL REFERENCES wallets(id),
      reward         INTEGER NOT NULL,
      last_status    INTEGER NOT NULL
    );
  `);

  const q = {
    ensure: db.prepare('INSERT INTO wallets (discord_id) VALUES (?) ON CONFLICT (discord_id) DO NOTHING'),
    byDiscord: db.prepare('SELECT * FROM wallets WHERE discord_id = ?'),
    bySite: db.prepare('SELECT * FROM wallets WHERE site_user_id = ?'),
    add: db.prepare('UPDATE wallets SET balance = balance + ? WHERE id = ?'),
    take: db.prepare('UPDATE wallets SET balance = balance - ? WHERE id = ? AND balance >= ?'),
    setDaily: db.prepare('UPDATE wallets SET last_daily = ?, streak = ? WHERE id = ?'),
    entry: db.prepare('INSERT INTO ledger (wallet_id, amount, kind, ref, created_at) VALUES (?, ?, ?, ?, ?)'),
    sentToday: db.prepare("SELECT COALESCE(-SUM(amount), 0) FROM ledger WHERE wallet_id = ? AND kind = 'pay_out' AND created_at > ?").pluck(),
    top: db.prepare('SELECT discord_id, balance FROM wallets WHERE discord_id IS NOT NULL AND balance > 0 ORDER BY balance DESC, id LIMIT ?'),
    link: db.prepare('UPDATE wallets SET site_user_id = ? WHERE id = ? AND site_user_id IS NULL'),
    tx: db.prepare('SELECT * FROM offer_tx WHERE transaction_id = ?'),
    txNew: db.prepare('INSERT INTO offer_tx (transaction_id, wallet_id, reward, last_status) VALUES (?, ?, ?, ?)'),
    txSet: db.prepare('UPDATE offer_tx SET last_status = ? WHERE transaction_id = ?'),
    audit: db.prepare(`SELECT w.id, w.balance, COALESCE(SUM(l.amount), 0) AS total
      FROM wallets w LEFT JOIN ledger l ON l.wallet_id = w.id
      GROUP BY w.id HAVING w.balance != total`),
  };

  function wallet(discordId) {
    q.ensure.run(discordId);
    return q.byDiscord.get(discordId);
  }

  function move(w, amount, kind, ref, now) {
    if (amount >= 0) q.add.run(amount, w.id);
    else if (q.take.run(-amount, w.id, -amount).changes === 0) {
      throw new EconomyError('insufficient', 'Not enough coins.');
    }
    q.entry.run(w.id, amount, kind, ref, now);
  }

  const claimDaily = db.transaction((discordId, now) => {
    const w = wallet(discordId);
    if (w.last_daily !== null && now - w.last_daily < DAY) {
      return { ok: false, nextAt: w.last_daily + DAY };
    }
    const keepsStreak = w.last_daily !== null && now - w.last_daily < 2 * DAY;
    const streak = keepsStreak ? Math.min(w.streak + 1, DAILY_MAX_STREAK) : 1;
    const amount = DAILY_BASE + DAILY_STEP * (streak - 1);
    move(w, amount, 'daily', null, now);
    q.setDaily.run(now, streak, w.id);
    return { ok: true, amount, streak, balance: w.balance + amount };
  });

  const pay = db.transaction((fromId, toId, amount, now) => {
    if (!Number.isSafeInteger(amount) || amount <= 0) throw new EconomyError('amount', 'Amount must be a whole number above 0.');
    if (fromId === toId) throw new EconomyError('self', 'You cannot pay yourself.');
    const from = wallet(fromId);
    const to = wallet(toId);
    if (q.sentToday.get(from.id, now - DAY) + amount > PAY_LIMIT_PER_DAY) {
      throw new EconomyError('limit', `You can send up to ${PAY_LIMIT_PER_DAY} coins per 24 hours.`);
    }
    const ref = randomUUID();
    move(from, -amount, 'pay_out', ref, now);
    move(to, amount, 'pay_in', ref, now);
    return { balance: from.balance - amount };
  });

  const linkSiteAccount = db.transaction((discordId, siteUserId) => {
    const w = wallet(discordId);
    if (q.bySite.get(siteUserId)) throw new EconomyError('taken', 'That site account is already linked.');
    if (q.link.run(siteUserId, w.id).changes === 0) throw new EconomyError('linked', 'This Discord account is already linked.');
  });

  const applyOfferEvent = db.transaction(({ tx, user, reward, status }, now) => {
    if (!/^\d+$/.test(reward)) throw new Error(`reward ${reward} is not a whole number`);
    const w = q.bySite.get(user);
    if (!w) throw new Error(`no wallet for site user ${user}`);
    const row = q.tx.get(tx);
    if (status === '1' && !row) {
      q.txNew.run(tx, w.id, Number(reward), 1);
      move(w, Number(reward), 'offer', tx, now);
      return 'credited';
    }
    if (status === '2' && row && row.last_status === 1) {
      q.txSet.run(2, tx);
      q.add.run(-row.reward, row.wallet_id);
      q.entry.run(row.wallet_id, -row.reward, 'offer_reversal', tx, now);
      return 'reversed';
    }
    if (status === '2' && !row) {
      q.txNew.run(tx, w.id, Number(reward), 2);
      return 'reversal_recorded';
    }
    return 'ignored';
  });

  return {
    db,
    balance: (discordId) => wallet(discordId).balance,
    isLinked: (discordId) => wallet(discordId).site_user_id !== null,
    claimDaily: (discordId, now = Date.now()) => claimDaily.immediate(discordId, now),
    pay: (fromId, toId, amount, now = Date.now()) => pay.immediate(fromId, toId, amount, now),
    top: (limit = 10) => q.top.all(limit),
    linkSiteAccount: (discordId, siteUserId) => linkSiteAccount.immediate(discordId, String(siteUserId)),
    applyOfferEvent: (p, now = Date.now()) => applyOfferEvent.immediate(p, now),
    audit: () => q.audit.all(),
  };
}

module.exports = { openLedger, EconomyError, DAY };

Why it is written this way

  • Transactions roll back on any error. The better-sqlite3 API docs say a function wrapped in db.transaction() always runs inside a transaction, and if an exception is thrown, the transaction is rolled back. So when the receiver’s side of a transfer fails, the sender keeps their coins.
  • The balance check is part of the update. UPDATE ... SET balance = balance - ? WHERE id = ? AND balance >= ? either changes one row or none. Reading the balance first and subtracting later leaves a gap where two commands can both spend the same coins.
  • .immediate() takes the write lock at the start. The docs list deferred, immediate and exclusive versions of every transaction function. Immediate makes a second process (your website) wait at BEGIN instead of failing halfway, and busy_timeout sets how long it waits.
  • No async inside transactions. The same docs say transaction functions do not work with async functions. Do all Discord calls before or after the ledger call, never inside it.
  • WAL mode. better-sqlite3’s performance guide recommends journal_mode = WAL because concurrent reads and writes can otherwise be slow.
  • The daily cooldown lives in the database. last_daily is checked and set in the same transaction as the credit, so a restart cannot reset it and two quick /daily calls cannot both pay. A claim within 48 hours of the last one keeps the streak; a longer gap starts again at day 1.

Step 3: register commands and handle them

Register the commands once with a deploy script. Guild commands update instantly, according to Discord’s application commands docs, so use them while you build; switch to Routes.applicationCommands (global commands) when the bot goes public. Integer options take a minimum and maximum, so Discord rejects a /pay of 0 before your code runs. Save this as src/deploy-commands.js and run it with node src/deploy-commands.js:

const { REST, Routes, SlashCommandBuilder } = require('discord.js');

const commands = [
  new SlashCommandBuilder().setName('balance').setDescription('Show your coin balance')
    .addUserOption((o) => o.setName('member').setDescription('Someone else in this server')),
  new SlashCommandBuilder().setName('daily').setDescription('Claim your daily coins'),
  new SlashCommandBuilder().setName('pay').setDescription('Send coins to another member')
    .addUserOption((o) => o.setName('member').setDescription('Who gets the coins').setRequired(true))
    .addIntegerOption((o) => o.setName('amount').setDescription('How many coins').setRequired(true).setMinValue(1).setMaxValue(5000)),
  new SlashCommandBuilder().setName('leaderboard').setDescription('Top 10 balances in this server'),
  new SlashCommandBuilder().setName('earn').setDescription('Earn more coins on our website'),
].map((c) => c.toJSON());

const rest = new REST().setToken(process.env.DISCORD_TOKEN);

rest.put(Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID), { body: commands })
  .then((data) => console.log(`Registered ${data.length} commands`))
  .catch(console.error);

Then the bot, src/bot.js:

const {
  Client, Events, GatewayIntentBits, MessageFlags,
  ActionRowBuilder, ButtonBuilder, ButtonStyle, time, TimestampStyles, userMention,
} = require('discord.js');
const { openLedger, EconomyError, DAY } = require('./ledger');

const ledger = openLedger(process.env.ECONOMY_DB || 'economy.db');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const MIN_ACCOUNT_AGE = 7 * DAY;
const COOLDOWN_MS = 3000;
const lastUse = new Map();
setInterval(() => {
  for (const [id, at] of lastUse) if (Date.now() - at > COOLDOWN_MS) lastUse.delete(id);
}, 60_000).unref();

const handlers = {
  async balance(interaction) {
    const member = interaction.options.getUser('member') ?? interaction.user;
    if (member.bot) return interaction.reply({ content: 'Bots do not have wallets.', flags: MessageFlags.Ephemeral });
    const coins = ledger.balance(member.id);
    const who = member.id === interaction.user.id ? 'You have' : `${member.username} has`;
    return interaction.reply({ content: `${who} ${coins.toLocaleString('en-US')} coins.`, flags: MessageFlags.Ephemeral });
  },

  async daily(interaction) {
    const result = ledger.claimDaily(interaction.user.id);
    if (!result.ok) {
      const next = time(Math.ceil(result.nextAt / 1000), TimestampStyles.RelativeTime);
      return interaction.reply({ content: `You already claimed today. Come back ${next}.`, flags: MessageFlags.Ephemeral });
    }
    return interaction.reply({
      content: `+${result.amount} coins (day ${result.streak} streak). Balance: ${result.balance.toLocaleString('en-US')}.`,
      flags: MessageFlags.Ephemeral,
    });
  },

  async pay(interaction) {
    const target = interaction.options.getUser('member', true);
    const amount = interaction.options.getInteger('amount', true);
    const now = Date.now();
    if (target.bot) return interaction.reply({ content: 'You cannot pay a bot.', flags: MessageFlags.Ephemeral });
    if (now - interaction.user.createdTimestamp < MIN_ACCOUNT_AGE || now - target.createdTimestamp < MIN_ACCOUNT_AGE) {
      return interaction.reply({ content: 'Both Discord accounts must be at least 7 days old to send or receive coins.', flags: MessageFlags.Ephemeral });
    }
    if (!ledger.isLinked(interaction.user.id)) {
      return interaction.reply({ content: 'Link your account on our website before you send coins. Run /earn for the link.', flags: MessageFlags.Ephemeral });
    }
    const { balance } = ledger.pay(interaction.user.id, target.id, amount, now);
    return interaction.reply({
      content: `${userMention(interaction.user.id)} sent ${amount.toLocaleString('en-US')} coins to ${userMention(target.id)}. Your balance: ${balance.toLocaleString('en-US')}.`,
      allowedMentions: { users: [target.id] },
    });
  },

  async leaderboard(interaction) {
    const rows = ledger.top(10);
    if (rows.length === 0) return interaction.reply({ content: 'Nobody has coins yet. Try /daily.', flags: MessageFlags.Ephemeral });
    const lines = rows.map((r, i) => `${i + 1}. ${userMention(r.discord_id)} ${r.balance.toLocaleString('en-US')}`);
    return interaction.reply({ content: `**Top balances**\n${lines.join('\n')}`, allowedMentions: { parse: [] } });
  },

  async earn(interaction) {
    const button = new ButtonBuilder().setStyle(ButtonStyle.Link)
      .setLabel('Open the earn page').setURL('https://yoursite.com/earn');
    return interaction.reply({
      content: 'Sign in on our website to earn coins by completing offers. They land in this same wallet once your Discord account is linked.',
      components: [new ActionRowBuilder().addComponents(button)],
      flags: MessageFlags.Ephemeral,
    });
  },
};

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || !interaction.inGuild()) return;
  const handler = handlers[interaction.commandName];
  if (!handler) return;

  const now = Date.now();
  if (now - (lastUse.get(interaction.user.id) ?? 0) < COOLDOWN_MS) {
    return interaction.reply({ content: 'Slow down a little.', flags: MessageFlags.Ephemeral });
  }
  lastUse.set(interaction.user.id, now);

  try {
    await handler(interaction);
  } catch (err) {
    const content = err instanceof EconomyError ? err.message : 'Something went wrong. Try again later.';
    if (!(err instanceof EconomyError)) console.error(err);
    if (!interaction.replied && !interaction.deferred) {
      await interaction.reply({ content, flags: MessageFlags.Ephemeral }).catch(() => {});
    }
  }
});

client.once(Events.ClientReady, (c) => console.log(`Logged in as ${c.user.tag}`));
client.login(process.env.DISCORD_TOKEN);

Notes on the bot code:

  • Reply within 3 seconds. Discord’s interactions docs say you must send an initial response within 3 seconds. The SQLite calls take well under a millisecond, so every handler replies directly. If a command calls a slow API, use deferReply() first; the interaction token then stays valid for 15 minutes.
  • Private by default. MessageFlags.Ephemeral makes balances and errors visible only to the member who ran the command. /pay and /leaderboard are public on purpose.
  • No mass pings. allowedMentions: { parse: [] } shows the leaderboard names as mentions without notifying ten people. The /pay reply only pings the receiver.
  • Discord’s own timestamps. time(..., TimestampStyles.RelativeTime) renders “in 5 hours” in each member’s own time zone, as in the discord.js guide on cooldowns. The 3-second spam guard is in memory like the guide’s; anything that pays out is enforced in the database.

Cooldowns, alt accounts and other abuse

Every economy bot gets farmed, usually by a member who claims /daily on five alt accounts and sends it all to the main one. No single rule stops that, but layers make it slow and visible:

  • Account age. /pay refuses Discord accounts younger than 7 days, using createdTimestamp, which discord.js reads from the user’s ID. Fresh alts are the cheapest ones.
  • A linked site account to send. Sending coins needs a linked website account with a verified email, so each alt costs real effort.
  • A rolling transfer cap. 5,000 coins per 24 hours, counted from the ledger, limits how fast a farm drains into one wallet.
  • Small daily rewards. If /daily is a small part of what an active member earns, alts are not worth running. Virtual economy design covers sources and sinks, and the virtual currency calculator helps with prices.
  • Watch the ledger. Many pay_in rows from young wallets into one wallet is what a farm looks like. A weekly query beats a complicated rule.

Multi-accounting on the earning side is a different problem, because offer rewards are worth real money to you. Stop multi-accounting and VPN abuse covers the layers that work for communities.

Economy bot mistakes that break the books

Saving balances to a JSON file (two writes at once lose one). Reading a balance, then writing balance minus amount in a second query. Storing coins as floats. Keeping the /daily cooldown in a Map, so every restart gives everyone a free claim. Calling Discord inside a database transaction. And letting a ledger row exist without the matching balance change, or the other way round.

Step 4: let members earn coins through an offerwall

An offerwall adds a real source of coins: members complete offers, such as trying an app or answering a survey, and you earn per completed offer. Discord OAuth2 linking and the signed link are covered in How to add an offerwall to a Discord bot. Here is how it plugs into this ledger.

Members earn on their site account, never on their Discord ID

Rule 17 of the Discord Developer Policy says not to disclose API Data to data brokers, advertising networks or services, or any other monetization-related service. A Discord user ID your bot reads is API Data, so it never goes into an offerwall link. Instead:

  1. The member signs up on your website and links Discord there with OAuth2. Your site then calls ledger.linkSiteAccount(discordId, siteUserId), which stores the site account ID on the member’s wallet and refuses a second link.
  2. /earn sends a private button to your site’s earn page. The page builds the offerwall link from the signed-in member’s site account ID, signed on your server.
  3. When an offer completes, the postback carries that site account ID as {user_id}. Your endpoint finds the wallet by site_user_id and credits it in the same ledger the bot reads.

The postback endpoint

Sharklio sends a signed server-to-server postback for every credit and every reversal. The Postbacks docs define the hash as an HMAC-SHA256 of transaction ID, user ID, reward and status joined with colons, signed with your postback secret key. Set the postback URL in your app to:

https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}

A small endpoint with no framework, src/postback.js, which opens the same SQLite file as the bot. Run it behind your web server’s reverse proxy:

const http = require('node:http');
const crypto = require('node:crypto');
const { openLedger } = require('./ledger');

const KEYS = ['tx', 'user', 'reward', 'status', 'hash'];

function validHash(p, secret) {
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${p.tx}:${p.user}:${p.reward}:${p.status}`).digest('hex')
  );
  const given = Buffer.from(p.hash);
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

function createPostbackServer(ledger, secret) {
  return http.createServer((req, res) => {
    const url = new URL(req.url, 'http://localhost');
    if (req.method !== 'GET' || url.pathname !== '/sharklio/postback') {
      res.writeHead(404).end();
      return;
    }
    const p = {};
    for (const key of KEYS) {
      const values = url.searchParams.getAll(key);
      if (values.length !== 1) return res.writeHead(403).end();
      p[key] = values[0];
    }
    if (!validHash(p, secret)) return res.writeHead(403).end();
    if (p.tx.startsWith('test_')) {
      console.info('test postback', p.tx, p.status);
      return res.writeHead(200).end('ok');
    }
    try {
      console.info('offer event', p.tx, ledger.applyOfferEvent(p));
      res.writeHead(200).end('ok');
    } catch (err) {
      console.error('postback failed', p.tx, err.message);
      res.writeHead(500).end();
    }
  });
}

if (require.main === module) {
  const secret = process.env.SHARKLIO_POSTBACK_KEY;
  if (!secret) throw new Error('SHARKLIO_POSTBACK_KEY is not set');
  const ledger = openLedger(process.env.ECONOMY_DB || 'economy.db');
  createPostbackServer(ledger, secret).listen(Number(process.env.PORT || 3001), '127.0.0.1');
}

module.exports = { createPostbackServer, validHash };
  • Whole-number rewards. Set Reward rounding to whole numbers in your app settings, so {reward} arrives as a plain number such as 250 (see the Rewards docs). A decimal reward makes the endpoint answer 500, which shows up in your postback reports instead of being rounded silently.
  • Once per transaction. The offer_tx table keeps the last status of each transaction ID, so a retried postback cannot pay twice, a reversal only takes back a credit that happened, and a reversal that arrives first blocks a later credit. How to prevent duplicate reward credits explains each case.
  • Answer fast. Answer with a 2xx status within 6 seconds; errors return 500 so the postback is retried. Dashboard tests, whose transaction ID starts with test_, are logged, not credited.

A reversal can push a balance below zero if the coins were already spent. That is deliberate: the ledger stays true. The reasoning behind each check is in Postback security.

Discord rules for economy bots

These are the rules that hit economy bots most often. They change, so read the current pages before you launch (checked on October 22, 2026; the Developer Policy version we found is effective July 8, 2024).

RuleSourceWhat it means for your bot
Do not disclose API Data to advertising or monetization-related servicesDeveloper Policy, rule 17Offerwall links and postbacks use the site account ID, never the Discord ID
Do not sell or commercialize Discord’s services, such as Nitro subscriptionsDeveloper Policy, rule 18Our reading: no Nitro as a coin shop prize
No inflating server membership, and no automated messages to keep a server activeDeveloper PolicyNo coins for joining, and no bot posts to make a channel look busy
“Invite rewards”, “join-for-join” and engagement induced by promises of financial reward are prohibitedPlatform Manipulation Policy explainerNo coins per invite and no invite leaderboard with prizes
Gambling means a wager of real-world value, a prize of real-world value, and an outcome mostly decided by chance; virtual currency with a real-world market value countsGambling Policy explainerIf coins can be bought, sold, or redeemed for gift cards or cash, leave out coinflip, slots and betting commands

Our reading, not a line Discord spells out: chance games are safest when coins can never leave your community for anything of real value. For growing the bot within the rules, see How to grow a Discord bot.

Step 5: test the economy before members touch it

You cannot log in to Discord from a test, but you can test the part that holds the money. Node.js ships a stable test runner, node:test, so no extra package is needed. Save this as test/ledger.test.js:

const test = require('node:test');
const assert = require('node:assert/strict');
const { openLedger, DAY } = require('../src/ledger');

const T0 = Date.UTC(2026, 9, 22, 12);

test('daily: one claim per 24 hours, streak grows, gap resets it', () => {
  const l = openLedger(':memory:');
  assert.equal(l.claimDaily('a', T0).amount, 100);
  assert.deepEqual(l.claimDaily('a', T0 + DAY - 1), { ok: false, nextAt: T0 + DAY });
  assert.equal(l.claimDaily('a', T0 + DAY).amount, 120);
  assert.equal(l.claimDaily('a', T0 + 4 * DAY).amount, 100);
  assert.deepEqual(l.audit(), []);
});

test('pay: all or nothing', () => {
  const l = openLedger(':memory:');
  l.claimDaily('a', T0);
  assert.throws(() => l.pay('a', 'b', 101, T0), { code: 'insufficient' });
  assert.equal(l.balance('a'), 100);
  assert.throws(() => l.pay('a', 'a', 1, T0), { code: 'self' });
  assert.throws(() => l.pay('a', 'b', 1.5, T0), { code: 'amount' });
  assert.deepEqual(l.pay('a', 'b', 60, T0), { balance: 40 });
  assert.equal(l.balance('b'), 60);
  assert.deepEqual(l.audit(), []);
});

test('offer postbacks: credit once, reverse once', () => {
  const l = openLedger(':memory:');
  l.linkSiteAccount('a', 42);
  const ev = (tx, status) => l.applyOfferEvent({ tx, user: '42', reward: '250', status }, T0);
  assert.equal(ev('t1', '1'), 'credited');
  assert.equal(ev('t1', '1'), 'ignored');
  assert.equal(ev('t1', '2'), 'reversed');
  assert.equal(ev('t2', '2'), 'reversal_recorded');
  assert.equal(ev('t2', '1'), 'ignored');
  assert.equal(l.balance('a'), 0);
  assert.deepEqual(l.audit(), []);
});

Run node --check src/bot.js for syntax errors and node --test in the project folder to run every file under test/. Our full suite also checks the postback server for a forged hash (403), a repeated parameter (403), a test_ postback (200, no credit) and an unknown site account (500). Three worker threads then made 6,000 random transfer attempts through separate database connections: the total of all balances stayed the same, no wallet went negative, and the audit found no mismatch.

Then test in Discord with a private test server:

  1. Run /daily twice: the second reply says when to come back.
  2. Try /pay with too many coins, to yourself, and to a bot. All three must fail and change nothing.
  3. Run /leaderboard and check that nobody was notified.
  4. Use Send a test postback in Integration status: your log shows a test_ transaction and no balance change.
  5. Run ledger.audit(). It must return an empty list.

The launch checklist is in How to test your app before it goes live, and the Testing docs list common errors.

Sharklio as the earning source for your economy

Sharklio is a pay-per-result affiliate network. Your members earn your own coins on its offerwall, with your coin name, icon, conversion rate, user split, rounding and wall colors, and your server gets a signed postback for every credit and every reversal, ready for a ledger like this one. Offers follow each member’s country and device, VPN, proxy, Tor and data center traffic is blocked, and members must be at least 16 (parental permission under 18). Every publisher application is reviewed by hand. See how the wall works, or get in touch with questions about your bot.

Frequently asked questions

What is a Discord economy bot?

A Discord economy bot gives a server its own virtual currency, with commands to check balances, claim daily rewards, send coins to other members and see a leaderboard. The coins usually buy roles, cosmetics or perks inside the community.

What database should a discord.js economy bot use?

SQLite through better-sqlite3 is enough for one bot on one server; PostgreSQL fits when the bot and website run on different machines. Avoid JSON files and in-memory maps, which lose coins on concurrent writes and restarts.

How do you make a daily command with a cooldown in discord.js?

Store the time of the last claim in the database next to the balance, and check and update it in the same transaction that credits the coins. A cooldown kept only in memory resets every time the bot restarts.

How do you stop people farming an economy bot with alt accounts?

Combine a minimum Discord account age for transfers, a rolling daily cap on coins sent, a linked and verified website account for sending, and small daily rewards.

Can a Discord economy bot have gambling commands?

Discord’s gambling policy covers chance games with a wager and a prize of real-world value, and virtual currency with a real-world market value counts. If your coins can be bought, sold or redeemed for anything of real value, leave chance games out.

Can members earn economy bot coins by completing offers?

Yes, through an offerwall on your website. Members link Discord to a site account, earn there, and your server credits the shared ledger from verified postbacks, using the site account ID and never the Discord ID.