How to Add an Offerwall to a Discord Bot

Key takeaways

  • The bot only answers a member's own /earn command with a private link button; the offerwall itself opens on your website or as a signed link your server builds.
  • The user ID in the offerwall link is the member's account ID on your site, linked through Discord OAuth2, never their Discord user ID, which Discord's Developer Policy does not let you disclose to monetization services.
  • Coins are credited only by your server from verified postbacks, and no reward ever depends on joining, inviting, bumping or reviewing.

How do you add an offerwall to a Discord bot?

To add an offerwall to a Discord bot, give members an account on your own website, let them link it to Discord with Discord’s OAuth2 login, and add an /earn slash command that replies privately with a link button. The link goes to your site’s earn page, or it is a signed offerwall link that your backend builds from the member’s site account ID. Your server credits coins when the offerwall sends a postback, and the bot only reads the balance back.

The business side is in How to monetize a Discord server without breaking the rules. This guide is the code, for discord.js v14 and discord.py 2.x.

Who does what

PartDoesNever does
Discord botAnswers /earn and /balance with ephemeral replies, asks your backend for the linkHolds the link salt, credits coins, posts offers or links in public channels, DMs members about offers
Your website and backendOwns accounts and the coin wallet, links Discord accounts, checks age, signs the link, receives postbacksPuts a Discord ID in the offerwall link
SharklioShows offers for the member’s country and device, tracks results, sends signed postbacksPays your members directly or learns who they are on Discord

The ID rule comes straight from Discord. Rule 17 of the Discord Developer Policy says: “Do not disclose API Data to data brokers, advertising networks or services, or any other monetization-related service.” The Developer Terms of Service define API Data as any data you obtain through the APIs, and a member’s Discord user ID is exactly that. Your own account ID is not, so that is the only ID the offerwall ever sees (checked on October 21, 2026).

Shortcuts that break the setup

Using interaction.user.id as the offerwall user_id, creating site accounts whose ID is the Discord snowflake, signing links inside the bot process with the salt in a public repo, or posting one member’s signed link in a channel “so everyone can see how it works”.

Members sign up on your website first, with a verified email and a date of birth. Then they link Discord from their account page with the OAuth2 authorization code grant described in Discord’s OAuth2 documentation:

  1. Your account page sends the signed-in member to https://discord.com/oauth2/authorize?response_type=code&client_id=YOUR_CLIENT_ID&scope=identify&state=RANDOM&redirect_uri=YOUR_CALLBACK. The identify scope lets you call /users/@me, which is all you need. Store the random state in the member’s session; Discord’s docs use it against CSRF and clickjacking.
  2. Discord redirects back with a code. Your server exchanges it at https://discord.com/api/oauth2/token, which only accepts application/x-www-form-urlencoded.
  3. Your server calls https://discord.com/api/users/@me with the access token and saves the returned id next to the member’s own account ID.
app.get('/link/discord/callback', requireLogin, async (req, res) => {
  if (!req.query.state || req.query.state !== req.session.discordState) {
    return res.sendStatus(403);
  }
  const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: String(req.query.code || ''),
      redirect_uri: 'https://yoursite.com/link/discord/callback',
      client_id: process.env.DISCORD_CLIENT_ID,
      client_secret: process.env.DISCORD_CLIENT_SECRET,
    }),
  });
  if (!tokenRes.ok) return res.redirect('/account?discord=failed');
  const { access_token } = await tokenRes.json();
  const meRes = await fetch('https://discord.com/api/users/@me', {
    headers: { Authorization: `Bearer ${access_token}` },
  });
  const me = await meRes.json();
  await linkDiscord(req.user.id, me.id);
  res.redirect('/account?discord=linked');
});

requireLogin and linkDiscord stand for your own session check and database code. Make linkDiscord refuse a Discord ID already linked to another site account. Do not store the access token: the Developer Terms count access tokens as API Data and ask you to delete API Data you no longer need. Offer an Unlink button, and describe the linking in your privacy policy, which the Developer Terms require for every application.

You have two ways to answer /earn. Both keep the Discord ID inside your own systems.

Option A: link to your earn pageOption B: personal signed link
What the button openshttps://yoursite.com/earn, where the member signs inThe offerwall, already signed for their site account
Bot needs your backendNoYes, one internal endpoint
If the link leaksNothing, a sign-in is still requiredAnyone can open the wall as that member
Best forMost servers, and the first versionCommunities where members rarely stay signed in on the site

With option A, your earn page signs the link for the signed-in member. For option B, add an internal endpoint only your bot can call, which looks up the linked site account and signs the link as the Offerwall link docs show:

const crypto = require('crypto');

app.get('/internal/earn-link', requireBotSecret, async (req, res) => {
  const account = await findAccountByDiscordId(String(req.query.discord_id || ''));
  if (!account) return res.json({ url: null, reason: 'not_linked' });
  if (!account.canEarn) return res.json({ url: null, reason: 'not_eligible' });
  const userId = String(account.id);
  const hash = crypto.createHmac('sha256', process.env.SHARKLIO_LINK_SALT).update(userId).digest('hex');
  const url = `https://wall.sharklio.com/${process.env.SHARKLIO_APP_ID}?user_id=${encodeURIComponent(userId)}&hash=${hash}&sub1=discord`;
  res.json({ url });
});

requireBotSecret checks an Authorization header against a long random secret that only the bot and the backend know; keeping the endpoint on a private network is better still. canEarn is your own flag: the member confirmed an age of at least 16 (with parental permission under 18), verified their email, and is not banned. The optional sub1=discord shows in reports and postbacks what the bot brings; sub IDs are not part of the hash, so never credit by them. If your backend is in Python, the docs version is:

import hmac, hashlib
from urllib.parse import quote
hash = hmac.new(link_hash_salt.encode(), str(user_id).encode(), hashlib.sha256).hexdigest()
url = f"https://wall.sharklio.com/{app_id}?user_id={quote(str(user_id))}&hash={hash}"

Step 3: the /earn slash command

The command answers with an ephemeral message, which only the member who ran it can see, holding one link button. Discord’s component reference says link buttons must have a URL of at most 512 characters, cannot have a custom_id, and do not send an interaction to your app when clicked. The bot has to acknowledge every command within 3 seconds, so it defers first and calls your backend after; the token then stays valid for 15 minutes, per Discord’s interactions docs.

discord.js v14

Register the command once with a deploy script. A guild command shows up right away in your server; setContexts keeps it out of DMs if you later make it global.

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

const earn = new SlashCommandBuilder()
  .setName('earn')
  .setDescription('Get your private link to earn coins')
  .setContexts(InteractionContextType.Guild);

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

(async () => {
  await rest.put(
    Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
    { body: [earn.toJSON()] },
  );
})();

Then handle it in the bot. The reply uses flags: MessageFlags.Ephemeral, the form the discord.js guide shows; the older ephemeral: true option is marked deprecated in the discord.js 14 typings.

const {
  Client, Events, GatewayIntentBits, MessageFlags,
  ActionRowBuilder, ButtonBuilder, ButtonStyle,
} = require('discord.js');

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

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'earn') return;

  const now = Date.now();
  if (now - (lastUse.get(interaction.user.id) ?? 0) < 30_000) {
    return interaction.reply({ content: 'You just asked for your link. Try again in a moment.', flags: MessageFlags.Ephemeral });
  }
  lastUse.set(interaction.user.id, now);

  await interaction.deferReply({ flags: MessageFlags.Ephemeral });

  let data = null;
  try {
    const res = await fetch(`https://yoursite.com/internal/earn-link?discord_id=${interaction.user.id}`, {
      headers: { Authorization: `Bearer ${process.env.BOT_API_SECRET}` },
      signal: AbortSignal.timeout(5000),
    });
    if (res.ok) data = await res.json();
  } catch {}

  const button = new ButtonBuilder().setStyle(ButtonStyle.Link);
  let text;
  if (data?.url?.startsWith('https://wall.sharklio.com/')) {
    button.setLabel('Open your earn page').setURL(data.url);
    text = 'This link is only for you. Turn off any VPN before you open it.';
  } else if (data?.reason === 'not_eligible') {
    button.setLabel('Account settings').setURL('https://yoursite.com/account');
    text = 'Earning is open to members aged 16 and over with a verified account.';
  } else {
    button.setLabel('Link your account').setURL('https://yoursite.com/account#discord');
    text = 'Link Discord to your account on our site first, then run /earn again.';
  }

  await interaction.editReply({ content: text, components: [new ActionRowBuilder().addComponents(button)] });
});

client.login(process.env.DISCORD_TOKEN);

The bot only needs the Guilds intent. The startsWith check stops a backend bug from making the bot hand out another URL. For option A, drop the fetch and always link your earn page.

discord.py 2.x

The same command in discord.py, using app_commands and its built-in per-user cooldown. Sync the tree in setup_hook, not in on_ready, which can fire many times. Syncing replaces the command list of your application, so run it from the bot that owns these commands only.

import os
import aiohttp
import discord
from discord import app_commands

GUILD = discord.Object(id=int(os.environ["GUILD_ID"]))

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

    async def setup_hook(self):
        await self.tree.sync(guild=GUILD)

bot = EarnBot()

@bot.tree.command(name="earn", description="Get your private link to earn coins", guild=GUILD)
@app_commands.guild_only()
@app_commands.checks.cooldown(1, 30.0, key=lambda i: i.user.id)
async def earn(interaction: discord.Interaction):
    await interaction.response.defer(ephemeral=True, thinking=True)
    data = None
    try:
        async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) as session:
            async with session.get(
                "https://yoursite.com/internal/earn-link",
                params={"discord_id": str(interaction.user.id)},
                headers={"Authorization": f"Bearer {os.environ['BOT_API_SECRET']}"},
            ) as resp:
                if resp.status == 200:
                    data = await resp.json()
    except aiohttp.ClientError:
        pass

    view = discord.ui.View()
    url = (data or {}).get("url") or ""
    if url.startswith("https://wall.sharklio.com/"):
        view.add_item(discord.ui.Button(label="Open your earn page", style=discord.ButtonStyle.link, url=url))
        text = "This link is only for you. Turn off any VPN before you open it."
    elif (data or {}).get("reason") == "not_eligible":
        view.add_item(discord.ui.Button(label="Account settings", style=discord.ButtonStyle.link, url="https://yoursite.com/account"))
        text = "Earning is open to members aged 16 and over with a verified account."
    else:
        view.add_item(discord.ui.Button(label="Link your account", style=discord.ButtonStyle.link, url="https://yoursite.com/account#discord"))
        text = "Link Discord to your account on our site first, then run /earn again."
    await interaction.followup.send(text, view=view, ephemeral=True)

@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    if isinstance(error, app_commands.CommandOnCooldown):
        await interaction.response.send_message(f"Try again in {error.retry_after:.0f} seconds.", ephemeral=True)

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

aiohttp is already installed as a dependency of discord.py. interaction.response.defer, followup.send, ButtonStyle.link and the cooldown check are all in the discord.py interactions reference. The 30 second cooldown in both examples is a sample value; pick what fits your server.

Step 4: credit coins from postbacks, then sync balance and roles

Coins move only on your server. When a member completes an offer, Sharklio sends a signed postback to the URL you set in the Postback tab, and your handler adds or takes back the reward:

StatusMeaningYour server
1CreditedAdd {reward} to the account in {user_id}, once per transaction_id
2ReversedTake it back, only if you credited that transaction

Verify the {hash} first: an HMAC-SHA256 of transaction ID, user ID, reward and status joined with colons, signed with your postback secret key, which is a different value from the link salt. Answer with a 2xx status within 6 seconds. Ready-made handlers are in Offerwall postback in Node.js and Offerwall postback in Python, the reasoning behind each check is in Postback security, and the full macro list is in the Postbacks docs. If postbacks are new to you, start with What is a postback URL?

The {user_id} in the postback is your site account ID, so the bot takes no part. A /balance command works like /earn: an ephemeral reply with the balance your backend returns for the linked account.

Milestone roles, such as a role at 10,000 lifetime coins, can be set by your backend after a credit, with Discord’s Add Guild Member Role endpoint and your bot token:

async function grantRole(discordId, roleId) {
  const res = await fetch(`https://discord.com/api/v10/guilds/${process.env.GUILD_ID}/members/${discordId}/roles/${roleId}`, {
    method: 'PUT',
    headers: {
      Authorization: `Bot ${process.env.DISCORD_TOKEN}`,
      'User-Agent': 'DiscordBot (https://yoursite.com, 1.0)',
    },
  });
  if (res.status === 429) {
    const { retry_after } = await res.json();
    setTimeout(() => grantRole(discordId, roleId), retry_after * 1000);
  }
}

The endpoint needs the Manage Roles permission, and Discord’s permission hierarchy only lets a bot work with roles below its own highest role, so place the bot’s role above the milestone roles and below your staff roles. Keep milestone roles cosmetic, and decide what a reversal below a milestone does. Pricing the coins themselves is covered in How to set your virtual currency exchange rate.

Discord rules your earn bot must follow

These are the rules that apply most directly, from Discord’s own pages and DISBOARD’s guidelines (checked on October 21, 2026). They change, so read the current versions before you launch.

RuleSourceWhat it means for your bot
Do not disclose API Data to advertising networks or monetization-related servicesDeveloper Policy, rule 17Your site account ID goes in the link, never the Discord ID
Do not target users with advertisements or marketingDeveloper Policy, rule 6The bot answers when asked; it never announces offers or lists them in channels
No unsolicited DMs, or DMs unrelated to the app’s functionDeveloper Policy, rule 5No DMs about offers or bonuses; members check /balance
No inflating membership; no automated messages to keep a server activeDeveloper Policy, rule 13No coins for joining, and no bot posts to look busy
Purchasing or inducing server joins, invite rewards and join-for-join are prohibitedPlatform Manipulation Policy explainerNo invite leaderboards with coin prizes, and no offers that require joining a server
You may not reward or force users to do actions in DISBOARD, and auto-bump is not allowedDISBOARD guidelinesNo coins for /bump or reviews; a cooldown reminder is fine
Not for people under 13 or the local minimum ageDeveloper Policy, rule 9Sharklio’s own limit is higher: 16, with parental permission under 18

Our reading, not a line Discord spells out: a link a member asked for is the bot doing its job, but a bot pinging everyone about a new offer, or listing offers in bot messages, is marketing inside Discord. Keep offers on your site, and announce the earn page yourself in a channel members follow. Promoting your offerwall lists what is allowed, and How to get more Discord members covers growth that does not touch rewards.

Private links, VPNs and rate limits

Never post signed links in public

The hash is an HMAC of the user ID only, so a signed link does not expire. Anyone who has it can open the offerwall as that member. Send it only in ephemeral replies, never in a channel, a thread or a log. If one leaks, rotating the link hash salt invalidates every old link; How to rotate your keys and link salt walks through it.

Tell members about VPNs before they click

The offerwall blocks VPNs, proxies, Tor and data center networks, because offers cannot track users through them. Members on one see a block page, which is why the reply text mentions it. For alt accounts, Stop multi-accounting and VPN abuse covers the layers that work for Discord communities.

Stay inside every rate limit

Discord’s rate limits page says a bot can make up to 50 requests per second, that interaction endpoints are not bound to that global limit, and that an IP which makes 10,000 invalid requests (401, 403 or 429) in 10 minutes is blocked for a while. Honor retry_after on every 429, as the role example does, and never retry in a tight loop. The per-member cooldown protects your backend, and the offerwall shows a Too many requests page when one network or user sends too many requests.

Step 5: test before members see it

  1. Create a test account on your site, confirm its age, and link it to a test Discord account.
  2. Run /earn in your server and check that only you see the reply.
  3. In Integration status on the Sharklio Integration tab, click Check a link, paste the link from the button, and compare it with Test a link for the same site account ID. They must match exactly.
  4. Open the link from a normal home or mobile connection and check that the offerwall loads, not the Link not valid page.
  5. Use Send a test postback with status 1 for the test account ID. /balance should go up once. Test transaction IDs start with test_, so a status 2 test is for a transaction you never credited and must change nothing.
  6. Run /earn from a Discord account that is not linked and from one linked to an under-16 site account, and check both get the right button.

Every postback attempt and your server’s answer is listed under Reports, Postbacks. How to test your app before it goes live and the Testing docs list the common errors and fixes.

A Sharklio offerwall behind your /earn command

Sharklio is a pay-per-result affiliate network, and its offerwall gives your community one signed link your server builds from the member’s site account, with your own coin name, icon, rate, user split and wall colors, plus a signed postback for every credit and every reversal. 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 and every app is reviewed by hand before the wall goes live. See how the wall works, check which Discord bots to run alongside it, or get in touch before you apply.

Frequently asked questions

Can a Discord bot have an offerwall?

Yes, as long as the offerwall lives on your website and the bot only gives members a private link when they ask for it. The bot answers a slash command with an ephemeral link button, and your server credits coins from postbacks.

Can I use a member’s Discord ID as the offerwall user ID?

No. Discord’s Developer Policy says apps must not disclose API Data to advertising or monetization-related services, and a Discord user ID your bot reads is API Data. Link the Discord account to an account on your site and use that account ID.

Can the offerwall open inside Discord?

Not as an embedded page. The iframe only loads on https pages of the website you registered for the app, so the bot sends a link button, which opens the earn page or the offerwall in the member’s browser.

Can I give coins for inviting people or bumping the server?

No. Discord’s Platform Manipulation Policy prohibits inducing server joins and invite rewards, and DISBOARD’s guidelines say you may not reward users for bumping or reviews.

Should the bot add coins when a member finishes an offer?

No. Only your server credits coins, from a postback whose hash you verified, once per transaction ID. The bot just shows the balance your backend returns.

Why does a member see “VPN detected” when they open the link?

The offerwall blocks VPNs, proxies, Tor and data center networks because offers cannot track users through them. The member needs to turn the VPN off or use mobile data.

How old do members have to be to use the earn command?

Discord requires users to be at least 13, or older where local law says so, but Sharklio requires end users to be at least 16, with parental permission under 18. Check age when members sign up on your site, not through Discord.