Type /maskid @someone.
See who you’re talking to.
A small, open bot for your Discord server. Ask it about a Mask.ID member and it posts their profile card — Trust Index, verified platforms, referrals — right in the channel. Public data only. Nothing stored.
What it does
Someone types /maskid username:@eric_stanek. The bot fetches that member’s public profile card from Mask.ID and replies with it as an embed, linked to the full profile. If there is no public profile under that name, it says so. That is the whole bot — about 60 lines, one dependency, no database.
It reads exactly what any visitor to Mask.ID can see. It never asks a member to share anything, never stores who asked about whom, and needs no Mask.ID account or API key of its own. Roles by Trust Index tier — the version that does ask the member to verify — is the next step, built on the Attestation API.
Setup
- Create the Discord applicationOpen the Discord Developer Portal → New Application. Under Bot, click Reset Token and copy the token (you see it once). Note the Application ID from General Information. No privileged intents are needed.
- Invite it to your serverUnder OAuth2 → URL Generator tick the scopes
botandapplications.commands, and the bot permissions Send Messages and Embed Links. Open the generated URL and pick your server. - Get the codeDownload the files below into a folder (say
maskid-bot/) and runnpm install. Node 18 or newer. - ConfigureCopy
env.exampleto.envand fill in the token and application ID. SetDISCORD_GUILD_IDto your server’s ID while testing — the command appears instantly instead of within the hour. - Register the command, then start
npm run registeronce, thennpm start. Type/maskidin any channel the bot can see. To keep it running on a server, use the systemd unit at the bottom.
Node loads .env only when you ask: run with node --env-file=.env bot.js (Node 20+), export the variables in your shell, or use the systemd unit, which reads the file for you.
The code
// Mask.ID Discord bot — answers `/maskid username:@someone` with that
// member's profile card. Reads only PUBLIC data (the same card any
// visitor sees at Mask.ID); nothing is stored.
import { Client, EmbedBuilder, GatewayIntentBits } from "discord.js";
const MASKID = process.env.MASKID_HOST ?? "https://app.mask.id";
const token = process.env.DISCORD_TOKEN;
if (!token) { console.error("Set DISCORD_TOKEN."); process.exit(1); }
// Usernames are letters, digits, and underscores. Anything else is rejected
// before it can reach a URL.
const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once("ready", (c) => console.log(`Signed in as ${c.user.tag}`));
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "maskid") return;
const raw = interaction.options.getString("username", true).trim();
const username = raw.replace(/^@/, "");
if (!USERNAME.test(username)) {
return interaction.reply({ content: "That doesn't look like a Mask.ID username.", ephemeral: true });
}
// The card is rendered on demand and cached server-side; give it a moment.
await interaction.deferReply();
const cardUrl = `${MASKID}/card/${username}`;
const profileUrl = `${MASKID}/u/${username}`;
let status;
try {
const res = await fetch(cardUrl, { method: "HEAD", redirect: "follow" });
status = res.status;
} catch (err) {
console.error("card fetch failed:", err);
return interaction.editReply("Couldn't reach Mask.ID just now. Try again in a minute.");
}
if (status === 404) {
return interaction.editReply(`No public Mask.ID profile for **@${username}**.`);
}
if (status !== 200) {
return interaction.editReply("Mask.ID is busy rendering cards — try again shortly.");
}
const embed = new EmbedBuilder()
.setTitle(`@${username} on Mask.ID`)
.setURL(profileUrl)
.setImage(cardUrl)
.setColor(0x5b6bff)
.setFooter({ text: "Public profile · verified by the member, not about the member" });
return interaction.editReply({ embeds: [embed] });
});
client.login(token);
// Registers the /maskid slash command with Discord. Run once (and again
// whenever you change the command definition): `npm run register`.
import { REST, Routes, SlashCommandBuilder } from "discord.js";
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_GUILD_ID } = process.env;
if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID) {
console.error("Set DISCORD_TOKEN and DISCORD_CLIENT_ID first.");
process.exit(1);
}
const command = new SlashCommandBuilder()
.setName("maskid")
.setDescription("Show a Mask.ID member's profile card")
.addStringOption((o) =>
o.setName("username")
.setDescription("Their Mask.ID username, e.g. @eric_stanek")
.setRequired(true)
)
.toJSON();
const rest = new REST().setToken(DISCORD_TOKEN);
// With DISCORD_GUILD_ID set, the command appears in that server instantly —
// ideal while testing. Without it, it registers globally (up to an hour).
const route = DISCORD_GUILD_ID
? Routes.applicationGuildCommands(DISCORD_CLIENT_ID, DISCORD_GUILD_ID)
: Routes.applicationCommands(DISCORD_CLIENT_ID);
await rest.put(route, { body: [command] });
console.log(`Registered /maskid ${DISCORD_GUILD_ID ? "in guild " + DISCORD_GUILD_ID : "globally"}.`);
{
"name": "maskid-discord-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Answers /maskid <username> with the member's Mask.ID profile card.",
"scripts": {
"register": "node register-commands.js",
"start": "node bot.js"
},
"dependencies": {
"discord.js": "^14.16.3"
},
"engines": { "node": ">=18" }
}
# Copy to .env and fill in. Never commit .env.
DISCORD_TOKEN=paste-the-bot-token
DISCORD_CLIENT_ID=paste-the-application-id
# Optional: register the command in one server only (instant) while testing.
DISCORD_GUILD_ID=
# /etc/systemd/system/maskid-bot.service — keeps the bot running.
[Unit]
Description=Mask.ID Discord bot
After=network-online.target
[Service]
User=maskidbot
WorkingDirectory=/opt/maskid-bot
EnvironmentFile=/opt/maskid-bot/.env
ExecStart=/usr/bin/node bot.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
How it behaves
Rate limits. Card rendering on Mask.ID is limited per IP address, and the bot fetches every card from one address. A busy server with many lookups a minute will see “busy rendering” replies now and then; cards are cached for a while once rendered, so repeat lookups are cheap.
Privacy. The bot sends Mask.ID only the username that was asked about. It does not send who asked, which server, or anything else — and Mask.ID does not log IP addresses. Discord itself fetches the image to display the embed.
License. The bot code on this page is public domain (CC0) — copy, change, and ship it however you like.
Next: roles by Trust Index tier
The card bot tells a channel who someone is. The next bot asks members to verify through Mask.ID once, then assigns a Discord role by their Trust Index tier — so you can gate channels, mute newcomers below a tier, or simply mark the established. That one uses the Attestation API and is on the Ways to integrate list. Want it first? The contact form reaches us.