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) | |
|---|---|---|
| Examples | Boss kill, score, join | Paid order, rank to grant |
| Trigger | A world event | An action on the site |
| Delay tolerance | High, seconds are fine | Low, the player waits |
| Main risk | Losing the event | Delivering twice |
| What it needs | A queue and retries | A delivery id |
| Port to open | None | None, 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.
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
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.
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
/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".
// 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.
/** 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
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.
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
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.
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.
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
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 servers | What it means for your bridge | |
|---|---|---|
| 1.21.11 | 36.4 % | The majority target, by a wide margin |
| 26.1.2 | 14.9 % | The sensible calendar target in production |
| 26.2 | 7.4 % | No stable Paper build, avoid in production |
| 1.21.4 | 7.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 pluginConclusion
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.



