discord.js Bewerbungssystem in v14 (oder ganz ohne Code)
Wie baust du ein Bewerbungssystem mit discord.js v14? Du brauchst vier Teile: ein Panel mit einem Bewerben-Button, ein Modal (das Pop-up-Formular von Discord, höchstens 5 Felder pro Fenster, längere Formulare müssen also mehrere hintereinanderhängen), eine Karte im Team-Kanal mit den Buttons Annehmen und Ablehnen und bei Annahme member.roles.add plus eine DM an den Bewerber. Dieser Ratgeber liefert dir ein kurzes, lauffähiges Grundgerüst, die Liste dessen, was du drumherum noch bauen musst, und den Weg ohne Code, falls du keinen Bot pflegen willst.
Was du programmieren musst
| Teil | discord.js-v14-API |
|---|---|
| Panel mit Bewerben-Button | ButtonBuilder, ActionRowBuilder, channel.send |
| Das Formular | ModalBuilder, TextInputBuilder, interaction.showModal |
| Die Antworten auslesen | interaction.isModalSubmit(), interaction.fields.getTextInputValue |
| Team-Karte mit Buttons | EmbedBuilder, ButtonBuilder mit der ID des Bewerbers in der customId |
| Die Entscheidung | member.roles.add, member.send, interaction.update |
Ein minimales Grundgerüst mit discord.js v14
Eine Datei, npm install discord.js, Node 18 oder neuer. Trag die drei IDs ein und setz DISCORD_TOKEN. Der Bot braucht nur den Intent Guilds. Die Texte im Code sind auf Englisch; übersetz sie für deinen Server.
// 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'; // Kanal mit dem Apply-Button
const STAFF_CHANNEL_ID = '000000000000000000'; // privater Team-Kanal
const ROLE_ID = '000000000000000000'; // Rolle, die bei Annahme vergeben wird
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Postet das Panel beim Start: einmal ausführen, dann entfernen
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. Der Apply-Button öffnet das Formular (höchstens 5 Felder pro 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. Das Formular ist abgeschickt: Karte im Team-Kanal posten
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 oder Decline auf der Karte im Team-Kanal
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); // die Rolle des Bots muss über ROLE_ID stehen
} catch {
await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
return;
}
}
// Der Bewerber hat vielleicht seine DMs geschlossen: Fehler ignorieren
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);
Die ID des Bewerbers steckt in der customId des Buttons, die Entscheidung funktioniert also auch nach einem Neustart. Alles andere lebt nur in der Nachricht im Team-Kanal.
Was du noch selbst bauen musst
Das Grundgerüst funktioniert, aber ein echtes Bewerbungssystem braucht mehr:
- Speicherung. Leg Bewerbungen in einer Datenbank ab, nicht nur in Discord-Nachrichten, um sie zu durchsuchen, zu exportieren und nach einer gelöschten Nachricht noch zu haben.
- Eine offene Bewerbung pro Mitglied, damit niemand dasselbe Formular zehnmal abschickt.
- Eine Wartezeit nach einer Absage, gespeichert pro Mitglied und pro Formular.
- Mehr als 5 Fragen. Das Absenden eines Modals kann kein weiteres Modal öffnen: Antworte mit einem Weiter-Button, der das nächste Fenster öffnet, und speichere die Antworten zwischen den Schritten.
- Rechte fürs Team. Hier kann nur „Server verwalten“ entscheiden; du willst Teamrollen pro Formular.
- Rollenreihenfolge. Discord lehnt
roles.addfür eine Rolle über der eigenen Rolle des Bots ab: Erkenn das und sag dem Team, was es korrigieren muss. - Geschlossene DMs. Sag dem Team, wenn die DM nicht zugestellt werden konnte, damit es den Bewerber auf anderem Weg informiert.
- Hosting. Ein Prozess, der rund um die Uhr läuft, nach einem Absturz neu startet und aktualisiert wird, wenn sich discord.js ändert.
Was Toscio ohne Code erledigt
Willst du das alles nicht pflegen, zeigt unsere Anleitung ohne Code denselben Ablauf mit Toscio: /toscio setup, eine Vorlage, und das Panel ist online. Alles aus der Liste oben ist schon erledigt: native Discord-Formulare, je fünf Fragen hintereinander, eine offene Bewerbung pro Mitglied, eine Wartezeit, Teamrollen, die Prüfung der Rollenreihenfolge und geschlossene DMs, die auf der Karte angezeigt werden.
Dazu kommen Team-Notizen und Bewerberverlauf auf jeder Karte. Der kostenlose Plan enthält das bewertete Quiz, die Stufe vor der Annahme (Gespräch, Probezeit) und den Dashboard-Zugang fürs Team, mit 2 Formularen zu je 10 Fragen.
Dein Skript behalten, einen Webhook anschließen (Premium)
Du hast schon ein Skript, eine Tabelle oder ein CAD/MDT, das die Bewerbungen braucht? Behalt es und lass Toscio ihm die Ereignisse schicken. Öffne im Formular-Editor unter den erweiterten Einstellungen „Webhook (Integrationen)“ und gib eine https-Adresse ein. Toscio sendet:
| Ereignis | Wann |
|---|---|
application.submitted |
eine Bewerbung ist eingegangen |
application.staged |
sie kommt in die Stufe vor der Annahme |
application.accepted |
sie wird angenommen |
application.refused |
sie wird abgelehnt |
ping |
du klickst auf „Test senden“ (application ist null) |
Der Body ist 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" }]
}
}
Jede Anfrage hat zwei Header: X-Toscio-Timestamp (Unix-Zeit, in Sekunden) und X-Toscio-Signature, also sha256= gefolgt vom hexadezimalen HMAC-SHA256 von ${timestamp}.${roher Body}, berechnet mit dem „Signatur-Secret“ des Formulars. Prüf sie, bevor du dem Body vertraust:
// npm install express
const express = require('express');
const crypto = require('node:crypto');
const SECRET = process.env.TOSCIO_SIGNING_SECRET; // das „Signatur-Secret“ aus dem Formular-Editor
const app = express();
// Roher Body: Die Signatur wird über genau die empfangenen Bytes berechnet
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; // Anfragen ablehnen, die älter als 5 Minuten sind
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); // hinter einem Reverse Proxy, der https auf Port 443 ausliefert
Grenzen, die du kennen solltest:
- Nur öffentliche https-Adressen: ein Domainname auf Port 443, keine IP-Adresse und kein lokaler Name.
- Weiterleitungen werden nicht verfolgt, und Toscio wartet höchstens 5 Sekunden auf eine Antwort.
- Kein neuer Versuch: Eine fehlgeschlagene Anfrage wird nicht erneut gesendet.
- Das Secret ist verborgen für Teammitglieder ohne „Server verwalten“.
Webhooks gehören zu Premium (4,99 €/Monat oder 49,90 €/Jahr pro Server), genau wie Team-Abstimmungen, das Webformular und Entbannungsanträge.
Fragen
Wie baue ich ein Bewerbungssystem mit discord.js v14?
Poste eine Nachricht mit einem Button, öffne beim Klick ein Modal (ModalBuilder mit TextInputBuilder-Feldern), lies die Antworten beim Absenden des Modals aus, poste sie mit Buttons zum Annehmen und Ablehnen in einen Team-Kanal, vergib dann die Rolle mit member.roles.add und schick dem Bewerber eine DM. Das Skript auf dieser Seite macht genau das in etwa 70 Zeilen.
Kann ein Discord-Modal mehr als 5 Fragen haben?
Nein. Ein Modal fasst höchstens 5 Felder. Für längere Formulare hängst du mehrere Modals hintereinander, und weil das Absenden eines Modals nicht direkt ein weiteres öffnen kann, antwortest du mit einem Weiter-Button, der das nächste öffnet, und speicherst die Antworten dazwischen.
Wie prüfe ich die Signatur eines Toscio-Webhooks?
Berechne den HMAC-SHA256 aus dem Header X-Toscio-Timestamp, einem Punkt und dem rohen Body der Anfrage, mit dem Signatur-Secret deines Formulars, als Hexadezimalwert. Stell sha256= davor und vergleich das Ergebnis mit X-Toscio-Signature in konstanter Zeit. Lehn außerdem Anfragen mit zu altem Zeitstempel ab.
Wiederholt Toscio fehlgeschlagene Webhooks?
Nein. Jedes Ereignis wird einmal gesendet, mit 5 Sekunden Zeitlimit. Ist dein Endpunkt nicht erreichbar oder zu langsam, geht dieses Ereignis verloren; die Bewerbung selbst wird ganz normal bearbeitet. Nutz das Dashboard oder den CSV-Export, um nachzuziehen.
Gibt es einen Open-Source-Bewerbungsbot für Discord?
Ja, auf GitHub findest du mehrere, die du selbst hostest und pflegst. Toscio ist dagegen ein gehosteter Bot: Es gibt nichts zu deployen, und wenn du trotzdem eigenen Code einbinden willst, schicken seine Webhooks jedes Bewerbungsereignis an dein Skript.



