How a redeemed Tester Key gets linked to a player's in-game device, same three-method contract in all five official SDKs (TypeScript, Python, Unity, Unreal, Godot).
When a tester redeems a key via /redeem/<token> they pick a handle (e.g. RedFox42) and receive a Playloop tester token alongside the game key. The tester pastes that token into the game once; the SDK calls Playloop's claim endpoint; from then on every telemetry event from this device auto-tags with their handle server-side. The studio sees RedFox42 in dashboards instead of an opaque deviceId.
| Method | What it does |
|---|---|
| linkTester(claimToken, …) | Bind this device to a handle. Idempotent on same-device retry; rejects if the token is bound to a different device. |
| getCurrentTester() | Query whether this device is linked, and to which handle. Cheap; safe to call on every menu render. |
| unlinkTester() | Clear the binding, primary use is "Switch tester?" UX or dev/test cleanup. |
All three resolve gameId from per-call > SDK config > error. Most studios set the config gameId once at construction time.
POST /api/telemetry/claim { claimToken, gameId, deviceId }
→ 200 { ok: true, alreadyLinked, handle, claimedAt }
→ 404 { ok: false, reason: 'unknown' } (uniform, see Anti-enumeration)
GET /api/telemetry/tester ?gameId=&deviceId=
→ 200 { linked: true, handle, claimedAt } | { linked: false }
DELETE /api/telemetry/claim { claimToken, gameId, deviceId }
→ 200 { ok: true, unlinked: boolean }
→ 404 { ok: false, reason: 'unknown' } (uniform)Public surfaces, no auth needed; the claim token IS the credential, and the deviceId is the SDK's local identity. All three are rate-limited per IP.
POST and DELETE both collapse every rejection path to the uniform 404 { ok: false, reason: 'unknown' } shape. Wrong-token, wrong-game, wrong-device, already-bound, all look identical on the wire. Playloop's own audit log distinguishes them server-side (cross-game claim, device conflict, unbind rejected, etc.) for studios debugging support cases. The collapse closes a cross-studio inventory-enumeration vector that granular response codes opened, and the DELETE handler additionally requires the original claimToken so a leaked (gameId, deviceId)from an exported CSV / audit row can't be used to grief attribution.
The binding key is (gameId, deviceId). A claim token issued for game A cannot link a device for game B, the server returns the uniform unknown 404. Same applies to getCurrentTester and unlinkTester: each query is scoped to one game. A device can be linked to different testers across different games.
linkTester from the SAME device with the SAME token → returns alreadyLinked: true. Cheap; safe to call on every game launch.linkTester from a DIFFERENT device → uniform 404 unknown. Tester must unlink the original device first, OR get a fresh invite.getCurrentTester → read-only, no state change. Call on every menu render if you want.unlinkTester when nothing is linked → returns unlinked: false (only after the claim-token authorization passes).Three common placements. Pick the one that matches your audience. Whatever you pick, the field is a plain text input that accepts a 32-char base32 token plus the pl_tt_ prefix, nothing special.
Add an item under Settings → Beta tester ID (or Playtest token, Tester linking, whatever matches your naming). Shows the bound handle when linked, prompts for a token when not. Players can ignore it forever and the game plays exactly the same.
On first boot only (track a flag in your player prefs), show a small dismissible card: “Got a Playloop tester token? Paste it here so the studio can see your feedback. Skip if you weren't sent one.” Two buttons: Link + Skip. Never shows again unless the player navigates to Settings.
Add a small “Link Playloop tester token” option to the pause menu (or main menu). Always there, never intrusive, easy to reach mid-session if a tester realises they forgot to link before starting.
After a successful link, replace the input with a confirmation: “Linked as RedFox42 · switch”. The switch link calls unlinkTester and re-renders the input. See Full UX (15 lines) below for the code shape.
The bar is intentionally low so studios actually ship this. A single text input in your main menu + one call:
TypeScript
const token = await prompt('Enter your Playloop tester token (optional):')
if (token) await client.linkTester({ claimToken: token })Python
token = input("Enter your Playloop tester token (optional): ").strip()
if token:
await client.link_tester(claim_token=token)Unity (C#)
var token = mainMenuInput.text;
if (!string.IsNullOrEmpty(token))
{
await client.LinkTesterAsync(token);
}Godot (GDScript)
var token: String = $TesterTokenInput.text
if token != "":
await playloop.link_tester({ "claim_token": token })Show who they're playing as + offer a “Switch tester” affordance:
TypeScript
const tester = await client.getCurrentTester()
if (tester.linked) {
showLabel(`Playing as ${tester.handle}`)
// Persist the claim token locally at linkTester time so you can
// pass it back on unlink (the server requires it — audit L-6).
onSwitchTester(async () => {
await client.unlinkTester({ claimToken: storedClaimToken })
reloadMenu()
})
} else {
const token = await prompt('Enter your Playloop tester token (optional):')
if (token) await client.linkTester({ claimToken: token })
}The other three SDKs mirror this shape, see each SDK's README “Linking a tester” section for the language-specific code.
All three methods resolve gameId from per-call > config. Set it on the SDK constructor so per-call args stay optional:
TypeScript
const client = new Playloop({
apiKey: process.env.PLAYLOOP_INGEST_KEY!,
gameId: 'game_xyz', // ← per-call linkTester etc. resolve from here
})Python
client = Playloop(
api_key=os.environ["PLAYLOOP_INGEST_KEY"],
game_id="game_xyz",
)Unity (C#)
var client = new PlayloopClient(new PlayloopOptions
{
ApiKey = "pl_ik_...",
GameId = "game_xyz",
});Godot (GDScript)
playloop.configure({
"api_key": "pl_ik_...",
"game_id": "game_xyz",
})You can still pass gameId per-call to override (multi-game studios where one client talks to several titles).
Per the Anti-enumeration section above, every rejection collapses to the uniform 404 { ok: false, reason: 'unknown' } on the wire. Unknown token, wrong game, different-device conflict, and unbind-of-nothing all look identical. The SDK surfaces the same friendly message in every reject case. (The server-side audit log distinguishes them for studios debugging support cases.)
| Wire response | Status | When | What to show the tester |
|---|---|---|---|
| { ok: false, reason: 'unknown' } | 404 | ANY rejection: unknown token, wrong game, different-device conflict, etc. | "That token doesn't look right (or it's already linked to another device). Double-check the value, or ask the studio for a fresh invite." |
| { ok: true, alreadyLinked: true } | 200 | Same device re-linking with the same token. Idempotent, not an error. | Silently no-op; the device is already bound. |
| Network / 5xx | 0 / 5xx | Transport / server failure | "Couldn't reach Playloop right now. Your game still works, you can try linking again later." |
The link is non-critical: a failed linkTesterdoes NOT prevent the player from playing. Telemetry will still flow; it just won't carry the handle until the next successful link.
When the SDK calls linkTester, Playloop binds the tester's handle to the device. On every subsequent POST /api/telemetry from this device, the session-create path looks up that binding and stamps the session with the handle the tester picked at redemption.
Read sites (per-game user list, build-summary, analytics) already group by tester handle, no extra wiring needed. The studio sees RedFox42 appear in dashboards within seconds of the first session after linkTester.
Geo-stamping piggybacks: the same path records the country observed at this session's ingest. A future rollup uses those to compute the per-key “likely shared” flag.
getCurrentTester first; only call linkTester if linked === false AND the player just entered a token.These docs are evolving. Playloop is in active development ahead of launch, so APIs and details may change as we polish.