The HTTP surface that powers Playloop MCP. Twenty-one read endpoints plus one Discord-ingest write, one auth scheme, JSON in / JSON out, use it from your backend, a data pipeline, or any tool that can speak HTTP. Free for every account. Rate-limited per management key to keep agents from runaway loops; paid tiers (Indie, Studio) get higher per-key throughput.
The Management API is the read-only side of Playloop. Where the Telemetry API writes events into Playloop, the Management API reads playtest data out, sessions, insights, build rollups, tester rollups, heatmaps, event aggregates.
Three audiences typically hit it:
list_sessions, get_session, get_build_summary, etc.) resolves to one of the twenty-one HTTP routes below.This is the same surface MCP uses under the hood. Tool-typed access from inside an agent? Use Playloop MCP. Raw HTTP from anything else? You're in the right place.
Every Management API request needs an Authorization: Bearer pl_mgmt_<hex> header. The management key reads every session, insight, build, tester, heatmap, and event count on your account, and authorizes the one Discord-ingest write endpoint documented at the end of this page.
GET /api/v1/games HTTP/1.1Host: playloop.ggAuthorization: Bearer pl_mgmt_<your-key>Playloop has two API keys with deliberately non-overlapping powers. The ingest key is safe to ship in a game binary: it writes telemetry data and reads your game's A/B experiment assignments, but cannot access session data, insights, or any other account resource. The management key is server-side only and reads your account. Nothing destructive (account close, billing, data export, key rotation) accepts either key, those flows are gated behind your signed-in dashboard session or Playloop's own internal infrastructure secrets.
| Key | Can do | Cannot do |
|---|---|---|
pl_ik_…Ingest | POST /api/telemetry (events), POST /api/telemetry/feedback (Player Feedback), and GET /api/telemetry/experiments (A/B variant assignments for the current player). | Read sessions, insights, builds, testers, heatmaps. Connect to MCP. Ingest Discord threads. Modify your account. Any v1 read returns 403 requiredScope: "management". |
pl_mgmt_…Management | Read all /api/v1/* endpoints. Connect to Playloop MCP. Ingest Discord threads via POST /api/webhooks/discord (the one write surface — see caveat below). | Change your password, close your account, modify billing, rotate keys, edit webhook subscriptions, send telemetry events, run cron jobs. |
| Neither | — | Account close, data export, key rotation, billing management, admin surfaces, and Playloop's own scheduled jobs / inbound infrastructure webhooks are reachable only with your signed-in dashboard session or Playloop's internal infrastructure credentials. No API key — leaked or otherwise — gets you there. |
POST /api/webhooks/discord is the one write endpoint authorized by the management key. It exists because a Discord-thread ingest is a one-time studio-side action per channel (linked from the Playloop SDKs, see Docs · Ingest paths), not a per-event ingest stream, so it doesn't belong on the ingest-key surface. A leaked management key could be used to spam-create sessions on your account, treat the key like an admin password, rotate it from Settings → API keys if you suspect exposure.
Settings → API keys , one management key per account, rotatable on demand. The previous value dies the moment you click Rotate, so update your scripts / agent configs before rotating. The two-scope model is also described at Docs · API keys.
Treat the management key like an admin password. It can read every session, insight, and tester record in your account, and it's the only key that can ingest Discord threads. Never bundle it into a game build, never commit it to a repo, never paste it into a client-side codebase. Read it from environment variables (e.g. PLAYLOOP_MANAGEMENT_KEY) and keep .env in .gitignore.
Predictable on purpose, the same auth header, the same error shape, the same pagination envelope on every list endpoint.
https://playloop.gg/api/v1. Path-versioned, breaking changes go to /api/v2, not silently into v1.
Responses are application/json. All endpoints are GET today, no body to send.
Most timestamps are unix milliseconds (e.g. recordedAt, createdAt). Filter params from / to also take unix ms.
Sessions: sess_…. Games accept either the id (game_…) or the slug (your-game) interchangeably anywhere game is a parameter.
Single-resource endpoints return a bare object, e.g. { game, counts } or { session, insights }. The shape is descriptive, keys name what each piece is.
Paginated list endpoints return { items, hasMore, total }. See Pagination below.
Errors always return { error: string, ...extra } with a non-2xx status. See Errors for the full list.
?limit=&offset=Standard offset pagination on every list endpoint (/sessions and /insights). limit defaults to 20 and is silently clamped to a max of 100 (e.g. limit=500 returns 100, not 400). offset defaults to 0. Negative or non-integer values 400.
# Page 1, first 20 sessionscurl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/sessions?limit=20&offset=0" # Page 2, next 20curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/sessions?limit=20&offset=20"{ "items": [/* PlaytestSession[] */], "hasMore": true, "total": 137}On total: it's present on /sessions and /insights today (the cost is negligible, both endpoints already load the full filtered set into memory before paging). Don't rely on it being defined on every future list endpoint, hasMore is the canonical end-of-list signal.
Each management key has a 60 requests-per-minute bucket. A per-IP bucket sits in front of it as a safety net against compromised keys hitting a single origin. Overflow returns 429 with retryAfterMs.
{ "error": "Rate limit exceeded", "retryAfterMs": 4823, "scope": "key"}Recommended client behavior: honor retryAfterMs exactly, then back off exponentially on repeated 429s (double the wait each retry, cap at ~30s). Don't hammer in a tight loop, every retry counts against the same bucket and just extends the cooldown.
Two paginated list endpoints (sessions, insights), nineteen single-resource / nested-list / prescriptive endpoints (games, builds, build-compare, build-compare suggestions, testers, tester journey, tester archetypes, Player Feedback forms, heatmaps, events, playtest batches + keys + invites + handles), all read-only, plus one write endpoint (Discord ingest) at the end. Every one of them gates on the same Bearer management key. Parameters below are tagged Required or Optional; defaults are explicit (a dash means there is no default).
/api/v1/gamesList every game owned by the authenticated key. No pagination, even prolific studios have a handful of games. Returns { games }.
{ "games": [ { "id": "game_abc123", "userId": "user_xyz", "name": "Necromancer's Army", "slug": "necromancers-army", "createdAt": 1778714850693 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/games/api/v1/games/{id}Single game by id or slug, plus rollup counts. Ownership-checked, unowned or unknown returns 404 (we don't leak existence).
idRequiredstring· default –Game id (game_…) or slug. Either works.
{ "game": { /* Game */ }, "counts": { "sessionCount": 137, "buildCount": 4, "testerCount": 29 }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/games/necromancers-army/api/v1/sessionsPaginated session list across all of the caller's games, with optional filters. Returns { items, hasMore, total } (total reflects post-filter count).
gameOptionalstring · 1..120· default –Game id (game_…) or slug. Limits results to one game; 404 if you do not own it.
buildOptionalstring · 1..120· default –metadata.gameVersion exact match. Filters to one build version.
envOptionalstring · 1..64 · [a-z0-9_-]+· default –Environment slug (production, demo, …). Validated against the lowercase / digits / dashes / underscores regex.
statusOptionalenum· default –pending | transcribing | processing | analyzed | failed. Anything else 400s.
sourceOptionalenum· default –manual | discord | unity-telemetry | unreal-telemetry | godot-telemetry | python-telemetry | typescript-telemetry | obs.
qOptionalstring · 1..200· default –Case-insensitive substring against title / testerHandle / transcript.
fromOptionalint · unix ms · >=0· default –Lower bound on recordedAt (inclusive). Must be <= to when both are supplied.
toOptionalint · unix ms · >=0· default –Upper bound on recordedAt (inclusive).
limitOptionalint · 1..100· default 20Page size. Silently clamped to a max of 100 (e.g. limit=500 returns 100); values less than 1 or non-integer return 400.
offsetOptionalint · >=0· default 0Page offset. Negative values 400.
{ "items": [ { "id": "sess_mp4nv055s6oepmc6", "gameId": "game_abc123", "title": "Demo build 0.4.9, first run", "source": "unity-telemetry", "status": "analyzed", "recordedAt": 1778714850693, "duration": 1754, "testerHandle": "device_e9c0…", "metadata": { "gameVersion": "0.4.9", "platform": "Win64" } } ], "hasMore": true, "total": 137}# Last 50 analyzed sessions for one game in the demo envcurl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/sessions?game=necromancers-army\&env=demo&status=analyzed&limit=50"/api/v1/sessions/{id}One session joined with every insight attached to it. Ownership-checked, unowned returns 404.
idRequiredstring· default –Session id (sess_…). 404 if unowned or unknown, same response in both cases (no existence leak).
{ "session": { /* PlaytestSession, full row */ }, "insights": [ { "id": "ins_…", "sessionId": "sess_mp4nv055s6oepmc6", "type": "stuck-point", "sentiment": "negative", "confidence": 0.81, "evidence": "Player retried act2_door 6 times…", "createdAt": 1778716598400 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/sessions/sess_mp4nv055s6oepmc6/api/v1/insightsPaginated insights across all of the caller's sessions, sorted newest-first. Returns { items, hasMore, total }.
gameOptionalstring · 1..120· default –Game id (game_…) or slug. Limits results to one game; 404 if you do not own it.
buildOptionalstring · 1..120· default –metadata.gameVersion exact match. Filters to one build version.
typeOptionalenum· default –stuck-point | confusion | positive-moment | negative-moment | feature-request | bug-report | difficulty-spike | pacing-issue | ui-friction | praise | session-summary.
sentimentOptionalenum· default –positive | neutral | negative.
fromOptionalint · unix ms · >=0· default –Lower bound on createdAt (inclusive). Must be <= to when both are supplied.
toOptionalint · unix ms · >=0· default –Upper bound on createdAt (inclusive).
limitOptionalint · 1..100· default 20Page size. Silently clamped to a max of 100 (e.g. limit=500 returns 100); values less than 1 or non-integer return 400.
offsetOptionalint · >=0· default 0Page offset. Negative values 400.
{ "items": [ { "id": "ins_…", "sessionId": "sess_…", "type": "stuck-point", "sentiment": "negative", "confidence": 0.81, "createdAt": 1778716598400 } ], "hasMore": false, "total": 42}# Negative-sentiment insights for build 0.4.9, last 50curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/insights?game=necromancers-army\&build=0.4.9&sentiment=negative&limit=50"/api/v1/builds/{game}/{version}Build-level rollup for one (game, gameVersion) pair: session count, unique devices, first/last seen, nested stats (totalWallSec, totalActiveSec, avg/median wall + active, engagementRate), and a sampleCountry hint, plus the persisted AI build summary if present.
gameRequiredstring· default –Game id (game_…) or slug.
versionRequiredstring· default –metadata.gameVersion exact match (e.g. "0.4.9"). 404 if no build with that version exists.
envOptionalstring · [a-z0-9_-]+· default production (for summary lookup)Environment scope. Validated against the lowercase / digits / dashes / underscores regex. Falls back to "production" when finding the persisted AI summary.
{ "game": { /* Game */ }, "rollup": { "version": "0.4.9", "sessionCount": 42, "uniqueDevices": 17, "firstSeen": 1778714850693, "lastSeen": 1779314850693, "stats": { "totalWallSec": 312_400, "totalActiveSec": 221_804, "avgWallSec": 7_438, "avgActiveSec": 5_281, "medianWallSec": 6_120, "medianActiveSec": 4_390, "engagementRate": 0.71 }, "sampleCountry": "US" }, "summary": { "narrative": "Build 0.4.9 introduced a stall…", "generatedAt": 1779320000000 }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/builds/necromancers-army/0.4.9?env=demo"/api/v1/testers/{game}/{deviceId}Per-tester rollup for one (game, deviceId) pair: lifetime sessionCount, firstSeen / lastSeen, nested stats (totalWallSec / totalActiveSec / avg / median / engagementRate), lastBuild, and best-known country / region / city (flat, no geo wrapper), plus the persisted AI user summary if present, and the tester's full session list.
gameRequiredstring· default –Game id (game_…) or slug.
deviceIdRequiredstring· default –Persistent anonymous device GUID (from SystemInfo.deviceUniqueIdentifier / OS.get_unique_id). 404 if no tester with that device exists.
envOptionalstring · [a-z0-9_-]+· default production (for summary lookup)Environment scope. Validated against the lowercase / digits / dashes / underscores regex. Falls back to "production" when finding the persisted AI summary.
{ "game": { /* Game */ }, "rollup": { "deviceId": "device_e9c0…", "sessionCount": 6, "firstSeen": 1778714850693, "lastSeen": 1779314850693, "stats": { "totalWallSec": 42_180, "totalActiveSec": 29_944, "avgWallSec": 7_030, "avgActiveSec": 4_990, "medianWallSec": 6_510, "medianActiveSec": 4_720, "engagementRate": 0.71 }, "lastBuild": "0.4.9", "country": "US", "region": "CA", "city": "San Francisco" }, "sessions": [/* PlaytestSession[] */], "summary": { /* UserSummary | null */ }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/testers/necromancers-army/device_e9c0abcd"/api/v1/heatmaps/{game}Per-room density grids for a game. Walks player_pos events from unity-telemetry sessions, bins each room into a grid (defaults to 32×18, exact dimensions returned per-room as cellsX / cellsY), log-scales intensity to a normalized 0..1 range, denser cell == higher intensity, the single densest cell is always exactly 1. Capped at top-12 rooms by event volume; only cells with at least one event are returned (sparse array).
gameRequiredstring· default –Game id (game_…) or slug.
buildOptionalstring· default –metadata.gameVersion exact match. Filters the underlying session set before binning.
envOptionalstring · [a-z0-9_-]+· default –Environment scope. Validated against the lowercase / digits / dashes / underscores regex.
{ "game": { /* Game */ }, "rooms": [ { "room": "act2_corridor", "cellsX": 32, "cellsY": 18, "cells": [/* HeatmapCell[], sparse */ { "x": 14, "y": 7, "intensity": 1 }, { "x": 15, "y": 7, "intensity": 0.62 } ], "totalEvents": 1842, "bounds": { "xMin": -12.4, "xMax": 24.1, "yMin": -3.0, "yMax": 18.7 } } ], "eventCount": 12_847}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/heatmaps/necromancers-army?build=0.4.9&env=demo"/api/v1/events/{game}Event-name occurrence aggregates across a game. Sums each session's persisted event_counts map (no transcript re-walk). Top list capped at 50.
gameRequiredstring· default –Game id (game_…) or slug.
buildOptionalstring · 1..120· default –metadata.gameVersion exact match. Filters the underlying session set before summing.
envOptionalstring · [a-z0-9_-]+· default –Environment scope. Validated against the lowercase / digits / dashes / underscores regex.
fromOptionalint · unix ms · >=0· default –Lower bound on recordedAt (inclusive). Must be <= to when both are supplied.
toOptionalint · unix ms · >=0· default –Upper bound on recordedAt (inclusive).
{ "game": { /* Game */ }, "sessionCount": 42, "total": 128_472, "byName": { "click": 81_204, "level_completed": 96, … }, "top": [ ["click", 81_204], ["player_pos", 27_312], ["level_completed", 96] ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/events/necromancers-army?build=0.4.9"Surface for the dashboard's playtest-distribution feature (mint a batch → upload or generate keys → send 1:1 invites or share a public redemption link). The mutations live in the dashboard; here you can read state for an MCP agent or a side-script.
/api/v1/batches/{game}List every playtest batch for a game. Returns rollup counts (key_count / redeemed_count / revoked_count) so a dashboard can render lifecycle without N round-trips.
gameRequiredstring· default –Game id (game_…) or slug.
{ "game": { "id": "game_…", "slug": "…", "name": "…" }, "batches": [ { "id": "ptb_…", "name": "Next Fest Press", "distribution_target": "steam", "fulfillment_source": "studio-uploaded", "redemption_mode": "invite", "identity_mode": "handle+email", "share_token": null, "key_count": 50, "redeemed_count": 31, "revoked_count": 2, "expires_at": 1784000000000, "created_at": 1778714850693 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/batches/necromancers-army/api/v1/batches/{game}/{batchId}Detail for one batch. Same fields as the list endpoint plus the full instructions Markdown (the redemption-page copy the studio wrote).
gameRequiredstring· default –Game id or slug.
batchIdRequiredstring· default –playtest_batches.id (ptb_…).
{ "game": { /* Game */ }, "batch": { /* …all batch fields, plus: */ "instructions": "# Welcome\n\nThanks for testing…" }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/batches/necromancers-army/ptb_abc/api/v1/batches/{game}/{batchId}/keysLifecycle + redemption metadata for every key in a batch. Cleartext / ciphertext / sha256-fingerprint of the key are stripped server-side, never on the wire. Redemption IP is stripped; country (ISO-3166 alpha-2) is what stays.
gameRequiredstring· default –Game id or slug.
batchIdRequiredstring· default –playtest_batches.id.
{ "batch": { "id": "ptb_…", "name": "Next Fest Press" }, "keys": [ { "id": "ptk_…", "status": "redeemed", // available | reserved | redeemed | revoked | expired "redeemed_at": 1779600000000, "redeemed_by_email": "alex@studio.gg", "redeemed_by_handle": "alex-press", "redemption_country": "DE", "redemption_user_agent": "Mozilla/5.0 …", "tester_invite_id": "tvi_…", "created_at": 1778714850693 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/batches/necromancers-army/ptb_abc/keys/api/v1/batches/{game}/{batchId}/invitesEvery 1:1 invite in a batch, recipient, optional personal note, and the send / opened / redeemed timeline. The per-tester redemption token is NEVER returned (it's a one-shot URL credential). opened_at is best-effort (many clients block tracking pixels); redeemed_at is definitive.
gameRequiredstring· default –Game id or slug.
batchIdRequiredstring· default –playtest_batches.id.
{ "batch": { "id": "ptb_…", "name": "Next Fest Press" }, "invites": [ { "id": "tvi_…", "email": "alex@studio.gg", "personal_note": "Loved your last devlog, would love your take.", "sent_at": 1778714850693, "opened_at": 1778715900000, "redeemed_at": 1779600000000 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/batches/necromancers-army/ptb_abc/invites/api/v1/handles/{game}Every SDK-linked tester handle for a game, with the cached rollup (session_count, total_play_time_ms, last_seen_at, distinct_countries). Sorted by last_seen_at desc, recently-active testers first. The one-shot claim token is stripped server-side. distinct_countries ≥ 3 is the documented 'likely-shared key' abuse signal.
gameRequiredstring· default –Game id or slug.
{ "game": { /* Game */ }, "handles": [ { "id": "pth_…", "handle": "alex-press", "key_id": "ptk_…", "device_id": "device_e9c0…", "claimed_at": 1779600000000, "session_count": 12, "total_play_time_ms": 4_217_000, "last_seen_at": 1780100000000, "distinct_countries": 1, // ≥3 = abuse signal "created_at": 1779600000000 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/handles/necromancers-army/api/v1/builds/compareClassified friction diff between two builds. Per-tag breakdown (resolved / got_smaller / carried_over / got_larger / introduced) plus the per-build rollups. Powers the dashboard's build-compare view; the MCP `compare_builds` tool wraps this.
gameRequiredstring· default –Game id or slug.
aRequiredstring · 1..200· default –Baseline build version.
bRequiredstring · 1..200· default –Comparison build version.
envOptionalstring · [a-z0-9_-]+· default productionEnvironment slug to scope both sides of the diff.
{ "game": { /* Game */ }, "environment": "production", "buildA": { "version": "0.4.9", "testersInBatch": 18, /* … */ }, "buildB": { "version": "0.5.0", "testersInBatch": 22, /* … */ }, "diff": { "changes": [ { "kind": "resolved", "tag": "tutorial-confusion", "deltaPct": -42 }, { "kind": "got_larger", "tag": "vendor-ui", "deltaPct": 18 }, { "kind": "introduced", "tag": "save-on-quit-bug", "deltaPct": 27 } ], "totals": { "resolved": 4, "gotSmaller": 2, "carriedOver": 5, "gotLarger": 1, "introduced": 2 } }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/builds/compare?game=alpha&a=0.4.9&b=0.5.0"/api/v1/builds/compare/suggestionsPrescriptive (not descriptive), AI-ranked concrete fixes grounded in the tester sessions that surfaced the friction. Single-build mode: ?build=<v> targets the top tags. Compare mode: ?a=&b= targets tags that are still bad (carried_over, got_larger, introduced). BYO AI key required (Free is BYOK-only); a 402 requiresPremium:true is returned otherwise. Per-key rate-limited.
gameRequiredstring· default –Game id or slug.
buildOptionalstring · 1..200· default –Single-build mode. Mutually exclusive with a + b.
aOptionalstring · 1..200· default –Compare mode: baseline. Pair with b.
bOptionalstring · 1..200· default –Compare mode: target. Pair with a.
envOptionalstring · [a-z0-9_-]+· default productionEnvironment slug. 'all' is rejected, concrete env only.
{ "ok": true, "mode": "compare-diff", // or "single-build" "game": { /* Game */ }, "environment": "production", "a": "0.4.9", // present in compare mode "b": "0.5.0", "suggestions": [ { "title": "Surface a hint after 90s idle in the hub", "rationale": "…", "evidence": ["ins_…"], "confidence": 0.82 } ], "source": "ai", // or "fallback" when AI declined "emptyInput": false}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/builds/compare/suggestions?game=alpha&a=0.4.9&b=0.5.0"/api/v1/testers/{game}/{deviceId}/journeyOne tester's full session-by-session timeline for the game (chronological, oldest first). Each entry: session metadata, per-session insights, top friction summary, top praise. Includes an engagement-trend tag (rising / flat / declining / insufficient_data) computed from first-3 vs last-3 sessions' avg active duration. {deviceId} accepts a tester handle OR a device GUID, handle tried first, device id second. Bounded to 256 chars to prevent buffer abuse.
gameRequiredstring · 1..256· default –Game id or slug.
deviceIdRequiredstring · 1..256· default –Tester handle (preferred) OR persistent device GUID.
envOptionalstring · [a-z0-9_-]+· default –Optional environment scope.
{ "journey": { "tester": { "handle": "RedFox42", "deviceId": "device_…", "identityLabel": "RedFox42" }, "game": { "id": "gm_…", "slug": "alpha", "name": "Alpha" }, "totalSessions": 9, "totalActiveSec": 4280, "engagementTrend": "declining", // rising | flat | declining | insufficient_data "entries": [ /* per-session entries, oldest first */ ], "summary": "Steady early, dropped off after build 0.4.9 hub puzzle." }}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/testers/alpha/RedFox42/journey/api/v1/testers/{game}/archetypesEmbed every tester's narrative summary, greedy-cluster by cosine similarity, label each cluster. Returns an `archetypes` array (each with label, count, representative testers, common friction tags). Always returns 200 with a discriminated reason, empty `archetypes` + reason:'requires_byo_key' when no embedding key is configured, reason:'insufficient_data' when <5 testers have summaries, reason:'api_error' on provider failure. Requires a BYO embedding key on the user (OpenAI text-embedding-3-small or Google text-embedding-004).
gameRequiredstring· default –Game id or slug.
envOptionalstring · [a-z0-9_-]+· default –Optional environment scope (default: production).
{ "archetypes": [ { "id": "arc_a1b2…", "label": "Optimizers", "testerCount": 7, "commonFrictionTags": ["pacing-issue", "shop-ui"], "avgSessionCount": 8.4, "avgActivePlayMinutes": 42.1, "representativeTesters": [ { "handle": "RedFox42", "summary": "…" } ] } ], "testerCount": 23, "modelId": "openai/text-embedding-3-small", "reason": undefined // requires_byo_key | insufficient_data | api_error}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/testers/alpha/archetypes/api/v1/games/{id}/promptsStudio-defined Player Feedback forms. Each row: form name, fields array (id + label + kind in `rating-1-5` / `short-text` / `long-text` / `yes-no`), trigger hint, active flag, per-form submission count (DISTINCT submissionId, not raw answer rows). Tester-level submission rows are NOT returned here, they live on the session and slot into `get_session`. {id} accepts a game id OR slug. Path kept stable for back-compat with shipped MCP clients; new code prefers the `list_game_feedback_forms` MCP tool, which targets the same data.
idRequiredstring· default –Game id or slug.
{ "game": { /* Game */ }, "forms": [ { "id": "ff_…", "name": "Session feedback", "fields": [ { "id": "q1", "label": "Rating", "kind": "rating-1-5", "required": true }, { "id": "q2", "label": "What worked?", "kind": "short-text" }, { "id": "q3", "label": "What didn\u2019t?","kind": "long-text" } ], "triggerHint": "after_session", "active": true, "responseCount": 29 } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/games/alpha/prompts/api/v1/games/{id}/experimentsEvery A/B experiment for a game (newest first, deleted ones excluded). Each row: status (draft / running / stopped), the variant catalog ({ key, name, allocation }, where allocations are percentages), the targeting audience id plus resolved audienceName (null when the experiment targets all players), the picked winnerVariantKey (or null), and start/stop timestamps. {id} accepts a game id OR slug. The MCP `list_game_experiments` tool wraps this.
idRequiredstring· default –Game id or slug.
{ "game": { /* Game */ }, "experiments": [ { "id": "exp_...", "name": "Tutorial test", "status": "running", // draft | running | stopped "variants": [ { "key": "control", "name": "Control", "allocation": 50 } ], "audienceId": "aud_...", "audienceName": "Beta cohort", // null when no audience filter "winnerVariantKey": null, "startedAt": 1779600000000, "stoppedAt": null } ]}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/games/alpha/experiments/api/v1/experiments/{id}/comparisonOne experiment's latest per-variant raw-stats comparison plus the persisted AI cross-variant digest (if generated). comparison.variants carries per variant: session count, cohort-eligible D1/D2/D7 retention, engagement %, crash rate, top friction / praise clusters, and sample feedback quotes. digest is null until first generated; when present it carries the recommendation, per-variant headlines, shared themes, sentiment shift, and the headline confidence label. {id} is the experiment id. Returns 404 when the experiment does not exist or belongs to another user. The MCP `get_experiment_comparison` tool wraps this.
idRequiredstring· default –Experiment id.
{ "comparison": { "experimentId": "exp_...", "gameId": "gm_...", "generatedAt": 1780100000000, "variants": [ { "key": "control", "name": "Control", "allocation": 50, "sessionCount": 42, "retention": [ { "day": 1, "eligible": 30, "retained": 18, "rate": 0.6 } ], "engagementPct": 0.72, "crashRate": 0.04, "topFriction": [ { "type": "ui-friction", "tag": "menu", "count": 6 } ], "topPraise": [ /* clusters */ ], "sampleQuotes": [ "Loved the new hub." ] } ] }, "digest": { "status": "ok", // ok | degraded "confidenceLabel": "leaning", "digest": { "recommendation": "...", "perVariant": [ /* ... */ ] } } // null until first generated}curl -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/experiments/exp_1a2b3c/comparisonThe management key authorizes one write endpoint: the Discord-thread ingest used by the Discord ingest path. Calling it creates (or appends to) a session with source discord and kicks the analyzer pipeline. See the security caveat in Authentication for why this write lives on the management surface.
/api/webhooks/discordCreate or append-to a Discord-source playtest session for a given channel/thread. Auth via ?key= (management scope; ingest keys 403). Returns { sessionsCreated, messagesIngested } so the SDK has stable counts to surface.
keyRequiredstring · pl_mgmt_…· default –Management API key passed as a query param (this endpoint does not accept the Authorization header). The TS / Python SDKs send it for you.
channel_idRequiredstring · 1..120· default –Discord channel id the session is bound to. Used as the dedup key for the 30-minute append-to-open-session window.
gameRequiredstring · 1..120· default –Game slug (resolved per user). 400 with "unknown game" if you do not own a game with that slug.
thread_idOptionalstring · 1..120· default –Optional Discord thread id. When supplied, dedup is per-(channel, thread); when omitted, dedup is channel-level.
// 201 Created, fresh session{ "sessionsCreated": 1, "messagesIngested": 0} // 200 OK, appended to existing open session{ "sessionsCreated": 0, "messagesIngested": 0}# Note: ?key= goes in the URL, not the Authorization header.curl -X POST \ -H "Content-Type: application/json" \ -d '{"channel_id":"1234567890","game":"necromancers-army","thread_id":"thr_42"}' \ "https://playloop.gg/api/webhooks/discord?key=$PL_MGMT_KEY"Body is capped at 100 KB; rate limit is the same per-key 60/min bucket as the v1 reads.
These endpoints accept an ingest key (pl_ik_…) via Authorization: Bearer. They are safe to call from a game client and return only data scoped to the key owner's account.
/api/telemetry/experimentsReturn the pre-resolved A/B variant map for the current player device. The SDK calls this once per session, caches the result, and tags the first telemetry flush with it. The server re-validates on ingest and persists only matched tags.
gameIdRequiredstring· default –Game id from your game's settings page. Scopes the response to running experiments for that game only.
deviceIdRequiredstring · 1..128· default –Stable per-device identifier. The server uses this (plus the game id) to deterministically bucket the player into each experiment variant. Use the same value you pass in telemetry.
buildVersionOptionalstring · 1..64· default –Optional build version string. When present, audience predicates that filter by build version are evaluated against this value.
// 200 OK{ "experiments": { "exp_tutorial_v2": "treatment", "exp_hud_layout": "control" }} // 200 OK (no running experiments){ "experiments": {} }curl \ -H "Authorization: Bearer $PL_INGEST_KEY" \ "https://playloop.gg/api/telemetry/experiments ?gameId=game_abc123&deviceId=dev_xyz789"Response carries Cache-Control: private, max-age=60. Rate limited to 120 requests per minute per IP, matching the telemetry ingest ceiling. A leaked ingest key cannot read another tenant's experiments by guessing a gameId: the response is scoped to experiments owned by the key's account.
This endpoint is the only experiment surface on an API key, it's what your game calls to read a player's variant map. Everything else, creating an experiment, defining variants and allocations, attaching an audience, starting / stopping / resuming it, reading the side-by-side comparison, and generating the AI digest, is done from the dashboard's Experiments tab (authenticated by your signed-in session, not an API key). See Docs · A/B experiments for the full walkthrough.
Every non-2xx response is { error: string, ...extra }. No stack traces, no internal field names, just enough to fix the caller.
Missing the Authorization header, or the bearer token doesn't resolve to any known key. Response: { error: "Missing bearer token" } or { error: "Invalid key" }.
The bearer resolves to an pl_ik_… ingest key. Response: { error, requiredScope: "management" }. Fix: use a management key from Settings → API keys.
The game / session / build / tester doesn't exist, OR it exists but is owned by a different user. We return the same 404 in both cases on purpose so the API can't be used to enumerate other accounts.
Bucket exhausted. Response: { error, retryAfterMs, scope: "key" }. Honor retryAfterMs exactly, see Rate limits.
Query params failed validation, e.g. limit=0, env=BadEnv, from greater than to. (Note: limit values above 100 are silently clamped to 100, not rejected.) Response includes a zod issues array pointing at the offending field.
Surfaces as { error: "Internal error" }. We log + capture every one, no stack traces leak to the client. If you can repro one consistently, email with the request shape and we'll dig in.
Bearer auth + JSON response. Nothing fancy, any HTTP client works. Read the key from the environment, never from source.
Quickest one-off, useful for poking the API from a shell while you build a real integration.
export PL_MGMT_KEY="pl_mgmt_<your-key>" # List gamescurl -sS -H "Authorization: Bearer $PL_MGMT_KEY" \ https://playloop.gg/api/v1/games | jq . # Last 20 analyzed sessions for one gamecurl -sS -H "Authorization: Bearer $PL_MGMT_KEY" \ "https://playloop.gg/api/v1/sessions?game=necromancers-army&status=analyzed" \ | jq '.items[] | { id, title, duration }'Built-in fetch on Node 18+ / Bun / Deno / edge runtimes. No SDK required, the surface is intentionally small enough to call directly.
const KEY = process.env.PLAYLOOP_MANAGEMENT_KEY! async function listSessions(game: string, opts: { limit?: number } = {}) { const params = new URLSearchParams({ game, limit: String(opts.limit ?? 20) }) const res = await fetch(`https://playloop.gg/api/v1/sessions?${params}`, { headers: { Authorization: `Bearer ${KEY}` }, }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(`${res.status}: ${body.error ?? res.statusText}`) } return res.json() as Promise<{ items: unknown[]; hasMore: boolean; total: number }>} const { items } = await listSessions('necromancers-army', { limit: 50 })console.log(`Got ${items.length} sessions`)Use requests for one-off scripts and data-pipeline runners, pandas-friendly once you have items in hand.
import os, requests KEY = os.environ["PLAYLOOP_MANAGEMENT_KEY"]BASE = "https://playloop.gg/api/v1" def list_insights(game: str, build: str, *, limit: int = 100): r = requests.get( f"{BASE}/insights", params={"game": game, "build": build, "limit": limit}, headers={"Authorization": f"Bearer {KEY}"}, timeout=30, ) r.raise_for_status() return r.json()["items"] for ins in list_insights("necromancers-army", "0.4.9"): print(ins["type"], ins["sentiment"], ins["confidence"])The Playloop MCP server already wraps this entire surface in tool-typed access for Claude, Cursor, Codex, and every other MCP-aware client. Use MCP if you want the agent to discover the tools natively; use raw HTTP if you're doing your own integration.
Today's surface is /api/v1/*. Within a version we'll add new endpoints and new optional fields freely — your client breaking on a new field is on the parser, not us. We won't remove fields, rename fields, tighten validators, or change response shapes within v1 without an explicit version bump.
Breaking changes go to /api/v2/*. When that lands, v1 stays online with a deprecation window long enough to migrate (we'll publish the window with the announcement — minimum 6 months). New features only land on the newer version; v1 stops getting additions once v2 is announced.
Track the changelog for every shipped change. Auth, rate limits, and error shapes are platform-wide and apply across versions.
SDK install, the telemetry contract, API key scopes, webhooks, the MCP integration. Everything that isn't the HTTP read surface lives on the main docs hub.
Connect Claude Desktop, Claude Code, Cursor, or Codex CLI in one config block. Same surface, but discovered natively by the agent.
These docs are evolving. Playloop is in active development ahead of launch, so APIs and details may change as we polish.