Player data: show the player their balance, level and name in the store

The store opens signed in from a player token. To show the player who they are (name, avatar, level, currency balances) and to refresh those after a purchase and after a free reward is claimed, your backend exposes one read-only endpoint that the store calls. It is optional: without it the store shows the level from the token, and no balances. The spender tier is never shown to the player.

The endpoint

Register the URL in Studio → Integration → Player data URL (HTTPS, your domain). The store calls:

GET <playerDataUrl>?player=<player_id>
x-appside-timestamp: 1793268000
x-appside-signature: <lowercase hex HMAC-SHA256>
Accept: application/json

The store calls with two headers, the same scheme as webhooks:

Verify with a constant-time comparison, and reject timestamps more than 300 seconds from your clock. There is no Authorization header. The player-data. prefix keeps these signatures apart from webhook signatures made with the same secret: a webhook signature covers "<ts>." + rawBody, and a JSON body never starts with player-data..

The response

{
  "name": "Wisp",
  "avatar": "https://game.example/avatars/wisp.png",
  "level": 12,
  "balances": [
    { "currency": "shards", "label": "Star Shards", "amount": 550, "icon": "https://game.example/icons/shard.png" }
  ]
}
FieldTypeShown
namestring ≤ 40Header
avatarHTTPS image URLHeader
levelinteger 0–1,000,000Header only
balances[]up to 4 currencies (currency, label, amount, icon)Header; the first is the primary balance

Offer targeting uses only the attributes in the signed player token (level, spender tier, country, new player). Data returned by this endpoint is displayed and never changes which offers a player can buy. tier and segments fields, if present, are ignored.

Every field is optional. Unknown fields are ignored. The response must be application/json, under 8 KB, and answer within 2 seconds; on error or timeout the store falls back to the token attributes and shows no balances.

When the store calls it

Security notes

Example (Node)

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyPlayerDataSignature(req, secret) {
  const ts = String(req.get('x-appside-timestamp') || ''), sig = String(req.get('x-appside-signature') || '');
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${ts}.player-data.${req.query.player}`).digest();
  const given = Buffer.from(sig, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

app.get('/appside/player', async (req, res) => {
  if (!verifyPlayerDataSignature(req, process.env.APPSIDE_WEBHOOK_SECRET)) return res.status(401).end();
  const p = await players.get(req.query.player);
  if (!p) return res.status(404).end();
  res.json({ name: p.displayName, avatar: p.avatarUrl, level: p.level,
    balances: [{ currency: 'shards', label: 'Star Shards', amount: p.shards }] });
});