Sistema de postulaciones discord.js v14 (o sin programar)

Por el equipo de Toscio · Actualizado 22 de octubre de 2026 · 6 min de lectura

¿Cómo hacer un sistema de postulaciones con discord.js v14? Necesitas cuatro piezas: un panel con un botón Postular, un modal (el formulario emergente de Discord, con 5 campos como máximo por ventana, así que los formularios largos tienen que encadenar varios), una ficha publicada en un canal del staff con botones Aceptar y Rechazar, y al aceptar member.roles.add más un MD al candidato. Esta guía te da un esqueleto corto que funciona, la lista de lo que aún tienes que construir alrededor y la vía sin código si prefieres no mantener un bot.

Lo que tienes que programar

Pieza API de discord.js v14
Panel con un botón Postular ButtonBuilder, ActionRowBuilder, channel.send
El formulario ModalBuilder, TextInputBuilder, interaction.showModal
Leer las respuestas interaction.isModalSubmit(), interaction.fields.getTextInputValue
Ficha del staff con botones EmbedBuilder, ButtonBuilder con el ID del candidato en el customId
La decisión member.roles.add, member.send, interaction.update

Un esqueleto mínimo con discord.js v14

Un solo archivo, npm install discord.js, Node 18 o posterior. Rellena los tres ID y define DISCORD_TOKEN. El bot solo necesita el intent Guilds.

// 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 con el botón Postular
const STAFF_CHANNEL_ID = '000000000000000000'; // canal privado del staff
const ROLE_ID = '000000000000000000'; // rol que se da al aceptar

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

// Publica el panel al arrancar: ejecútalo una vez y luego quítalo
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. El botón Postular abre el formulario (5 campos como 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. Se envía el formulario: publica una ficha en el canal del 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. Aceptar o Rechazar en la ficha del 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); // el rol del bot debe estar por encima de ROLE_ID
      } catch {
        await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
        return;
      }
    }
    // El candidato puede tener los MD cerrados: se ignora el 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);

El ID del candidato viaja en el customId del botón, así que la decisión sigue funcionando después de un reinicio. Todo lo demás vive solo en el mensaje del staff. Los textos que ve el usuario ('Apply', 'How old are you?'…) están en inglés: cámbialos por los de tu servidor.

Lo que te queda por construir

El esqueleto funciona, pero un sistema de postulaciones de verdad necesita más:

  • Almacenamiento. Guarda las solicitudes en una base de datos, no solo en mensajes de Discord, para buscarlas, exportarlas y conservarlas si se borra un mensaje.
  • Una solicitud pendiente por miembro, para que nadie envíe el mismo formulario diez veces.
  • Una espera después de un rechazo, guardada por miembro y por formulario.
  • Más de 5 preguntas. El envío de un modal no puede abrir otro modal: responde con un botón Continuar que abra la siguiente ventana, y guarda las respuestas entre pasos.
  • Permisos del staff. Aquí solo Gestionar servidor puede decidir; querrás roles del staff por formulario.
  • Orden de los roles. Discord rechaza roles.add para un rol que esté por encima del rol del bot: detéctalo y dile al staff qué tiene que corregir.
  • MD cerrados. Avisa al staff cuando no se pudo enviar el MD, para que avise al candidato por otra vía.
  • Alojamiento. Un proceso que funcione 24/7, que se reinicie si se cae y que se actualice cuando cambie discord.js.

Lo que hace Toscio sin código

Si prefieres no mantener todo eso, nuestra guía sin código muestra el mismo proceso con Toscio: /toscio setup, una plantilla, y el panel ya está publicado. Todo lo de la lista de arriba ya está resuelto: formularios nativos de Discord encadenados de cinco en cinco preguntas, una solicitud pendiente por miembro, una espera, roles del staff, comprobación del orden de los roles y MD cerrados señalados en la ficha.

Además vienen las notas del staff y el historial del candidato en cada ficha. El plan gratuito incluye el quiz con nota, la etapa antes de la aceptación (entrevista, periodo de prueba) y el acceso staff al dashboard, con 2 formularios de 10 preguntas.

Conserva tu script y conecta un webhook (Premium)

¿Ya tienes un script, una hoja de cálculo o un CAD/MDT que necesita las solicitudes? Consérvalo, y deja que Toscio le envíe los eventos. En el editor de formulario, abre «Webhook (integraciones)» en los ajustes avanzados y escribe una dirección https. Toscio envía:

Evento Cuándo
application.submitted se recibe una solicitud
application.staged pasa a la etapa antes de la aceptación
application.accepted se acepta
application.refused se rechaza
ping haces clic en «Enviar una prueba» (application vale null)

El cuerpo es 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 petición lleva dos cabeceras: X-Toscio-Timestamp (tiempo Unix, en segundos) y X-Toscio-Signature, que es sha256= seguido del HMAC-SHA256 en hexadecimal de ${timestamp}.${cuerpo bruto}, calculado con el «Secreto de firma» del formulario. Compruébala antes de fiarte del cuerpo:

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

const SECRET = process.env.TOSCIO_SIGNING_SECRET; // el «Secreto de firma» del editor de formulario
const app = express();

// Cuerpo bruto: la firma se calcula sobre los bytes exactos recibidos
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; // rechaza las peticiones de más 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); // detrás de un proxy inverso que sirve https en el puerto 443

Límites que conviene conocer:

  • Solo direcciones https públicas: un nombre de dominio en el puerto 443, sin dirección IP ni nombre local.
  • Las redirecciones no se siguen, y Toscio espera 5 segundos como máximo una respuesta.
  • Sin reintentos: una petición fallida no se vuelve a enviar.
  • El secreto está oculto para los miembros del staff sin Gestionar servidor.

Los webhooks forman parte de Premium (4,99 €/mes o 49,90 €/año por servidor), igual que los votos del staff, el formulario web y las apelaciones de ban.

Preguntas

¿Cómo hago un sistema de postulaciones en discord.js v14?

Publica un mensaje con un botón, abre un modal (ModalBuilder con campos TextInputBuilder) cuando se pulse, lee las respuestas al enviarse el modal, publícalas en un canal del staff con botones Aceptar y Rechazar, y luego da el rol con member.roles.add y envía un MD al candidato. El script de esta página hace exactamente eso en unas 70 líneas.

¿Un modal de Discord puede tener más de 5 preguntas?

No. Un modal admite 5 campos como máximo. Para formularios más largos hay que encadenar varios modales, y como el envío de un modal no puede abrir otro modal directamente, respondes con un botón Continuar que abre el siguiente, guardando las respuestas entre medias.

¿Cómo compruebo la firma de un webhook de Toscio?

Calcula el HMAC-SHA256 de la cabecera X-Toscio-Timestamp, un punto y el cuerpo bruto de la petición, con el secreto de firma de tu formulario, en hexadecimal. Ponle delante sha256= y compáralo con X-Toscio-Signature con una comparación de tiempo constante. Rechaza también las peticiones con una marca de tiempo demasiado antigua.

¿Toscio reintenta los webhooks que fallan?

No. Cada evento se envía una vez, con un tiempo de espera de 5 segundos. Si tu servidor está caído o va demasiado lento, ese evento se pierde; la solicitud en sí se procesa con normalidad. Usa el dashboard o la exportación CSV para ponerte al día.

¿Hay un bot de postulaciones de Discord de código abierto?

Sí, en GitHub encontrarás varios, que alojas y mantienes tú. Toscio es en cambio un bot alojado: no hay nada que desplegar, y si aun así quieres tu propio código en el proceso, sus webhooks envían cada evento de solicitud a tu script.

Tu próxima postulación está a dos minutos.

Añade Toscio, ejecuta /toscio setup y elige una plantilla. Y ya está.