Skip to main content

Minecraft Discord Webhook: Server Join, Leave and Death Alerts

Minecraft Discord webhook guide: join, leave and death alerts from a Paper plugin, a bash log tailer for vanilla servers, cron and Pterodactyl. No bot needed.

6 min read Also available in Русский
On this page

Your Minecraft community already lives in Discord, so “who just joined” and “who died to a creeper again” belong in a channel, not only in the server console. You have two families of options: a bot-based bridge like DiscordSRV, or a plain webhook that your server posts to. Below: an honest comparison and three webhook implementations you can copy: a small Paper plugin, a bash script that tails latest.log on vanilla or modded servers, and a cron/Pterodactyl setup.

Bot or webhook: pick the right tool

DiscordSRV is the popular full solution for Bukkit-based servers (Spigot, Paper, Purpur). It runs a Discord bot inside the plugin, so it can do things a webhook never will: two-way chat between the game and a channel, a console channel you can type commands into, account linking, and join/leave/death/advancement messages out of the box. The price is setup weight: a Discord application, a bot token you must keep secret, bot permissions, channel IDs in its config. If you want chat bridging, install DiscordSRV and stop reading.

A webhook is the right tool when you only need one-way notifications: join, leave, death, advancements, “server started”, “backup finished”. No bot, no token beyond the webhook URL, and it works from anything that can make an HTTP request. Bot vs webhook explains the difference in detail.

Everything below uses webhooks.

Create the webhook

In Discord, open the target channel → Edit Channel → Integrations → Webhooks → New Webhook. Copy the URL. It looks like https://discord.com/api/webhooks/{id}/{token}, and the token is a secret: anyone who has the URL can post to your channel. Keep it in a config file with restricted permissions or in an environment variable, never in a public plugin repo or a modpack you hand out. If it leaks, delete the webhook and create a new one. The setup guide has screenshots if you need them.

Option 1: a Paper plugin that posts join, leave and death

Webhook plugins exist on SpigotMC and Hangar, but the whole thing fits in under a hundred lines, so write it yourself and run no unknown code. This one works on Spigot and Paper 1.20+ (Java 21 for 1.20.5 and newer). It uses Gson, which both platforms bundle, and Java’s built-in HttpClient.

plugin.yml and config.yml

# src/main/resources/plugin.yml
name: McWebhook
version: 1.0.0
main: com.example.mcwebhook.McWebhook
api-version: '1.20'
# src/main/resources/config.yml
webhook-url: 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
username: 'Minecraft'

The plugin class

package com.example.mcwebhook;

import com.google.gson.Gson;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;

public final class McWebhook extends JavaPlugin implements Listener {
    private final HttpClient http = HttpClient.newHttpClient();
    private final Gson gson = new Gson();
    private String webhookUrl;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        webhookUrl = getConfig().getString("webhook-url", "");
        if (webhookUrl.isBlank()) {
            getLogger().severe("webhook-url is empty in config.yml, disabling");
            getServer().getPluginManager().disablePlugin(this);
            return;
        }
        getServer().getPluginManager().registerEvents(this, this);
    }

    @EventHandler
    public void onJoin(PlayerJoinEvent e) {
        String name = e.getPlayer().getName();
        send(name, "**" + name + "** joined the server", 5763719);
    }

    @EventHandler
    public void onQuit(PlayerQuitEvent e) {
        String name = e.getPlayer().getName();
        send(name, "**" + name + "** left the server", 9807270);
    }

    @EventHandler
    public void onDeath(PlayerDeathEvent e) {
        String name = e.getEntity().getName();
        String msg = e.getDeathMessage(); // deprecated on Paper, still works
        send(name, "💀 " + (msg == null ? name + " died" : msg), 15548997);
    }

    private void send(String player, String description, int color) {
        Map<String, Object> payload = Map.of(
            "username", getConfig().getString("username", "Minecraft"),
            "embeds", List.of(Map.of(
                "description", description,
                "color", color,
                "thumbnail", Map.of("url", "https://mc-heads.net/avatar/" + player + "/64"),
                "footer", Map.of("text", Bukkit.getOnlinePlayers().size() + "/" + Bukkit.getMaxPlayers() + " online")
            ))
        );
        HttpRequest req = HttpRequest.newBuilder(URI.create(webhookUrl))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
            .build();
        // sendAsync keeps the HTTP call off the main server thread
        http.sendAsync(req, HttpResponse.BodyHandlers.ofString())
            .thenAccept(r -> {
                if (r.statusCode() >= 400) {
                    getLogger().warning("Discord returned " + r.statusCode() + ": " + r.body());
                }
            });
    }
}

Three details matter. Gson builds the JSON, so a death message with quotes in it cannot break the payload. sendAsync keeps the request off the main thread; a synchronous call would stall every tick until Discord answers. The thumbnail points at a public head-render service, because thumbnail.url must be a public HTTPS image. During PlayerQuitEvent the leaving player still counts as online, so subtract one there if the footer bothers you.

Build and install

Add io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT from https://repo.papermc.io/repository/maven-public/ as a provided dependency in Maven (compileOnly in Gradle), run mvn package, drop the jar into plugins/, start the server once so plugins/McWebhook/config.yml appears, paste your URL, restart. Join the server and the embed should show up within a second.

Option 2: tail latest.log on vanilla, Fabric or Forge

Vanilla servers cannot load plugins, and modded servers need a separate mod per loader. But every Java Edition server writes the same lines to logs/latest.log, such as [12:34:56] [Server thread/INFO]: Steve joined the game or Steve was slain by Zombie. Paper shortens the prefix to [12:34:56 INFO]:, and Forge and NeoForge insert a logger name after it ([Server thread/INFO] [net.minecraft.server.MinecraftServer/]:), so the script below cuts everything up to INFO] and then up to the first : , which works on all of them. It needs curl and jq.

The script

#!/usr/bin/env bash
# /opt/minecraft/mc-discord.sh
set -uo pipefail

WEBHOOK_URL="${DISCORD_WEBHOOK_URL:?set DISCORD_WEBHOOK_URL}"
LOG="${MC_LOG:-/opt/minecraft/logs/latest.log}"

send() {  # $1 = description, $2 = decimal color
  jq -cn --arg d "$1" --argjson c "$2" \
    '{username: "Minecraft", embeds: [{description: $d, color: $c, timestamp: (now | todate)}]}' \
  | curl -sS -o /dev/null --retry 3 --retry-delay 2 \
      -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
}

DEATH='^[A-Za-z0-9_]{3,16} (was |died|drowned|blew up|burned|fell|hit the ground|tried to swim|starved|suffocated|withered|froze|went up in flames|walked into|discovered|experienced)'

# -n 0: no history; -F: survives log rotation
tail -n 0 -F "$LOG" | while IFS= read -r line; do
  msg="${line#*INFO]}"
  [[ "$msg" == "$line" ]] && continue   # not an INFO line
  msg="${msg#*: }"                      # drop ": " or Forge's " [logger]: "
  [[ "$msg" == \<* ]] && continue       # "<Steve> ..." is player chat, never trust it
  case "$msg" in
    *" joined the game")            send "**${msg% joined the game}** joined the server" 5763719 ;;
    *" left the game")              send "**${msg% left the game}** left the server" 9807270 ;;
    *" has made the advancement "*) send "🏆 $msg" 16776960 ;;
    *) [[ "$msg" =~ $DEATH ]] && send "💀 $msg" 15548997 ;;
  esac
done

tail -F (capital F) is what makes this survive Minecraft’s log rotation: on every server start latest.log is gzipped and recreated, and -F reopens the new file by name. -n 0 means a restart of the script never replays old joins. --retry makes curl (7.71 or newer) treat 429 as transient and wait for the Retry-After value Discord sends with it.

Run it as a service

# /etc/systemd/system/mc-discord.service
[Unit]
Description=Minecraft log to Discord
After=minecraft.service

[Service]
User=minecraft
EnvironmentFile=/etc/minecraft/discord.env
ExecStart=/opt/minecraft/mc-discord.sh
Restart=always

[Install]
WantedBy=multi-user.target

/etc/minecraft/discord.env holds DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... with chmod 600. Then systemctl enable --now mc-discord.

Pterodactyl and cron

Pterodactyl

On Pterodactyl the server runs in a Docker container managed by Wings, and its files live on the node at /var/lib/pterodactyl/volumes/<server-uuid>/. The UUID is on the server’s Settings page in the panel. Point the same script at that path and run it as a systemd unit on the node:

MC_LOG=/var/lib/pterodactyl/volumes/3f2a1c7e-0000-0000-0000-000000000000/logs/latest.log

Renting on someone else’s panel with no node access? Use the plugin instead; the container is a poor place for a tail loop.

Cron: an hourly status embed via RCON

Enable RCON in server.properties (enable-rcon=true, rcon.port=25575, rcon.password=...) and install mcrcon. The vanilla list command answers with There are 3 of a max of 20 players online: Steve, Alex, Notch, which is already readable:

#!/usr/bin/env bash
# /opt/minecraft/mc-status.sh
set -uo pipefail
WEBHOOK_URL="${DISCORD_WEBHOOK_URL:?}"

if out=$(mcrcon -H 127.0.0.1 -P 25575 -p "$RCON_PASSWORD" list 2>/dev/null); then
  title="Server online"; color=5763719
else
  title="Server offline"; color=15548997; out="RCON did not answer. Check the process."
fi

jq -cn --arg t "$title" --arg d "$out" --argjson c "$color" \
  '{embeds: [{title: $t, description: $d, color: $c, timestamp: (now | todate)}]}' \
| curl -sS -o /dev/null -H "Content-Type: application/json" -d @- "$WEBHOOK_URL"
# crontab -e
0 * * * * set -a; . /etc/minecraft/discord.env; /opt/minecraft/mc-status.sh

Put RCON_PASSWORD=... in the same env file; set -a is what exports its variables to the script, a plain . would keep them shell-local. If you only want to hear about downtime, keep the last state in a file and post only when it flips; the uptime alerts article shows that pattern.

Ping a role when the server goes down

The offline branch is the one people actually want a notification for, so make it ping. Be explicit with allowed_mentions: only the roles listed there are notified, and { "parse": [] } renders the same text without pinging anyone. Omit the field and Discord pings everything it finds in content:

{
  "content": "<@&123456789012345678> the Minecraft server is down",
  "allowed_mentions": { "roles": ["123456789012345678"] },
  "embeds": [{ "title": "Server offline", "color": 15548997 }]
}

Keep @everyone out of hourly cron output. Mentions covers the rest of allowed_mentions. To try layouts, colors and thumbnails before wiring them into a script, the Discord Webhook builder gives you a live preview and exports the JSON you then hand to jq or Gson.

Common errors

  • Nothing arrives and nothing errors. The script is tailing the wrong file, or the line format differs. Run tail -F logs/latest.log | grep 'INFO]' while a player joins.
  • 400 Bad Request. Hand-built JSON broke on a quote, a backslash or a newline in a death message. Build the payload with jq or Gson as above; the response body names the invalid field.
  • 404 with {"message": "Unknown Webhook", "code": 10015}. The webhook was deleted or the URL was copied with a missing or extra character. Recreate it in Server Settings → Integrations → Webhooks.
  • 429 Too Many Requests after a restart. Everyone reconnecting at once means a burst of messages in seconds. curl’s --retry honours Retry-After; in the plugin, either queue events and send one embed every few seconds, or accept some dropped join messages. Rate limits explains the headers.
  • The server lags when Discord is slow. The plugin sent the request on the main thread. Use sendAsync or runTaskAsynchronously.
  • Players spoof joins in chat. A player typing Steve joined the game shows up in the log as <Alex> Steve joined the game. The < check in the script drops it; keep it.

FAQ

Do I need a Discord bot for Minecraft server notifications?

No. A webhook covers one-way notifications: joins, leaves, deaths, advancements, up/down status. You need a bot (DiscordSRV or your own) only for two-way chat, console access or commands typed in Discord.

Does the log-tailing script work on Fabric, Forge or a modpack?

Yes. It reads logs/latest.log, which every Java Edition server writes with the same joined the game and left the game lines. Only the prefix before the message differs, and the script strips it.

Can I post to a thread or a forum post instead of a channel?

Yes. Add ?thread_id=<id> to the webhook URL for an existing thread, or send thread_name in the body on a forum channel to create a new post. See threads and forums.

Will join and leave messages hit the rate limit?

On a small server, no. On a large one they can during mass reconnects; batch events into one embed or back off on 429 as described above.

Next steps

Start with the log script, because it works on every server today, and move to the plugin when you want no external process. Prototype the embed in the free Discord Webhook builder, export the JSON, and paste it into whichever option you chose. For more of the same pattern, see Roblox to Discord, notification automation and the webhook setup guide.

Tags: minecraftminecraft discord webhookminecraft server discord notificationspaper plugindiscordsrvbashpterodactyl

Related articles

All articles

Build it in the visual editor

Embeds, Components V2, buttons and polls with a live Discord preview. Free, no signup.