Skip to content

· Integration

Connecting a Minecraft plugin to a website: the two-direction guide

Minecraft server and website exchanging data through a queue

Any server that grows eventually gets a website. And any website eventually wants to talk to the game: a store has to grant a rank, a leaderboard has to show up outside the game, a Discord account has to be tied to a player, a defeated boss has to trigger an announcement. All of it works fine, as long as you understand that this is not one pipe but two, that they do not share the same constraints, and that the one place you must never do networking is exactly the place you will be tempted to do it.

The two directions of travel

Outbound and inbound are not the same problem

The first direction is the game pushing to the web. Something happens in the world and the site eventually needs to know: a player joins, a score changes, a boss dies. The game server is the source of truth here, and a delay of a few seconds bothers nobody.

The second direction is the web pushing to the game. A payment clears, a moderator clicks in the CMS, a reward must land. This time the website holds the truth, the player is watching, and a failure does not show up on a TPS graph. It shows up as a support ticket that says "I paid and got nothing". The two directions fail differently, so they should not be coded the same way.

Game pushes (outbound)Web pushes (inbound)
ExamplesBoss kill, score, joinPaid order, rank to grant
TriggerA world eventAn action on the site
Delay toleranceHigh, seconds are fineLow, the player waits
Main riskLosing the eventDelivering twice
What it needsA queue and retriesA delivery id
Port to openNoneNone, the plugin pulls

Case 1: the online store that has to grant a rank in game

The most requested feature, and the most often miswired

The correct chain has four links, and the order matters. The payment clears on the website. The site writes a delivery row with a unique id, a target account and what to apply. The plugin, from an async thread, asks for the list of pending deliveries. It applies, then it acknowledges. Only after the acknowledgement does the site consider the delivery done.

Two details separate a system that holds from a system that generates disputes. The first is the delivery id: if the plugin reads the same row twice, because a restart landed between applying and acknowledging, it has to recognise that it already granted the rank. The second is the offline player: a delivery that arrives while its target is disconnected is not dropped, it is kept for the next join.

java
package fun.minax.bridge;

import org.bukkit.entity.Player;

import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/** Scheduled ASYNC: this is where networking happens, and nowhere else. */
public final class DeliveryPuller implements Runnable {

    private final WebBridgePlugin plugin;
    private final Set<String> alreadyApplied = ConcurrentHashMap.newKeySet();

    public DeliveryPuller(WebBridgePlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public void run() {
        List<Delivery> pending = plugin.fetchPending();
        for (Delivery delivery : pending) {
            if (!alreadyApplied.add(delivery.id())) {
                continue; // already delivered: a duplicate must not grant the rank again
            }
            Player player = plugin.getServer().getPlayer(delivery.target());
            if (player == null) {
                plugin.keepForNextJoin(delivery); // offline: keep it, never drop it
                continue;
            }
            plugin.onOwnerThread(player, () -> plugin.apply(delivery, player));
        }
    }

    public record Delivery(String id, UUID target, String payload) {
    }
}

What you are going to break

The natural instinct is to have the site mark the delivery as done before the plugin applies it, because that is easier to write. That order is wrong: if the server dies between the acknowledgement and the application, the delivery is recorded as complete and the player has nothing. Apply first, and the worst case is a second attempt, which the delivery id absorbs.

Why a queue rather than a direct call

The difference between a late event and a lost event

Calling the website straight from an event listener looks shorter. What it actually does is tie the health of your game server to the availability of a machine you do not control. The site takes three seconds to answer because the host is oversubscribed, and now your tick takes three seconds. The site goes down for maintenance, and the event is gone, with no trace and no retry.

A queue breaks that coupling. The listener pushes a message and returns immediately. An async task drains the queue in batches, and puts items back if the send fails. The site can vanish for an hour and your events simply wait.

java
package fun.minax.bridge;

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.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;

public final class WebBridgePlugin extends JavaPlugin {

    private final ConcurrentLinkedQueue<String> outbox = new ConcurrentLinkedQueue<>();
    private HttpClient http;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        this.http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();

        getServer().getPluginManager().registerEvents(new GameEventListener(this), this);
        getServer().getScheduler().runTaskTimerAsynchronously(this, this::flush, 100L, 100L);
        getServer().getScheduler().runTaskTimerAsynchronously(this, new DeliveryPuller(this), 200L, 600L);
    }

    /** Safe to call from any thread: it only appends, it never touches the network. */
    public void enqueue(String jsonEvent) {
        outbox.add(jsonEvent);
    }

    private void flush() {
        List<String> batch = new ArrayList<>();
        String item;
        while (batch.size() < 50 && (item = outbox.poll()) != null) {
            batch.add(item);
        }
        if (batch.isEmpty()) {
            return;
        }

        String body = "[" + String.join(",", batch) + "]";
        String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
        try {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(getConfig().getString("endpoint", "")))
                    .header("Content-Type", "application/json")
                    .header("X-Bridge-Timestamp", timestamp)
                    .header("X-Bridge-Signature", Hmac.sign(timestamp + "." + body, secret()))
                    .timeout(Duration.ofSeconds(10))
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();

            HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() >= 500) {
                batch.forEach(outbox::add); // site is down: put it back in the queue
            }
        } catch (Exception exception) {
            batch.forEach(outbox::add);
            getLogger().warning("Send deferred, queue kept: " + exception.getMessage());
        }
    }

    private String secret() {
        return getConfig().getString("secret", "");
    }
}

This version keeps the queue in memory, so a restart loses it. For a store that is not acceptable and you need it durable. Good news: the SQLite JDBC driver already ships with Paper, so persisting the queue costs you zero extra dependencies. HikariCP does not ship with it. The day you actually need a connection pool, declare it in the libraries: block of plugin.yml so the server downloads it at startup, instead of shading it into your .jar.

The /reload command kills your queue

A /reload leaves orphaned tasks and listeners behind. Your drain task can end up duplicated, or stopped while the queue keeps filling. On a server that moves real payments, restart properly, every time.

Why a network call never starts on the main thread

The one thread you must never block

A server runs at 20 ticks per second, which leaves 50 milliseconds per tick for everything: mobs, redstone, chunk generation and your plugin. An HTTP round trip to a website offers no guarantee of fitting in that budget, and neither does DNS resolution. The snippet below is the single most common cause of "my server started lagging when I installed the bridge".

java
// DO NOT DO THIS: networking on the main thread
@EventHandler
public void onJoin(PlayerJoinEvent event) throws Exception {
    String body = """
            {"player": "%s"}
            """.formatted(event.getPlayer().getName());

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://my-site.com/api/join"))
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

    // Every millisecond spent here is taken out of the server tick.
    HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}

The same rule covers the database: an access that blocks the main thread drops TPS, whether it goes to MySQL across the network or to a local SQLite file. The healthy shape never changes. Network and database on an async thread, and hop back to the owning thread of the entity or the region to touch the server API.

That "owning thread" is not stylistic caution. On Folia, world regions run on several threads, and a plugin that assumes a single main thread causes crashes. Use the RegionScheduler for work tied to a place, and the entity scheduler for work tied to a player. Check the Paper docs and the Javadoc for the exact signatures.

java
/** Runs the action on the right thread, whether the server is Paper or Folia. */
public void onOwnerThread(Player player, Runnable action) {
    if (FOLIA) {
        player.getScheduler().run(this, task -> action.run(), null);
    } else {
        getServer().getScheduler().runTask(this, action);
    }
}

private static final boolean FOLIA = detectFolia();

private static boolean detectFolia() {
    try {
        Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
        return true;
    } catch (ClassNotFoundException notFolia) {
        return false;
    }
}

Case 2: the leaderboard displayed on your site

Outbound, with a design trap in it

The temptation here is to let the website read the plugin database directly. Bad idea, for three reasons. The SQLite file lives on the game machine, so you would have to expose it. A concurrent read locks a file the server needs. And your homepage now fires a query at the machine running the world, exactly when you do not want visitors there.

The right model is a snapshot. Every five minutes an async task computes the top fifty and posts it to the site, which serves it from its own cache. The leaderboard becomes website data rather than game data. Web traffic then has zero effect on TPS, and the page still works when the game server is down for maintenance.

Always send both keys

Put the account id and the current display name in the snapshot. The name is what the visitor wants to read, the id is what lets the site recognise the same player from one week to the next. A leaderboard built on the name alone invents a new player on every rename.

Case 3: linking a Discord account to an in-game account

The proof has to originate in game

The broken version of this feature is a "your Minecraft username" field on the website. Anyone can type somebody else's name in it and collect their purchases. The link has to start in game, because that is the only place where identity is already established by the server session.

The player types /link in game. The plugin mints a short single-use code, valid for ten minutes, and pushes it to the site through the outbound queue. The player pastes that code on the site or into the Discord bot. The site checks that the code exists, has not expired and has not been used, then writes the link. The code travels game to web, and trust travels in the same direction.

java
private static final SecureRandom RANDOM = new SecureRandom();
private final Map<String, PendingLink> pendingCodes = new ConcurrentHashMap<>();

/** A short, single-use code, valid for ten minutes. */
public String createLinkCode(Player player) {
    byte[] raw = new byte[4];
    RANDOM.nextBytes(raw);
    String code = HexFormat.of().withUpperCase().formatHex(raw);

    long expiresAt = System.currentTimeMillis() + Duration.ofMinutes(10).toMillis();
    pendingCodes.put(code, new PendingLink(player.getUniqueId(), player.getName(), expiresAt));
    return code;
}

public record PendingLink(UUID uuid, String name, long expiresAt) {
}

Case 4: the webhook that announces an event

Outbound, batched, and Adventure 5 aware

Announcing outside the game that a boss went down or an event is starting is the easiest case, because nothing comes back. It rides the same outbound queue. The only extra rule is rate: do not fire one HTTP call per kill. Batch, and send one payload every five seconds. Services that receive webhooks cap how many calls they accept, and a plugin that gets throttled loses its messages.

The Adventure 5 trap

A webhook almost always comes with an in-game announcement. Careful if you are porting code written for 1.21 to Paper 26.x: that branch ships Adventure 5, which removed APIs that were previously only deprecated. Anything touching messages and text shown to players is the first thing to stop compiling, and it is exactly the code you write alongside your webhook.

Security: a shared key, not an open endpoint

Where most home-made bridges fall over

An endpoint that accepts "grant VIP to this player" and only checks that the player exists is an open endpoint. Doing it properly takes three things. A shared secret, known to the site and the plugin, never present in a URL. An HMAC-SHA256 signature computed over the exact request body, with a timestamp folded into the signed payload so it cannot be rewritten. And a constant-time comparison, otherwise the verification itself leaks information.

java
package fun.minax.bridge;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public final class Hmac {

    private Hmac() {
    }

    public static String sign(String payload, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
    }

    /** Constant time: a plain equals() leaks how long the correct prefix was. */
    public static boolean verify(String payload, String secret, String received) throws Exception {
        byte[] expected = sign(payload, secret).getBytes(StandardCharsets.UTF_8);
        return MessageDigest.isEqual(expected, received.getBytes(StandardCharsets.UTF_8));
    }
}

Then there is the inbound question, and my position on it is blunt: I never open an HTTP listener inside the Minecraft server process. The plugin lives in the same virtual machine as the world, a malformed request or a flood of connections is paid for directly in TPS, and the game server IP becomes a target. Keep the inbound endpoint on the website, and let the plugin come and read it.

The number one trap: the offline-mode UUID

75.1 percent of servers are affected

Here is the measurement almost no store tutorial mentions. 75.1 percent of servers run in offline mode. In that mode a player UUID is derived from the player name. It does not come from Mojang, it is computed from the name.

For a web bridge the consequence is brutal. If the player renames, or if you ever flip the server to online mode, the key changes and every order stored against the old UUID is orphaned. The player did pay, the row does exist in your database, and nobody will ever claim it. This is the leading cause of store disputes, and it is decided when you pick the primary key, not when the bug appears.

The fix is an internal account id, stable, that you generate yourself. The UUID and the name become attributes of that account, attributes that are allowed to change. Purchases only ever point at the account.

sql
CREATE TABLE account (
  account_id     TEXT PRIMARY KEY,   -- yours, never changes
  minecraft_uuid TEXT NOT NULL,      -- attribute: can change in offline mode
  minecraft_name TEXT NOT NULL,      -- attribute: changes on every rename
  discord_id     TEXT,
  updated_at     INTEGER NOT NULL
);

CREATE UNIQUE INDEX account_by_uuid ON account(minecraft_uuid);

CREATE TABLE delivery (
  delivery_id TEXT PRIMARY KEY,      -- the id that makes a delivery idempotent
  account_id  TEXT NOT NULL REFERENCES account(account_id),
  payload     TEXT NOT NULL,
  applied_at  INTEGER               -- NULL until the game confirms
);

The one-minute test that exposes the problem

On a test server in offline mode, join, buy something, then rejoin under a different name. If your history is gone, your primary key is the player name wearing a UUID costume, and the bug will hit production the day someone renames.

Which version to build the bridge against

The real installed base, not the newest release

Minecraft has moved to calendar versioning, and the latest release is 26.2, out on 16 June 2026. That does not make it the version to target. A bStats reading across 62,948 servers paints a very different picture from the release notes.

Share of serversWhat it means for your bridge
1.21.1136.4 %The majority target, by a wide margin
26.1.214.9 %The sensible calendar target in production
26.27.4 %No stable Paper build, avoid in production
1.21.47.2 %Older 1.21, still very much around

The 1.21 family is still the majority. In practice, a bridge declaring api-version: '1.21' reaches the largest slice of the installed base. On the compilation side, Java 25 is the floor for calendar versions, but compiling to Java 21 is still the right call: it is what most of the fleet runs, and a Java 21 .jar loads on a newer runtime. The reverse is not true. One last build note: Minecraft has been deobfuscated since 26.1 and the Paper remapper is gone, so there is no remapping step to plan for on calendar targets.

My verdict

What I recommend, and when it changes

Below 50 concurrent players, and for a few dozen deliveries a day, I recommend plain pulling: the plugin asks the site every thirty seconds. The buyer waits at most half a minute, which nobody notices, and you have literally nothing to secure on the inbound side because nothing comes in. Best robustness-to-complexity ratio there is, and where most servers should stop.

Above that, when the rank has to land in under a second because the buyer is staring at the screen, I shorten the interval to five seconds rather than open a port. A tight pull is still easier to secure than an inbound endpoint, and it costs a handful of HTTP requests a minute, which is nothing.

On a network of several servers behind a proxy, the queue stops being an implementation detail and becomes a component of its own. One service owns the pending deliveries, each backend pulls its own, and the "applied" mark lives in that service and nowhere else. Otherwise two servers apply the same delivery, and a local delivery id will not protect them from each other.

A web bridge generated, tested, then installed

Describe the store, leaderboard or webhook you want to wire up. Minax writes the Java, compiles it and hands you a .jar you can test online before it touches your server.

Create my plugin

Conclusion

Five things to keep. Two directions, two sets of constraints. A queue between them, never a direct call. No networking and no database work on the thread that owns the world. A shared key with a signature, and a plugin that pulls rather than a port that listens. And before you write the first line, pick a primary key that survives a rename, because on 75.1 percent of servers the UUID is not one. From here, our economy plugin guide covers how to store balances, and the troubleshooting guide covers the load errors you will meet on the first deploy.

Related articles