Discord Bot Example
A complete Discord.js bot that lets server members sign XRPL transactions with any supported wallet via slash commands.
💡
This example uses polling for simplicity. For production bots with many users, use webhooks to avoid holding interaction responses open.
Prerequisites
- Node.js 18+
- A Discord application + bot token (Discord Developer Portal)
- An XRPL Request API key from xrplre.quest/dashboard
Install dependencies
npm install discord.js @xrplrequest/sdk
npm install -D typescript @types/node tsxFull example
// bot.ts
import {
Client,
GatewayIntentBits,
REST,
Routes,
SlashCommandBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
ComponentType,
} from 'discord.js';
import { XRPLRequest } from '@xrplrequest/sdk';
const DISCORD_TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.DISCORD_CLIENT_ID!;
const XRPL_API_KEY = process.env.XRPL_REQUEST_API_KEY!;
const discord = new Client({ intents: [GatewayIntentBits.Guilds] });
const xrpl = new XRPLRequest({ apiKey: XRPL_API_KEY });
// Register slash commands
const commands = [
new SlashCommandBuilder()
.setName('tip')
.setDescription('Tip someone XRP')
.addStringOption((o) =>
o.setName('destination').setDescription('Destination XRPL address').setRequired(true)
)
.addIntegerOption((o) =>
o.setName('amount').setDescription('Amount in XRP').setRequired(true)
),
new SlashCommandBuilder()
.setName('verify')
.setDescription('Verify ownership of your XRPL wallet'),
].map((c) => c.toJSON());
const rest = new REST({ version: '10' }).setToken(DISCORD_TOKEN);
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
// Handle interactions
discord.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'tip') {
await handleTip(interaction);
} else if (interaction.commandName === 'verify') {
await handleVerify(interaction);
}
});
async function handleTip(interaction: any) {
const destination = interaction.options.getString('destination', true);
const amountXrp = interaction.options.getInteger('amount', true);
// Create the signing payload
const payload = await xrpl.payloads.create({
type: 'signAndSubmit',
transaction: {
TransactionType: 'Payment',
Destination: destination,
Amount: String(amountXrp * 1_000_000), // XRP → drops
},
options: {
expiresIn: 300,
customInstructions: `Tip of ${amountXrp} XRP via Discord`,
},
});
// Build a link button
const button = new ButtonBuilder()
.setLabel('Sign Transaction')
.setStyle(ButtonStyle.Link)
.setURL(payload.signingUrl);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
// Defer so we have time to poll (up to 15 min with followUp)
await interaction.reply({
content: `💸 Please sign to send **${amountXrp} XRP** to \`${destination}\`\n\nExpires in 5 minutes.`,
components: [row],
ephemeral: true,
});
// Poll for result
const result = await xrpl.payloads.poll(payload.uuid, {
timeout: 300_000,
interval: 3_000,
});
if (result.status === 'signed') {
await interaction.editReply({
content: `✅ **Sent!** ${amountXrp} XRP → \`${destination}\`\nTx: \`${result.txHash}\``,
components: [],
});
} else if (result.status === 'rejected') {
await interaction.editReply({
content: '❌ Transaction rejected.',
components: [],
});
} else {
await interaction.editReply({
content: '⏱ Signing request expired.',
components: [],
});
}
}
async function handleVerify(interaction: any) {
const payload = await xrpl.payloads.create({
type: 'connect',
options: {
expiresIn: 180,
customInstructions: 'Verify your XRPL wallet ownership for this Discord server.',
},
});
const button = new ButtonBuilder()
.setLabel('Verify Wallet')
.setStyle(ButtonStyle.Link)
.setURL(payload.signingUrl);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await interaction.reply({
content: '🔐 Click below to verify your XRPL wallet. Expires in 3 minutes.',
components: [row],
ephemeral: true,
});
const result = await xrpl.payloads.poll(payload.uuid, {
timeout: 180_000,
interval: 3_000,
});
if (result.status === 'signed') {
await interaction.editReply({
content: `✅ Wallet verified! Your address: \`${result.signerAddress}\``,
components: [],
});
} else {
await interaction.editReply({
content: '❌ Verification cancelled or expired.',
components: [],
});
}
}
discord.login(DISCORD_TOKEN);Environment variables
DISCORD_TOKEN=your-bot-token
DISCORD_CLIENT_ID=your-application-id
XRPL_REQUEST_API_KEY=xrplr_live_...Running the bot
npx tsx bot.tsUsing webhooks instead of polling
For bots with many concurrent users, polling ties up resources. Use webhooks to handle results asynchronously:
// 1. Create the payload and post the link (same as above)
const payload = await xrpl.payloads.create({ ... });
await interaction.reply({ content: payload.signingUrl });
// Store the mapping: payload.uuid → Discord interaction token
pendingPayloads.set(payload.uuid, interaction.token);
// 2. In your webhook handler, look up the interaction and edit it
app.post('/xrplrequest-webhook', async (req, res) => {
// ... verify signature ...
const { event, payload } = req.body;
const token = pendingPayloads.get(payload.uuid);
if (!token) return res.sendStatus(200);
const message = event === 'payload.signed'
? `✅ Tx: \`${payload.txHash}\``
: event === 'payload.rejected' ? '❌ Rejected.' : '⏱ Expired.';
await rest.patch(Routes.webhookMessage(CLIENT_ID, token, '@original'), {
body: { content: message, components: [] },
});
res.sendStatus(200);
});Discord interaction tokens are valid for 15 minutes. If your payload TTL exceeds that, store enough state to send a DM or channel message instead.