Zaroz
Discord Bot

Build a Discord bot that monitors a Minecraft server

In this tutorial you will build a small discord.js bot that polls one Minecraft server, then creates or updates a single Discord embed with its current state. It only posts again when the state changes, so the channel does not fill with repeated status messages.

The bot uses GET /service-instances/{id}/resources and GET /service-instances/{id}/external-access from the API reference. Conventions shared by every call are in Using the Zaroz API.

What the finished bot does

  • Reads one service every 60 seconds.
  • Shows the overall state and primary Minecraft service state.
  • Includes the public address when the API returns one.
  • Changes embed colour for running, transitional, stopped, and failed states.
  • Finds its previous status message after a restart and edits it.
  • Shows an API error in the same embed without exposing the token.

1. Create the Discord application

  1. Open the Discord Developer Portal and create an application.
  2. Open Bot, create the bot user, and copy its token into a password manager.
  3. Under OAuth2 → URL Generator, select the bot scope.
  4. Grant View Channels, Send Messages, Embed Links, and Read Message History.
  5. Use the generated URL to invite the bot to your server.
  6. Enable Developer Mode in Discord, right-click the destination channel, and copy its channel ID.

This bot does not need Message Content or another privileged gateway intent.

2. Create the Zaroz API token

In Settings → Security → API tokens, create a token named discord-minecraft-status:

  • Capability: services.read
  • Scope: One resource
  • Resource: your Minecraft server (the service instance id, as in its console URL)

That is the only Zaroz capability this tutorial needs. Copy the token when it is shown; it cannot be displayed again.

3. Create the project

TerminalCode
mkdir zaroz-minecraft-status cd zaroz-minecraft-status npm init -y npm install discord.js dotenv

Replace package.json with:

JSONCode
{ "name": "zaroz-minecraft-status", "private": true, "type": "module", "scripts": { "start": "node index.js" }, "dependencies": { "discord.js": "^14.26.5", "dotenv": "^17.4.2" } }

Create index.js:

JavascriptCode
import "dotenv/config"; import { Client, EmbedBuilder, Events, GatewayIntentBits, } from "discord.js"; const requiredVariables = [ "DISCORD_TOKEN", "DISCORD_CHANNEL_ID", "ZAROZ_API_TOKEN", "ZAROZ_SERVICE_ID", ]; for (const variable of requiredVariables) { if (!process.env[variable]) { throw new Error(`Missing environment variable: ${variable}`); } } const API_BASE = "https://kernel.zaroz.cloud/api/v2"; const pollInterval = Math.max( Number(process.env.POLL_INTERVAL_SECONDS ?? 60), 15, ) * 1_000; const marker = `zaroz-service:${process.env.ZAROZ_SERVICE_ID}`; const colours = { running: 0x22c55e, provisioning: 0x3b82f6, pending: 0x3b82f6, stopping: 0xf59e0b, suspended: 0x64748b, crash_looping: 0xef4444, degraded: 0xf97316, failed: 0xef4444, }; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let channel; let statusMessage; let lastFingerprint; let updateInProgress = false; async function zaroz(path) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(`${API_BASE}${path}`, { headers: { Authorization: `Bearer ${process.env.ZAROZ_API_TOKEN}`, Accept: "application/json", }, signal: controller.signal, }); if (!response.ok) { throw new Error(`Zaroz API returned HTTP ${response.status}`); } return response.json(); } finally { clearTimeout(timeout); } } async function getStatus() { const id = process.env.ZAROZ_SERVICE_ID; const resources = await zaroz(`/service-instances/${id}/resources`); const primary = resources.data.find((item) => item.is_declared_primary); // The public address is optional: leave it out if this token cannot read it. let address; try { const access = await zaroz(`/service-instances/${id}/external-access`); const port = access.allocations.find((item) => item.is_primary); if (port) address = `${port.external_ip}:${port.external_port}`; } catch { address = undefined; } return { state: primary?.status ?? "pending", address }; } function buildStatusEmbed(status) { const fields = [ { name: "Minecraft service", value: status.state, inline: true }, ]; if (status.address) { fields.push({ name: "Address", value: `\`${status.address}\``, inline: false, }); } return new EmbedBuilder() .setTitle("Minecraft server status") .setColor(colours[status.state] ?? 0x64748b) .addFields(fields) .setFooter({ text: marker }) .setTimestamp(); } function buildErrorEmbed(error) { return new EmbedBuilder() .setTitle("Minecraft server status unavailable") .setDescription(error.message.slice(0, 2_000)) .setColor(0xef4444) .setFooter({ text: marker }) .setTimestamp(); } async function findPreviousMessage() { const messages = await channel.messages.fetch({ limit: 50 }); return messages.find( (message) => message.author.id === client.user.id && message.embeds.some((embed) => embed.footer?.text === marker), ); } async function publish(embed) { statusMessage ??= await findPreviousMessage(); if (statusMessage) { statusMessage = await statusMessage.edit({ embeds: [embed] }); } else { statusMessage = await channel.send({ embeds: [embed] }); } } async function updateStatus() { if (updateInProgress) return; updateInProgress = true; try { const status = await getStatus(); const fingerprint = JSON.stringify(status); if (fingerprint !== lastFingerprint) { await publish(buildStatusEmbed(status)); lastFingerprint = fingerprint; } } catch (error) { const safeError = error instanceof Error ? error : new Error("Unknown status error"); const fingerprint = `error:${safeError.message}`; if (fingerprint !== lastFingerprint) { await publish(buildErrorEmbed(safeError)); lastFingerprint = fingerprint; } console.error("Status update failed:", safeError.message); } finally { updateInProgress = false; } } client.once(Events.ClientReady, async (readyClient) => { console.log(`Logged in as ${readyClient.user.tag}`); channel = await readyClient.channels.fetch( process.env.DISCORD_CHANNEL_ID, ); if (!channel?.isTextBased() || !("messages" in channel)) { throw new Error("DISCORD_CHANNEL_ID is not a text channel"); } await updateStatus(); setInterval(updateStatus, pollInterval); }); client.login(process.env.DISCORD_TOKEN);

4. Configure and run it

For a local test, create .env:

Code
DISCORD_TOKEN=your-discord-bot-token DISCORD_CHANNEL_ID=000000000000000000 ZAROZ_API_TOKEN=your-zaroz-api-token ZAROZ_SERVICE_ID=00000000-0000-0000-0000-000000000000 POLL_INTERVAL_SECONDS=60

Add .env to .gitignore, then start the bot:

TerminalCode
printf '.env\n' >> .gitignore npm start

For a Zaroz Discord Bot service, upload package.json, package-lock.json, and index.js, set the five variables in the service's environment configuration, and use npm start as the start command. Do not upload .env.

5. Verify least privilege

The bot should be able to read GET /service-instances/{id}/resources, but POST /service-instances/{id}/restart should return 403. That is intentional: a public status integration should not be able to restart the server.

If you later add a Discord restart command, create a deliberate permission flow and add services.power for this service. Restrict the Discord command to trusted roles and record who invoked it.

Troubleshooting

The embed says HTTP 401

Replace an expired or revoked token. Confirm you copied the complete token and did not add quotes to the dashboard environment value.

The embed says HTTP 403

Give the token services.read for this exact service. The token owner must also still be able to see the service.

The bot creates a new message after every restart

Grant Read Message History and keep the original status message among the channel's most recent 50 messages.

The address is missing

The API only includes an address on a provision that exposes one. The overall state monitor still works without it.

How often should I poll?

Sixty seconds is a good default. The example enforces a 15-second minimum. For faster event-driven integrations, check the OpenAPI reference for newer platform capabilities before increasing request volume.

Last modified on