Integration Examples

Working recipes against the real API. Swap in your host, port, and Bearer key and each one runs as-is.

A tiny API client

One helper covers every recipe below: it prefixes the base URL, attaches the Bearer key, unwraps JSON, and throws on non-2xx.

statfyr-client.jsjavascript
export function createClient({ baseUrl = "http://localhost:8080", apiKey = "" } = {}) {
  async function get(path) {
    const res = await fetch(`${baseUrl}${path}`, {
      headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
    });
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
    return res.json();
  }
  return {
    health: () => get("/api/health"),
    players: (q = "") => get(`/api/players${q}`),
    playerStats: (id, q = "") => get(`/api/player/${id}${q}`),
    playerSummary: (id, q = "") => get(`/api/player/${id}/summary${q}`),
    leaderboard: (stat, q = "") => get(`/api/leaderboard/${stat}${q}`),
  };
}

Live dashboard

Poll the summary endpoint every 30 seconds, which matches the plugin's cache TTL, so no request ever pays for a full statistic re-read.

import { createClient } from "./statfyr-client.js";

const api = createClient({ baseUrl, apiKey });

async function renderPlayer(uuid) {
  const s = await api.playerSummary(uuid);
  document.querySelector("#playtime").textContent = s.playtime_formatted;
  document.querySelector("#kills").textContent =
    `${s.combat.player_kills} players · ${s.combat.mob_kills} mobs`;
  document.querySelector("#distance").textContent =
    `${s.movement.total_distance_km.toFixed(1)} km walked`;
  document.querySelector("#mined").textContent = s.activity.blocks_mined.toLocaleString();
}

renderPlayer("069a79f4-44e9-4726-a5be-fca90e38aaf5");
setInterval(() => renderPlayer("069a79f4-44e9-4726-a5be-fca90e38aaf5"), 30_000);

Leaderboard page

rank is computed as offset + position, so paging through with limit and page keeps continuous global ranks:

const api = createClient({ baseUrl, apiKey });

async function topPlaytime(page = 0, limit = 10) {
  const lb = await api.leaderboard(
    "playtime",
    `?limit=${limit}&page=${page}&order=desc`,
  );
  for (const e of lb.entries) {
    console.log(`#${e.rank} ${e.name} · ${e.formatted}`);
  }
  console.log(`page ${lb.page} of ${Math.ceil(lb.total / lb.limit)}`);
}

topPlaytime();

Discord bot

A discord.js command that answers /top with the playtime leaderboard and can look up a player's summary by name:

bot.jsjavascript
import { Client, GatewayIntentBits, EmbedBuilder } from "discord.js";
import { createClient } from "./statfyr-client.js";

const api = createClient({ baseUrl: process.env.STATFYR_URL, apiKey: process.env.STATFYR_API_KEY });
const discord = new Client({ intents: [GatewayIntentBits.Guilds] });

discord.on("interactionCreate", async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === "top") {
    const lb = await api.leaderboard("playtime", "?limit=10");
    const lines = lb.entries.map((e) => `**#${e.rank}** ${e.name} · ${e.formatted}`);
    await interaction.reply({
      embeds: [new EmbedBuilder()
        .setTitle("Playtime leaderboard")
        .setDescription(lines.join("\n"))],
    });
  }

  if (interaction.commandName === "stats") {
    const name = interaction.options.getString("player");
    const s = await api.playerSummary(name);
    await interaction.reply(
      `${s.name}: ${s.playtime_formatted} played, ${s.combat.deaths} deaths, ${s.activity.blocks_mined} blocks mined`,
    );
  }
});

discord.login(process.env.DISCORD_TOKEN);

Polling tips

  • Align polling with the 30-second statistic cache: faster cycles burn your rate limit (120 requests / 60 s by default) without fresher data.
  • Use /api/health for uptime checks and /api/players?online_only=true for live rosters.
  • Need one section? /api/player/<id>/summary?movement=false&combat=false slims the payload to activity only.
  • Send Accept-Encoding: gzip (fetch and axios do by default); large raw dumps compress well.