Système de candidature en discord.js v14 : le code, ou sans code

Par l’équipe Toscio · Mis à jour 22 octobre 2026 · 6 min de lecture

Comment faire un système de candidature en discord.js v14 ? Il te faut quatre morceaux : un panneau avec un bouton Postuler, une fenêtre (modal : le formulaire pop-up de Discord, 5 champs au maximum, donc il faut en enchaîner plusieurs au-delà), une fiche postée dans un salon du staff avec des boutons Accepter et Refuser, et à l’acceptation member.roles.add plus un MP au candidat. Ce guide te donne un squelette court qui fonctionne, la liste de ce qu’il reste à construire autour, et la solution sans code si tu préfères ne pas maintenir un bot.

Ce qu’il faut coder

Morceau API discord.js v14
Panneau avec un bouton Postuler ButtonBuilder, ActionRowBuilder, channel.send
Le formulaire ModalBuilder, TextInputBuilder, interaction.showModal
Lire les réponses interaction.isModalSubmit(), interaction.fields.getTextInputValue
Fiche du staff avec boutons EmbedBuilder, ButtonBuilder avec l’identifiant du candidat dans le customId
La décision member.roles.add, member.send, interaction.update

Un squelette discord.js v14 minimal

Un seul fichier, npm install discord.js, Node 18 ou plus récent. Remplis les trois identifiants et définis DISCORD_TOKEN. Le bot n’a besoin que de l’intent Guilds. Le code est le même que dans la version anglaise : seuls les commentaires sont traduits.

// 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'; // salon du bouton Postuler
const STAFF_CHANNEL_ID = '000000000000000000'; // salon privé du staff
const ROLE_ID = '000000000000000000'; // rôle donné à l'acceptation

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

// Publie le panneau au démarrage : à lancer une fois, puis à retirer
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. Le bouton Postuler ouvre le formulaire (5 champs max par fenêtre)
  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. Le formulaire est envoyé : on poste une fiche dans le salon du 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. Accepter ou Refuser sur la fiche du 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); // le rôle du bot doit être au-dessus de ROLE_ID
      } catch {
        await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
        return;
      }
    }
    // Le candidat a peut-être fermé ses MP : on ignore l'erreur
    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);

L’identifiant du candidat voyage dans le customId du bouton : la décision marche encore après un redémarrage. Tout le reste ne vit que dans le message du staff. Traduis les textes affichés ('Apply', 'Staff application'…) dans la langue de ton serveur.

Ce qu’il te reste à construire

Le squelette fonctionne, mais un vrai système de candidature demande plus :

  • Le stockage. Garde les candidatures dans une base de données, pas seulement dans des messages Discord, pour les retrouver, les exporter et les garder si un message est supprimé.
  • Une seule candidature en attente par membre, pour que personne n’envoie dix fois le même formulaire.
  • Un délai après un refus, enregistré par membre et par formulaire.
  • Plus de 5 questions. L’envoi d’une fenêtre ne peut pas en ouvrir une autre : réponds avec un bouton Continuer qui ouvre la fenêtre suivante, et garde les réponses entre deux étapes.
  • Les permissions du staff. Ici, seuls ceux qui ont « Gérer le serveur » décident ; il te faudra des rôles staff par formulaire.
  • L’ordre des rôles. Discord refuse roles.add pour un rôle placé au-dessus de celui du bot : détecte-le et dis au staff quoi corriger.
  • Les MP fermés. Préviens le staff quand le MP n’a pas pu partir, pour qu’il prévienne le candidat autrement.
  • L’hébergement. Un processus qui tourne 24 h/24, redémarre après un crash, et qu’il faut mettre à jour quand discord.js change.

Ce que Toscio fait sans code

Si tu préfères ne pas maintenir tout ça, notre guide sans code montre le même parcours avec Toscio : /toscio setup, un modèle, et le panneau est en ligne. Toute la liste ci-dessus est déjà gérée : formulaires Discord natifs enchaînés cinq questions par cinq, une candidature en attente par membre, un délai après un refus, des rôles staff, la vérification de l’ordre des rôles et les MP fermés signalés sur la fiche.

En plus, chaque fiche a les notes du staff et l’historique du candidat. L’offre gratuite comprend le quiz noté, l’étape avant l’acceptation (douane, période d’essai) et l’accès du staff au dashboard, avec 2 formulaires de 10 questions.

Garder ton script, brancher un webhook (Premium)

Tu as déjà un script, un tableur ou un CAD/MDT qui doit recevoir les candidatures ? Garde-le, et laisse Toscio lui envoyer les événements. Dans l’éditeur de formulaire, ouvre « Webhook (intégrations) » dans les réglages avancés et indique une adresse https. Toscio envoie :

Événement Quand
application.submitted une candidature est reçue
application.staged elle passe à l’étape avant l’acceptation
application.accepted elle est acceptée
application.refused elle est refusée
ping tu cliques sur « Envoyer un test » (application vaut null)

Le corps est en 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" }]
  }
}

Chaque requête porte deux en-têtes : X-Toscio-Timestamp (heure Unix, en secondes) et X-Toscio-Signature, qui vaut sha256= suivi du HMAC-SHA256 hexadécimal de ${horodatage}.${corps brut}, calculé avec le « Secret de signature » du formulaire. Vérifie-la avant de faire confiance au corps :

// npm install express
const express = require('express');
const crypto = require('node:crypto');

const SECRET = process.env.TOSCIO_SIGNING_SECRET; // le « Secret de signature » de l'éditeur de formulaire
const app = express();

// Corps brut : la signature est calculée sur les octets exacts reçus
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 les requêtes de plus de 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); // derrière un proxy inverse qui sert https sur le port 443

Les limites à connaître :

  • Adresses https publiques uniquement : un nom de domaine sur le port 443, ni adresse IP ni nom local.
  • Les redirections ne sont pas suivies, et Toscio attend 5 secondes au plus une réponse.
  • Pas de nouvel essai : un envoi raté n’est pas renvoyé.
  • Le secret est masqué pour les membres du staff qui n’ont pas « Gérer le serveur ».

Les webhooks font partie du Premium (4,99 €/mois ou 49,90 €/an par serveur), comme les votes du staff, le formulaire web et les recours de ban.

Questions

Comment faire un système de candidature en discord.js v14 ?

Publie un message avec un bouton, ouvre une fenêtre (ModalBuilder avec des champs TextInputBuilder) quand on clique, lis les réponses à l’envoi, poste-les dans un salon du staff avec des boutons Accepter et Refuser, puis donne le rôle avec member.roles.add et envoie un MP au candidat. Le script de cette page fait exactement ça en 70 lignes environ.

Une fenêtre (modal) Discord peut-elle avoir plus de 5 questions ?

Non. Une fenêtre accepte 5 champs au maximum. Pour un formulaire plus long, il faut en enchaîner plusieurs, et comme l’envoi d’une fenêtre ne peut pas en ouvrir une autre directement, tu réponds avec un bouton Continuer qui ouvre la suivante, en gardant les réponses entre deux.

Comment vérifier la signature d’un webhook Toscio ?

Calcule le HMAC-SHA256 de l’en-tête X-Toscio-Timestamp, d’un point et du corps brut de la requête, avec le secret de signature du formulaire, en hexadécimal. Ajoute sha256= devant et compare avec X-Toscio-Signature par une comparaison à temps constant. Refuse aussi les requêtes dont l’horodatage est trop ancien.

Toscio renvoie-t-il un webhook qui a échoué ?

Non. Chaque événement part une fois, avec un délai de 5 secondes. Si ton adresse est en panne ou trop lente, cet événement est perdu ; la candidature, elle, est traitée normalement. Rattrape-toi avec le dashboard ou l’export CSV.

Existe-t-il un bot de candidature Discord open source ?

Oui, on en trouve plusieurs sur GitHub, que tu héberges et maintiens toi-même. Toscio est au contraire un bot hébergé : rien à déployer, et si tu veux quand même ton propre code dans la boucle, ses webhooks envoient chaque événement de candidature à ton script.

Ta prochaine candidature est à deux minutes.

Ajoute Toscio, lance /toscio setup, choisis un modèle. C’est tout.