How to make an application system in discord.js v14 (or skip the code)
How to make an application system in discord.js v14? You need four pieces: a panel with an Apply button, a modal (Discord's pop-up form, 5 fields at most per window, so longer forms must chain several), a card posted in a staff channel with Accept and Decline buttons, and on acceptance member.roles.add plus a DM to the applicant. This guide gives you a short, working skeleton, the list of what you still have to build around it, and the no-code route if you'd rather not maintain a bot.
What you need to code
| Piece | discord.js v14 API |
|---|---|
| Panel with an Apply button | ButtonBuilder, ActionRowBuilder, channel.send |
| The form | ModalBuilder, TextInputBuilder, interaction.showModal |
| Reading the answers | interaction.isModalSubmit(), interaction.fields.getTextInputValue |
| Staff card with buttons | EmbedBuilder, ButtonBuilder with the applicant's ID in the customId |
| The decision | member.roles.add, member.send, interaction.update |
A minimal discord.js v14 skeleton
One file, npm install discord.js, Node 18 or later. Fill in the three IDs and set DISCORD_TOKEN. The bot only needs the Guilds intent.
// discord.js v14: npm install discord.js
const {
Client, GatewayIntentBits, Events, ActionRowBuilder, ButtonBuilder, ButtonStyle,
ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits,
} = require('discord.js');
const APPLY_CHANNEL_ID = '000000000000000000'; // channel with the Apply button
const STAFF_CHANNEL_ID = '000000000000000000'; // private staff channel
const ROLE_ID = '000000000000000000'; // role given on acceptance
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Posts the panel at startup: run it once, then remove it
client.once(Events.ClientReady, async (c) => {
const channel = await c.channels.fetch(APPLY_CHANNEL_ID);
const button = new ButtonBuilder().setCustomId('apply').setLabel('Apply').setStyle(ButtonStyle.Primary);
await channel.send({ content: 'Want to join the staff? Click below.', components: [new ActionRowBuilder().addComponents(button)] });
});
client.on(Events.InteractionCreate, async (interaction) => {
// 1. The Apply button opens the form (5 fields max per modal)
if (interaction.isButton() && interaction.customId === 'apply') {
const age = new TextInputBuilder().setCustomId('age').setLabel('How old are you?')
.setStyle(TextInputStyle.Short).setMaxLength(3).setRequired(true);
const why = new TextInputBuilder().setCustomId('why').setLabel('Why do you want to join the staff?')
.setStyle(TextInputStyle.Paragraph).setMaxLength(1000).setRequired(true);
const modal = new ModalBuilder().setCustomId('apply-form').setTitle('Staff application')
.addComponents(new ActionRowBuilder().addComponents(age), new ActionRowBuilder().addComponents(why));
await interaction.showModal(modal);
return;
}
// 2. The form is sent: post a card in the staff channel
if (interaction.isModalSubmit() && interaction.customId === 'apply-form') {
const embed = new EmbedBuilder().setTitle(`Application from ${interaction.user.username}`).addFields(
{ name: 'Age', value: interaction.fields.getTextInputValue('age') },
{ name: 'Why', value: interaction.fields.getTextInputValue('why') },
);
const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(`accept:${interaction.user.id}`).setLabel('Accept').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`decline:${interaction.user.id}`).setLabel('Decline').setStyle(ButtonStyle.Danger),
);
const staff = await interaction.client.channels.fetch(STAFF_CHANNEL_ID);
await staff.send({ embeds: [embed], components: [buttons] });
await interaction.reply({ content: 'Application sent!', flags: MessageFlags.Ephemeral });
return;
}
// 3. Accept or Decline on the staff card
if (interaction.isButton() && /^(accept|decline):\d+$/.test(interaction.customId)) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
await interaction.reply({ content: 'Staff only.', flags: MessageFlags.Ephemeral });
return;
}
const [action, userId] = interaction.customId.split(':');
const member = await interaction.guild.members.fetch(userId).catch(() => null);
if (!member) {
await interaction.reply({ content: 'This member left the server.', flags: MessageFlags.Ephemeral });
return;
}
if (action === 'accept') {
try {
await member.roles.add(ROLE_ID); // the bot's role must be above ROLE_ID
} catch {
await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
return;
}
}
// The applicant may have closed their DMs: ignore the error
await member.send(action === 'accept' ? 'Your application was accepted!' : 'Your application was declined.').catch(() => {});
await interaction.update({ content: `${action === 'accept' ? '✅ Accepted' : '❌ Declined'} by ${interaction.user}`, components: [] });
}
});
client.login(process.env.DISCORD_TOKEN);
The applicant's ID travels in the button's customId, so the decision still works after a restart. Everything else lives only in the staff message.
What's left to build yourself
The skeleton works, but a real application system needs more:
- Storage. Keep applications in a database, not only in Discord messages, to search them, export them and keep them after a deleted message.
- One pending application per member, so nobody sends the same form ten times.
- A cooldown after a decline, stored per member and per form.
- More than 5 questions. A modal submit can't open another modal: reply with a Continue button that opens the next window, and save the answers between steps.
- Staff permissions. Here only Manage Server can decide; you'll want staff roles per form.
- Role order. Discord refuses
roles.addfor a role above the bot's own: detect it and tell staff what to fix. - Closed DMs. Tell staff when the DM couldn't be sent, so they warn the applicant another way.
- Hosting. A process that runs 24/7, restarts on crash, and gets updated when discord.js changes.
What Toscio does without code
If you'd rather not maintain all that, our no-code guide shows the same flow with Toscio: /toscio setup, a template, and the panel is live. Everything in the list above is already handled: native Discord forms chained five questions at a time, one pending application per member, a cooldown, staff roles, role order checks and closed DMs flagged on the card.
On top of that come staff notes and applicant history on every card. The free plan includes the graded quiz, the stage before acceptance (interview, trial period) and staff access to the dashboard, with 2 forms of 10 questions.
Keep your script, plug in a webhook (Premium)
Already have a script, a spreadsheet or a CAD/MDT that needs the applications? Keep it, and let Toscio send it the events. In the form editor, open "Webhook (integrations)" in the advanced settings and enter an https address. Toscio sends:
| Event | When |
|---|---|
application.submitted |
an application is received |
application.staged |
it moves to the stage before acceptance |
application.accepted |
it is accepted |
application.refused |
it is declined |
ping |
you click "Send a test" (application is null) |
The body is JSON:
{
"event": "application.accepted",
"sentAt": "2026-10-22T18:04:11.000Z",
"guildId": "123456789012345678",
"form": { "id": 42, "name": "Staff recruitment" },
"application": {
"id": 1337,
"status": "accepted",
"userId": "234567890123456789",
"username": "alex",
"submittedAt": "2026-10-21T09:12:00.000Z",
"decidedAt": "2026-10-22T18:04:10.000Z",
"reviewerId": "345678901234567890",
"reason": null,
"stage": null,
"quiz": { "score": 3, "total": 4, "passed": true, "passPercent": 75 },
"web": false,
"answers": [{ "question": "How old are you?", "answer": "19" }]
}
}
Each request carries two headers: X-Toscio-Timestamp (Unix time, in seconds) and X-Toscio-Signature, which is sha256= followed by the hexadecimal HMAC-SHA256 of ${timestamp}.${raw body}, computed with the form's "Signing secret". Check it before trusting the body:
// npm install express
const express = require('express');
const crypto = require('node:crypto');
const SECRET = process.env.TOSCIO_SIGNING_SECRET; // the "Signing secret" from the form editor
const app = express();
// Raw body: the signature is computed on the exact bytes received
app.post('/toscio-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Toscio-Timestamp') ?? '';
const signature = Buffer.from(req.get('X-Toscio-Signature') ?? '');
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex'));
const valid = signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
const recent = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // refuse requests older than 5 minutes
if (!valid || !recent) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
if (payload.event === 'application.accepted') {
console.log(`${payload.application.username} accepted on ${payload.form.name}`);
}
res.sendStatus(204);
});
app.listen(3000); // behind a reverse proxy that serves https on port 443
Limits to know:
- Public https addresses only: a domain name on port 443, no IP address and no local name.
- Redirects aren't followed, and Toscio waits 5 seconds at most for an answer.
- No retry: a failed request is not sent again.
- The secret is hidden from staff members without Manage Server.
Webhooks are part of Premium ($4.99/month or $49.90/year per server), like staff votes, the web form and ban appeals.
Questions
How do I make an application system in discord.js v14?
Post a message with a button, open a modal (ModalBuilder with TextInputBuilder fields) when it's clicked, read the answers on the modal submit, post them to a staff channel with Accept and Decline buttons, then give the role with member.roles.add and DM the applicant. The script on this page does exactly that in about 70 lines.
Can a Discord modal have more than 5 questions?
No. A modal holds 5 fields at most. For longer forms you chain several modals, and since a modal submit can't open another modal directly, you reply with a Continue button that opens the next one, saving the answers in between.
How do I verify a Toscio webhook signature?
Compute HMAC-SHA256 of the X-Toscio-Timestamp header, a dot and the raw request body, with your form's signing secret, in hexadecimal. Prefix it with sha256= and compare it to X-Toscio-Signature with a constant-time comparison. Also refuse requests whose timestamp is too old.
Does Toscio retry failed webhooks?
No. Each event is sent once, with a 5-second timeout. If your endpoint is down or too slow, that event is lost; the application itself is processed normally. Use the dashboard or the CSV export to catch up.
Is there an open source Discord application bot?
Yes, you'll find several on GitHub, which you host and maintain yourself. Toscio is a hosted bot instead: nothing to deploy, and if you still want your own code in the loop, its webhooks send each application event to your script.



