Sistema de candidatura no discord.js v14 (ou sem código)
Como fazer um sistema de candidatura no discord.js v14? Você precisa de quatro peças: um painel com um botão Candidatar-se, um modal (a janela de formulário do Discord, com 5 campos no máximo por janela, então formulários mais longos precisam encadear várias), uma ficha publicada num canal da staff com botões Aceitar e Recusar, e, ao aceitar, member.roles.add mais uma DM para o candidato. Este guia traz um esqueleto curto e funcionando, a lista do que você ainda vai ter que construir em volta dele, e o caminho sem código se você não quiser manter um bot.
O que você precisa programar
| Peça | API do discord.js v14 |
|---|---|
| Painel com um botão de candidatura | ButtonBuilder, ActionRowBuilder, channel.send |
| O formulário | ModalBuilder, TextInputBuilder, interaction.showModal |
| Ler as respostas | interaction.isModalSubmit(), interaction.fields.getTextInputValue |
| Ficha da staff com botões | EmbedBuilder, ButtonBuilder com o ID do candidato no customId |
| A decisão | member.roles.add, member.send, interaction.update |
Um esqueleto mínimo em discord.js v14
Um arquivo, npm install discord.js, Node 18 ou mais novo. Preencha os três IDs e defina DISCORD_TOKEN. O bot só precisa do intent Guilds. Os textos mostrados pelo código estão em inglês: troque pelos seus.
// 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'; // canal com o botão Apply
const STAFF_CHANNEL_ID = '000000000000000000'; // canal privado da staff
const ROLE_ID = '000000000000000000'; // cargo dado ao aceitar
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Publica o painel ao iniciar: rode uma vez e depois tire isto
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. O botão Apply abre o formulário (5 campos no máximo por 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. Formulário enviado: publica uma ficha no canal da staff
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 ou Decline na ficha da staff
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); // o cargo do bot precisa estar acima de ROLE_ID
} catch {
await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
return;
}
}
// O candidato pode ter fechado a DM: ignora o erro
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);
O ID do candidato vai no customId do botão, então a decisão continua funcionando depois de reiniciar o bot. Todo o resto fica só na mensagem da staff.
O que você ainda vai ter que construir
O esqueleto funciona, mas um sistema de candidatura de verdade precisa de mais:
- Armazenamento. Guarde as candidaturas num banco de dados, não só em mensagens do Discord, para pesquisar, exportar e não perder nada quando uma mensagem for apagada.
- Uma candidatura pendente por membro, pra ninguém mandar o mesmo formulário dez vezes.
- Um tempo de espera depois de uma recusa, guardado por membro e por formulário.
- Mais de 5 perguntas. O envio de um modal não pode abrir outro modal: responda com um botão Continuar que abre a próxima janela, e guarde as respostas entre as etapas.
- Permissões da staff. Aqui só quem tem Gerenciar servidor decide; você vai querer cargos da staff por formulário.
- Ordem dos cargos. O Discord recusa
roles.addpara um cargo acima do cargo do bot: detecte isso e diga pra staff o que corrigir. - DM fechada. Avise a staff quando a DM não pôde ser enviada, pra ela avisar o candidato por outro caminho.
- Hospedagem. Um processo rodando 24/7, que reinicia quando cai e é atualizado quando o discord.js muda.
O que o Toscio faz sem código
Se você prefere não manter tudo isso, o nosso guia sem código mostra o mesmo fluxo com o Toscio: /toscio setup, um modelo, e o painel está no ar. Tudo da lista acima já está resolvido: formulários nativos do Discord encadeados de cinco em cinco perguntas, uma candidatura pendente por membro, tempo de espera, cargos da staff, verificação da ordem dos cargos e DM fechada sinalizada na ficha.
Além disso, vêm as notas da staff e o histórico do candidato em cada ficha. O plano grátis inclui o quiz com nota, a etapa antes da aceitação (entrevista, período de teste) e o acesso da staff ao dashboard, com 2 formulários de 10 perguntas.
Mantenha o seu script, ligue um webhook (Premium)
Já tem um script, uma planilha ou um CAD/MDT que precisa das candidaturas? Mantenha, e deixe o Toscio mandar os eventos pra ele. No editor de formulário, abra “Webhook (integrações)” nas configurações avançadas e informe um endereço https. O Toscio envia:
| Evento | Quando |
|---|---|
application.submitted |
uma candidatura é recebida |
application.staged |
ela passa para a etapa antes da aceitação |
application.accepted |
ela é aceita |
application.refused |
ela é recusada |
ping |
você clica em “Enviar um teste” (application vale null) |
O corpo é 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" }]
}
}
Cada requisição traz dois cabeçalhos: X-Toscio-Timestamp (horário Unix, em segundos) e X-Toscio-Signature, que é sha256= seguido do HMAC-SHA256 em hexadecimal de ${timestamp}.${corpo bruto}, calculado com o “Segredo de assinatura” do formulário. Confira antes de confiar no corpo:
// npm install express
const express = require('express');
const crypto = require('node:crypto');
const SECRET = process.env.TOSCIO_SIGNING_SECRET; // o "Segredo de assinatura" do editor de formulário
const app = express();
// Corpo bruto: a assinatura é calculada sobre os bytes exatos recebidos
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; // recusa requisições com mais de 5 minutos
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); // atrás de um proxy reverso que serve https na porta 443
Limites que você precisa saber:
- Só endereços https públicos: um nome de domínio na porta 443, sem endereço IP e sem nome local.
- Redirecionamentos não são seguidos, e o Toscio espera 5 segundos no máximo por uma resposta.
- Sem nova tentativa: uma requisição que falhou não é enviada de novo.
- O segredo fica escondido dos membros da staff sem Gerenciar servidor.
Os webhooks fazem parte do Premium (R$ 14,90/mês ou R$ 149/ano por servidor, no cartão ou no Pix), assim como a votação da staff, o formulário web e a apelação de ban.
Perguntas
Como fazer um sistema de candidatura no discord.js v14?
Publique uma mensagem com um botão, abra um modal (ModalBuilder com campos TextInputBuilder) quando ele for clicado, leia as respostas no envio do modal, publique tudo num canal da staff com botões Accept e Decline, depois dê o cargo com member.roles.add e mande uma DM ao candidato. O script desta página faz exatamente isso em umas 70 linhas.
Um modal do Discord pode ter mais de 5 perguntas?
Não. Um modal tem no máximo 5 campos. Para formulários mais longos, você encadeia vários modais, e como o envio de um modal não pode abrir outro modal direto, você responde com um botão Continuar que abre o próximo, guardando as respostas entre um e outro.
Como verificar a assinatura de um webhook do Toscio?
Calcule o HMAC-SHA256 do cabeçalho X-Toscio-Timestamp, um ponto e o corpo bruto da requisição, com o segredo de assinatura do seu formulário, em hexadecimal. Coloque sha256= na frente e compare com X-Toscio-Signature usando uma comparação em tempo constante. Recuse também as requisições com timestamp antigo demais.
O Toscio tenta de novo quando o webhook falha?
Não. Cada evento é enviado uma vez, com limite de 5 segundos. Se o seu endereço estiver fora do ar ou lento demais, esse evento se perde; a candidatura em si é processada normalmente. Use o dashboard ou a exportação CSV para recuperar.
Existe bot de candidatura para Discord open source?
Existe, você encontra vários no GitHub, que você hospeda e mantém sozinho. O Toscio é um bot hospedado: nada pra colocar no ar, e se você ainda quer o seu próprio código no meio, os webhooks dele mandam cada evento de candidatura para o seu script.



