Skip to content

Build a Discord bot that monitors a Minecraft order

In this tutorial you will build a small discord.js bot that polls one Minecraft order, 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 Order Status from the Kernel OpenAPI reference.

What the finished bot does

  • Reads one order 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: orders.read
  • Scope: One resource
  • Resource: your Minecraft order

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

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

Replace package.json with:

package.json
{
  "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:

index.js
import "dotenv/config";
import {
  Client,
  EmbedBuilder,
  Events,
  GatewayIntentBits,
} from "discord.js";

const requiredVariables = [
  "DISCORD_TOKEN",
  "DISCORD_CHANNEL_ID",
  "ZAROZ_API_TOKEN",
  "ZAROZ_ORDER_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/v1";
const pollInterval = Math.max(
  Number(process.env.POLL_INTERVAL_SECONDS ?? 60),
  15,
) * 1_000;
const marker = `zaroz-order:${process.env.ZAROZ_ORDER_ID}`;

const colours = {
  Running: 0x22c55e,
  Provisioning: 0x3b82f6,
  Stopping: 0xf59e0b,
  Suspended: 0x64748b,
  NotProvisioned: 0x64748b,
  CrashLooping: 0xef4444,
  Degraded: 0xf97316,
  Failed: 0xef4444,
};

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let channel;
let statusMessage;
let lastFingerprint;
let updateInProgress = false;

async function getOrderStatus() {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch(
      `${API_BASE}/orders/${process.env.ZAROZ_ORDER_ID}/status`,
      {
        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);
  }
}

function buildStatusEmbed(status) {
  const primary = status.provisions.find((item) => item.is_primary);
  const fields = [
    { name: "Order", value: status.state, inline: true },
    {
      name: "Minecraft service",
      value: primary?.status ?? "Not provisioned",
      inline: true,
    },
  ];

  if (primary?.external_ip && primary?.external_port) {
    fields.push({
      name: "Address",
      value: `\`${primary.external_ip}:${primary.external_port}\``,
      inline: false,
    });
  }

  if (status.failure_info?.message) {
    fields.push({
      name: "Startup failure",
      value: status.failure_info.message.slice(0, 1_024),
      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 getOrderStatus();
    const primary = status.provisions.find((item) => item.is_primary);
    const fingerprint = JSON.stringify({
      state: status.state,
      primaryStatus: primary?.status,
      address: primary?.external_ip,
      port: primary?.external_port,
      failure: status.failure_info?.message,
    });

    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:

.env
DISCORD_TOKEN=your-discord-bot-token
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=your-zaroz-api-token
ZAROZ_ORDER_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60

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

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

For a Zaroz Discord Bot order, upload package.json, package-lock.json, and index.js, set the five variables in the order'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 /orders/{id}/status, but a request to /orders/{id}/toggle 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 orders.server.toggle for this order. 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 orders.read for this exact order. The token owner must also own the order or retain an order membership that can view it.

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.