Data format & self-hosting

Updated a week ago

Both gs mods (this client companion and the server-side GsValheimStatsEmitter) work by POSTing JSON snapshots to a single HTTP endpoint. The default is the self-hosted gs dashboard, but the endpoint is just plain HTTP + JSON - point Url at anything you like and ingest the data yourself. This page is the full reference for that format.

Request

  • POST <Url> (config [Ingest] Url)
  • Authorization: Bearer <Token> (omitted if Token is blank)
  • Content-Type: application/json
  • The mod posts every [General] EmitIntervalSeconds (default 120), plus an immediate flush on a new personal-best/kill (client) or on player join/leave (server).
  • Idempotent. Every counter is a cumulative running total, and snapshotIdLocal lets a receiver dedupe - re-posting the same snapshot is safe. A receiver should reply { "status": "inserted" } (or "duplicate").

Common envelope

field type notes
schemaVersion int currently 1
game string always "valheim"
source string "client" or "server"
world string groups all stats; match the server's -world name
reporter string (client only) who captured this payload - the source dimension for merging (see below)
emittedAtUtc ISO-8601 when the snapshot was built
snapshotIdLocal string dedupe key

Client payload (source: "client")

{
"schemaVersion": 1, "game": "valheim", "source": "client",
"reporter": "Erebus", "world": "vhserver3",
"emittedAtUtc": "...", "snapshotIdLocal": "...",
"players": [
{
"name": "Erebus", "platformId": "-1285113746",
"kills": 312, "bossKills": 1, "deaths": 4,
"longestLifeSec": 7200, "bestKillsBeforeDeath": 150,
"stats": { "vh_EnemyKills": 312, "vh_Builds": 540 },
"skills": [{ "skill": "Knives", "level": 42 }],
"creatureKills": [{ "creature": "$enemy_greydwarf", "kills": 140 }],
"crafts": [{ "item": "$item_club", "count": 3 }],
"materials": [{ "material": "Wood", "amount": 3400 }],
"weapons": [{ "weapon": "Knives", "damageDealt": 493, "kills": 29, "hardestHit": 79, "biggestSwing": 121 }],
"weaponItems": [{ "item": "$item_knife_bronze", "damageDealt": 400, "kills": 25, "hardestHit": 79 }],
"boss": [{ "boss": "Eikthyr", "damageDealt": 1450, "fightSec": 95 }],
"pickups": [{ "item": "$item_trophy_boar", "count": 11 }]
},
{ "name": "Pridetoes", "weapons": [ ... ], "boss": [ ... ] }
],
"deathEvents": [{ "playerName": "Erebus", "killer": "tree", "biome": "BlackForest", "posX": 120, "posY": 30, "posZ": -44, "lifeSec": 1800, "killsThisLife": 40, "tsUtc": "..." }],
"bossSelfDamage": [{ "boss": "Fader", "damage": 210 }]
}
  • The first players[] entry is the local player (full stats). Additional entries are combat-only (weapons/weaponItems/boss only) - they're the damage this client witnessed other players deal to creatures it owns (see Multiplayer).
  • weapons[].weapon is a skill category (Knives, Bows, Swords...). weaponItems[].item is the specific item ($item_knife_bronze).
  • boss[].fightSec is the player's active engagement time, so DPS = damageDealt / fightSec.
  • bossSelfDamage[] is per-boss damage taken with no player attacker (lava, the boss's own fire, fall) - a curiosity stat, attributed to nobody.

Server payload (source: "server")

{
"schemaVersion": 1, "game": "valheim", "source": "server", "world": "vhserver3",
"hostName": "...", "serverStartedAtUtc": "...",
"onlinePlayers": ["Erebus", "Pridetoes"], "worldDay": 12,
"emittedAtUtc": "...", "snapshotIdLocal": "...",
"players": [{ "name": "Erebus", "boss": [{ "boss": "Eikthyr", "kills": 1, "damageDealt": 1450, "fightSec": 95 }], "weapons": [{ "weapon": "Knives", "damageDealt": 80, "kills": 2, "hardestHit": 79 }] }],
"bossKillEvents": [{ "boss": "Eikthyr", "fightSec": 95, "firstBlood": "Erebus", "topDamagePlayer": "Erebus", "topDamage": 1450, "participants": 2, "tsUtc": "..." }],
"milestones": [{ "key": "defeated_eikthyr", "label": "Eikthyr defeated", "kind": "boss", "tsUtc": "..." }],
"mods": [{ "guid": "Azumatt.AzuCraftyBoxes", "name": "AzuCraftyBoxes", "version": "1.8.14", "author": "Azumatt" }]
}
  • onlinePlayers / worldDay power "online now"; the server force-emits on join/leave.
  • The server only reports the combat it owns (idle/distant creatures - the sleeping-backstab case) plus world-progression milestones (global keys) it's authoritative for.

Key conventions

kind format example
creature localization token $enemy_greydwarf
item / craft / pickup localization token $item_club, $item_trophy_boar
material prefab name Wood, Coins, DeerHide
stat vh_<PlayerStatType> vh_EnemyKills
weapon skill category Knives, Bows
boss prefab key Eikthyr, gd_king (Elder), Dragon (Moder), GoblinKing (Yagluth), SeekerQueen, Fader

The merge contract

Valheim simulates each creature on whichever client is nearest, so combat is captured client-side, and the server captures only what it owns. Each hit is therefore seen by exactly one reporter. To combine feeds without double-counting:

  • Weapons & weapon-items - store per (player, weapon, source) where source = reporter (client) or "server". SUM damageDealt/kills, MAX hardestHit/biggestSwing across sources. Exact total, no double-count.
  • Boss damage - GREATEST per (player, boss) (a boss has one owner during a fight, so one reporter holds the real value; others report 0).
  • bossSelfDamage - GREATEST per (world, boss).
  • Multiplayer: because the owner records every attacker's damage (not just the local player), a group's combat totals are exact. Fire/poison damage-over-time is credited to whoever applied it, at the moment of the hit.

Update semantics

  • Cumulative running totals (last value wins per key): kills/deaths/etc., weapons, weaponItems, boss, materials, crafts, skills, stats, pickups.
  • Append-only events (deduped by a deterministic id): deathEvents, bossKillEvents, milestones.
  • Identity columns use COALESCE/GREATEST so the client and server feeds never clobber each other's fields.

Minimal receiver (Node)

import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/api/valheim/ingest', (req, res) => {
if (req.get('authorization') !== 'Bearer my-secret') return res.sendStatus(401);
const b = req.body;
for (const p of b.players ?? [])
for (const w of p.weapons ?? [])
console.log(`${p.name} - ${w.weapon}: ${w.damageDealt} dmg (reporter ${b.reporter ?? b.source})`);
res.json({ status: 'ok' });
});
app.listen(3001);
curl -X POST http://localhost:3001/api/valheim/ingest \
-H 'Authorization: Bearer my-secret' -H 'Content-Type: application/json' \
-d '{"game":"valheim","source":"client","world":"test","players":[{"name":"You","kills":1}]}'

FAQ

  • Stats not showing? Check [General] World matches the server's world name, [Ingest] Token is set, and the Url is reachable from the game machine. Client posts every 120s (and on death) - give it a tick.
  • Why is combat captured client-side? ZDO ownership: Valheim hands each creature's simulation (its Damage/OnDeath) to the nearest client, so the dedicated server never runs it. That's also why the server feed only covers idle/distant creatures.
  • Multiplayer double-counting? No - see the merge contract. Each hit is reported by exactly one owner.
  • Privacy: the mod sends your stats to whatever Url you configure. Only install with a Url/Token you trust.