Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Data format & self-hosting
Updated a week agoBoth 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 ifTokenis 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
snapshotIdLocallets 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/bossonly) - they're the damage this client witnessed other players deal to creatures it owns (see Multiplayer). weapons[].weaponis a skill category (Knives,Bows,Swords...).weaponItems[].itemis the specific item ($item_knife_bronze).boss[].fightSecis the player's active engagement time, soDPS = 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/worldDaypower "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)wheresource=reporter(client) or"server". SUMdamageDealt/kills, MAXhardestHit/biggestSwingacross sources. Exact total, no double-count. - Boss damage -
GREATESTper(player, boss)(a boss has one owner during a fight, so one reporter holds the real value; others report 0). - bossSelfDamage -
GREATESTper(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/GREATESTso 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] Worldmatches the server's world name,[Ingest] Tokenis set, and theUrlis 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
Urlyou configure. Only install with aUrl/Tokenyou trust.