Choosing How Players Connect
Pick how game clients reserve seats and join servers: through your backend, with tokens your login service signs, or with anonymous tokens. Three options, three setups.
A player token is a short-lived credential a game client presents to Discovery so it can reserve seats, quick-join, and queue for a match on its own. With one, players join servers without you running a backend between the client and Discovery. This page lays out the three ways to let players connect, helps you pick one, and gives you the code for each.
The examples use https://discovery.pingcore.io; your app page shows your exact base URL. Replace dscp_YOUR_PUBLIC_ID with your app's public ID.
The three options
Backend-authenticated
Needs a backend: yes. Your backend holds an allocate token and calls Discovery for the player.
Who authenticates the player: your backend, any way you like.
Anti-abuse strength: strongest. Your backend decides every call and can apply its own rules before Discovery sees it.
Matchmaking: full ticket contract, no player floors. The only way to start a solo session (
sessionSize: 1) or a full-party private session from a queue.Best for: studios that already run a backend or matchmaker and want full control.
Setup time: about 5 minutes if you have a backend.
Studio-signed player tokens
Needs a backend: no PingCore-facing backend. You need an existing login service that can sign a JWT.
Who authenticates the player: your login service (Steam, Epic Online Services, your own accounts). You sign a short-lived token per player; Discovery verifies it locally and never calls you.
Anti-abuse strength: strong. Only players you signed for can act, and every limit is tied to a player identity you control, so a bad actor is one account you can ban. Discovery adds per-player rate limits, a reservation cap, and an active-ticket cap on top.
Matchmaking: the client submits and polls its own tickets, with the player floors (sessions of at least 2, a party that never fills a session alone, no self-reported attributes by default). Ratings your backend holds are submitted by the backend with a
dsc_token.Best for: studios with player accounts but no join or matchmaking service.
Setup time: about 15 minutes: generate a key, add three lines to your login flow.
Anonymous player tokens
Needs a backend: no. The game client asks Discovery for a token directly.
Who authenticates the player: nobody. Discovery hands out a short-lived anonymous identity.
Anti-abuse strength: basic. Limits are tied to the token and to the requesting address, not to a person or device: someone who keeps requesting new tokens from one address can hold up to (tokens per minute x reservations per token) reservations at a time. Per-IP issuance caps, per-player rate limits, a per-player reservation cap, and a short TTL keep that bounded. If your game is a target for griefing, use signed tokens.
Matchmaking: the same client-submitted tickets as signed tokens, with the same floors. Two anonymous tokens from one client can still form a 2-seat session every 10 seconds; that is the accepted floor of anonymous mode.
Best for: prototypes, jams, early access, small community games, or as a fallback while you build login.
Setup time: about 2 minutes: flip one switch, call one endpoint.
You can turn on both player-token modes. Backend calls keep working whatever you choose.
How to decide
You already run a backend or a matchmaker: use backend-authenticated calls. Nothing changes for you.
You have player accounts (Steam, Epic Online Services, your own login) but no join service: use studio-signed player tokens.
You have nothing yet: start with anonymous player tokens. When you add login later, switch to signed tokens. The client code stays the same; only where the token comes from changes.
You want players to queue for matches without a backend: either player-token mode covers tickets too, within the player floors. A solo session started from the client (a private practice server, a solo campaign) is the one case that needs a backend: sign player tokens, have your backend decide who may have a server right now, and let the backend submit the
sessionSize: 1ticket with itsdsc_token.
Option 1: Backend-authenticated
Your backend authenticates the player however it already does, then calls Discovery with an Allocate-scope token (dsc_...). Reserve, quick-join, and the reservation lifecycle endpoints are covered in Reservations and Quick-Join; allocations and tickets are covered in Matchmaking Integration. Keep the allocate token on your backend. It is a secret and must never ship in a game client.
Nothing on this page changes backend-authenticated calls. Reservations your backend makes are owned by your backend, and it can read or release any reservation in the app.
Option 2: Studio-signed player tokens
Your login service signs a JWT for each player after it has authenticated them. The game client sends that JWT to Discovery as a bearer token. Discovery verifies the signature against the public key stored on your app, checks the claims, and treats the token's sub as the player. Discovery never calls your login service and never holds your private key.
Set it up in the workspace
Open Discovery in your PingCore workspace and select your app.
Under Player Access, click Generate signing key and give it a name (for example
production).Copy the private key. It is shown once and PingCore does not keep a copy. Store it in the secret store your login service reads from, note the Key ID (
dsk_...) shown next to it, then click I have saved it.Turn on Studio-signed player tokens.
Changes under Player Access reach Discovery within about 60 seconds. A token signed before that window closes answers 403 signed_tokens_disabled or 401 no_signing_keys; retry after a minute.
Token rules
Every token you sign must satisfy these rules. Discovery rejects anything else with a 401 and a reason code (see Errors you may see).
Header
algis exactlyES256.Header
kidis the Key ID from Player Access. It is optional, but include it: withkidset, a revoked key answers a clearunknown_kid; without it, Discovery tries every active key. When present it must be a non-empty string of at most 64 characters.audis your app's public ID (dscp_...), as a string or as an array containing it. A token minted for one app is refused by every other app.subis your player id. 1 to 128 characters of letters, digits, and_ . : @ -. It must not start withanon:, which is reserved for anonymous tokens. This value becomes the reservation'sownerPlayerId, so use the id your game server already knows the player by.iatandexpare both required, in seconds.expmay be at most 3600 seconds afteriat. Sign short tokens (15 minutes is plenty) and issue a fresh one when the client needs it.Discovery allows 60 seconds of clock skew.
nbfis optional and honoured; when present it must be integer seconds.issandjtiare optional and not checked for content, but if present they must be strings (issat most 200 characters,jtiat most 100). A wrong type or length on any of these answers 401malformed_token.
Sign the token
Add this to your login service where it already knows who the player is. Each snippet loads the PKCS#8 PEM from an environment variable and signs the exact claims above with a 15 minute lifetime.
// dotnet add package System.IdentityModel.Tokens.Jwt
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
public static class PlayerTokens
{
private const string KeyId = "dsk_YOUR_KEY_ID";
private const string PublicId = "dscp_YOUR_PUBLIC_ID";
// The PKCS#8 PEM copied from Player Access. Load it from your secret store, never from source control.
private static readonly ECDsaSecurityKey SigningKey = LoadKey(
Environment.GetEnvironmentVariable("DISCOVERY_SIGNING_KEY_PEM"));
private static ECDsaSecurityKey LoadKey(string privateKeyPem)
{
var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(privateKeyPem);
return new ECDsaSecurityKey(ecdsa) { KeyId = KeyId };
}
public static string Sign(string playerId)
{
var now = DateTime.UtcNow;
var descriptor = new SecurityTokenDescriptor
{
Audience = PublicId,
Subject = new ClaimsIdentity(new[] { new Claim(JwtRegisteredClaimNames.Sub, playerId) }),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(15),
SigningCredentials = new SigningCredentials(SigningKey, SecurityAlgorithms.EcdsaSha256),
};
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(handler.CreateToken(descriptor));
}
}// npm install jsonwebtoken
import jwt from 'jsonwebtoken';
const KEY_ID = 'dsk_YOUR_KEY_ID';
const PUBLIC_ID = 'dscp_YOUR_PUBLIC_ID';
// The PKCS#8 PEM copied from Player Access. Load it from your secret store, never from source control.
const privateKeyPem = process.env.DISCOVERY_SIGNING_KEY_PEM;
export function signPlayerToken(playerId) {
// jsonwebtoken sets iat to now; expiresIn sets exp relative to it.
return jwt.sign({ sub: playerId }, privateKeyPem, {
algorithm: 'ES256',
keyid: KEY_ID,
audience: PUBLIC_ID,
expiresIn: '15m',
});
}# pip install "PyJWT[crypto]"
import os
import time
import jwt
KEY_ID = "dsk_YOUR_KEY_ID"
PUBLIC_ID = "dscp_YOUR_PUBLIC_ID"
# The PKCS#8 PEM copied from Player Access. Load it from your secret store, never from source control.
PRIVATE_KEY_PEM = os.environ["DISCOVERY_SIGNING_KEY_PEM"]
def sign_player_token(player_id: str) -> str:
now = int(time.time())
return jwt.encode(
{"sub": player_id, "aud": PUBLIC_ID, "iat": now, "exp": now + 900},
PRIVATE_KEY_PEM,
algorithm="ES256",
headers={"kid": KEY_ID},
)// go get github.com/golang-jwt/jwt/v5
package playertokens
import (
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
const keyID = "dsk_YOUR_KEY_ID"
const publicID = "dscp_YOUR_PUBLIC_ID"
// SignPlayerToken signs a 15 minute player token for playerID.
// DISCOVERY_SIGNING_KEY_PEM holds the PKCS#8 PEM copied from Player Access.
func SignPlayerToken(playerID string) (string, error) {
privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(os.Getenv("DISCOVERY_SIGNING_KEY_PEM")))
if err != nil {
return "", err
}
now := time.Now()
claims := jwt.RegisteredClaims{
Subject: playerID,
Audience: jwt.ClaimStrings{publicID},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = keyID
return token.SignedString(privateKey)
}Return the signed token to the game client as part of your normal login response. The client never sees the private key.
Use it from the client
The client calls the same endpoints your backend would, with the player token in place of the allocate token. Quick-join is POST /v1/apps/{publicId}/quick-join; reserving a chosen server is POST /v1/apps/{publicId}/servers/{serverId}/reservations; reading and releasing a hold are GET and DELETE /v1/apps/{publicId}/reservations/{reservationId}. Request bodies and responses are exactly as described in Reservations and Quick-Join, with two additional response fields, ownerKind ("player") and ownerPlayerId (your token's sub). Matchmaking tickets work the same way: POST /v1/apps/{publicId}/tickets, then GET and DELETE /v1/apps/{publicId}/tickets/{ticketId}, as described under Tickets from the game client. All of these endpoints answer cross-origin requests from any origin, so a browser game can call them directly.
// Works in Unity (.NET Standard 2.1) and any .NET client.
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class DiscoveryClient
{
private static readonly HttpClient Http = new HttpClient();
private const string BaseUrl = "https://discovery.pingcore.io";
private const string PublicId = "dscp_YOUR_PUBLIC_ID";
// playerToken: the JWT your login service returned to this client.
public static async Task<JsonDocument> QuickJoinAsync(string playerToken, int seats)
{
// Reuse the same idempotencyKey when you retry one join, so a retry replays instead of holding twice.
var body = JsonSerializer.Serialize(new { idempotencyKey = Guid.NewGuid().ToString("N"), seats });
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/apps/{PublicId}/quick-join");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", playerToken);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"quick-join failed ({(int)response.StatusCode}): {json}");
}
return JsonDocument.Parse(json); // read ip, port, reservationId, expiresIn
}
}const BASE_URL = 'https://discovery.pingcore.io';
const PUBLIC_ID = 'dscp_YOUR_PUBLIC_ID';
// playerToken: the JWT your login service returned to this client.
export async function quickJoin(playerToken, seats = 1) {
const response = await fetch(`${BASE_URL}/v1/apps/${PUBLIC_ID}/quick-join`, {
method: 'POST',
headers: {
Authorization: `Bearer ${playerToken}`,
'Content-Type': 'application/json',
},
// Reuse the same idempotencyKey when you retry one join, so a retry replays instead of holding twice.
body: JSON.stringify({ idempotencyKey: crypto.randomUUID(), seats }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(`quick-join failed (${response.status}): ${result.reason || ''} ${result.message}`);
}
return result; // ip, port, reservationId, expiresIn
}Hand ip and port to the game and connect before expiresIn runs out, exactly as with a backend-made reservation.
A few limits apply to every player token, signed or anonymous, and do not apply to your backend's allocate token:
60 requests per minute per player, and 6000 player requests per minute across the app.
2 active reservations per player. A third answers 409
too_many_reservationsuntil one is released or expires.At most 8 seats per reservation.
contextof at most 1024 bytes.On tickets: a party of at most 8,
sessionSizeandminSessionSizeof at least 2, a party strictly smaller than both,relaxAfterSecondsraised to at least 10, noattributesunless the operator raises the limit for your app, and 1 queued ticket at a time (409too_many_tickets).
Players only ever see their own holds and tickets. A GET on a reservation another player made answers the same 404 as an unknown id, and a DELETE on one answers 200 without releasing anything. Your backend's allocate token still sees and releases everything in the app.
Verify on your game server
The verify endpoint, GET /v1/reservations/verify/{reservationId}, is unchanged and still takes your game server's Heartbeat-scope token. Its response now carries ownerKind ("backend" or "player") and ownerPlayerId (the token's sub, or null for backend-made holds). When the reservation was made with a signed token, compare ownerPlayerId with the id of the player connecting. A valid reservation presented by a different player is a rejection.
{
"error": false,
"valid": true,
"reservationId": "join-77",
"serverId": "203.0.113.9:27015",
"seats": 1,
"playerIds": [],
"context": null,
"expiresAt": 1756750060000,
"ownerKind": "player",
"ownerPlayerId": "steam:76561198000000001"
}Rotating your signing key
An app holds at most two signing keys at once: the current one and the previous one. To rotate:
Under Player Access, click Generate signing key again. Both keys now verify.
Deploy the new private key and Key ID to your login service.
Once every login instance signs with the new key, click the Revoke signing key button next to the old key and confirm with Revoke.
Revocation reaches Discovery within about 60 seconds. Tokens signed with the revoked key then answer 401 unknown_kid (or bad_signature when the token has no kid), and the player needs a fresh token from your login service. Because player tokens live for minutes, rotating during normal traffic is safe as long as the old key stays active until the new one is deployed everywhere. If you lose a private key, generate a new key and revoke the lost one the same way. Keys have no expiry of their own: the previous key stays valid until you revoke it.
Option 3: Anonymous player tokens
Discovery issues the player identity itself. The game client asks for a token, receives a random anonymous id, and uses the token for the reservation and quick-join calls. No login, no backend, no key to manage.
Set it up in the workspace
Open Discovery in your PingCore workspace, select your app, and under Player Access turn on Anonymous player tokens. The switch reaches Discovery within about 60 seconds; until then, token requests answer 403 anonymous_tokens_disabled.
Request a token
curl -X POST "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/player-tokens" \
-H "Content-Type: application/json" \
-d '{}'No authentication is needed. Send an empty JSON object; any fields you include are ignored, so a shipped client keeps working if the contract grows. You should see:
{
"error": false,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwaW5nY29yZS1kaXNjb3ZlcnkiLCJhdWQiOiJkc2NwX1lPVVJfUFVCTElDX0lEIiwic3ViIjoiYW5vbjozZjFjIiwiaWF0IjoxNzU2Nzc4NDAwLCJleHAiOjE3NTY4MDAwMDB9.c2lnbmF0dXJl",
"tokenType": "anonymous",
"playerId": "anon:3f1c8a2e-6b4d-4c1f-9e7a-2d5b8c0f1a3e",
"expiresAt": 1756800000000,
"expiresIn": 21600
}token: the bearer to send on reservation and quick-join calls. Treat it as opaque.
playerId: the anonymous identity Discovery assigned, always prefixed
anon:. This is what your game server sees asownerPlayerIdon verify.expiresAt: when the token stops working, in epoch milliseconds. expiresIn is the same in seconds (6 hours by default).
Cache the token on the client and reuse it for its whole lifetime. Request a new one only when it is about to expire or when a call answers 401 token_expired.
Use it from the client
The client fetches a token, caches it, and sends it as a bearer on quick-join. When a call answers 401, it fetches a new token and retries once. Everything else is the same request your backend would make.
// Works in Unity (.NET Standard 2.1) and any .NET client.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public sealed class AnonymousPlayer
{
private static readonly HttpClient Http = new HttpClient();
private const string BaseUrl = "https://discovery.pingcore.io";
private const string PublicId = "dscp_YOUR_PUBLIC_ID";
private string _token;
private DateTimeOffset _expiresAt;
public async Task<string> GetTokenAsync()
{
// Reuse the cached token until a minute before it expires.
if (_token != null && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-1))
{
return _token;
}
using var response = await Http.PostAsync(
$"{BaseUrl}/v1/apps/{PublicId}/player-tokens",
new StringContent("{}", Encoding.UTF8, "application/json"));
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"player-tokens failed ({(int)response.StatusCode}): {json}");
}
using var doc = JsonDocument.Parse(json);
_token = doc.RootElement.GetProperty("token").GetString();
_expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(doc.RootElement.GetProperty("expiresAt").GetInt64());
return _token;
}
public async Task<JsonDocument> QuickJoinAsync(int seats)
{
var idempotencyKey = Guid.NewGuid().ToString("N");
var (status, json) = await PostQuickJoinAsync(await GetTokenAsync(), idempotencyKey, seats);
if (status == HttpStatusCode.Unauthorized)
{
_token = null; // the token expired or was rejected: get a new one and retry once
(status, json) = await PostQuickJoinAsync(await GetTokenAsync(), idempotencyKey, seats);
}
if (status != HttpStatusCode.OK)
{
throw new HttpRequestException($"quick-join failed ({(int)status}): {json}");
}
return JsonDocument.Parse(json); // read ip, port, reservationId, expiresIn
}
private static async Task<(HttpStatusCode, string)> PostQuickJoinAsync(string token, string idempotencyKey, int seats)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/apps/{PublicId}/quick-join");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(
JsonSerializer.Serialize(new { idempotencyKey, seats }), Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request);
return (response.StatusCode, await response.Content.ReadAsStringAsync());
}
}const BASE_URL = 'https://discovery.pingcore.io';
const PUBLIC_ID = 'dscp_YOUR_PUBLIC_ID';
let cached = null; // { token, expiresAt }
async function getPlayerToken() {
// Reuse the cached token until a minute before it expires.
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
const response = await fetch(`${BASE_URL}/v1/apps/${PUBLIC_ID}/player-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const result = await response.json();
if (!response.ok) {
throw new Error(`player-tokens failed (${response.status}): ${result.message}`);
}
cached = { token: result.token, expiresAt: result.expiresAt };
return cached.token;
}
async function postQuickJoin(token, idempotencyKey, seats) {
const response = await fetch(`${BASE_URL}/v1/apps/${PUBLIC_ID}/quick-join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ idempotencyKey, seats }),
});
return { status: response.status, result: await response.json() };
}
export async function quickJoin(seats = 1) {
const idempotencyKey = crypto.randomUUID();
let { status, result } = await postQuickJoin(await getPlayerToken(), idempotencyKey, seats);
if (status === 401) {
cached = null; // the token expired or was rejected: get a new one and retry once
({ status, result } = await postQuickJoin(await getPlayerToken(), idempotencyKey, seats));
}
if (status !== 200) {
throw new Error(`quick-join failed (${status}): ${result.reason || ''} ${result.message}`);
}
return result; // ip, port, reservationId, expiresIn
}Reserving a chosen server, reading a hold, releasing it, and submitting a matchmaking ticket work the same way with the anonymous token in the Authorization header. On your game server, verify reports ownerKind: "player" and an ownerPlayerId starting with anon:. Since the id is random, there is nothing to compare it against beyond what the client tells you; the reservation still proves the seat was held.
Limits
Defaults on a PingCore Discovery deployment:
10 token requests per minute per address.
6 hour token lifetime.
60 requests per minute per player, and 6000 player requests per minute across the app.
2 active reservations per player.
8 seats per reservation, and
contextof at most 1024 bytes.
These limits apply to each token and each address, not to a person. Someone who keeps requesting tokens from one address can take 20 new reservations a minute (10 tokens, 2 reservations each), and with holds of up to 300 seconds that is up to 100 reservations held at once, each of up to 8 seats. If that matters for your game, use studio-signed tokens, where every limit is tied to an account you can ban.
Errors you may see
Every error body is { "error": true, "message": "..." }, and player-token errors add a machine-readable reason. Branch on the status and reason; show message to a developer, not a player.
400 (the request body breaks a player bound; dsc_ callers have higher limits):
Player tokens may reserve at most 8 seats per reservation.: lowerseats.Player token context must be 1024 bytes or smaller.: shrinkcontext.party_too_large_for_player: a ticket'spartySizeis above 8. Split the party or submit it from a backend.session_too_small_for_player: a ticket'ssessionSizeorminSessionSizeis below 2. Raise it; solo sessions come from your backend.party_fills_session_for_player: a ticket'spartySizeequalssessionSizeorminSessionSize, so the party would be the whole session. Lower the party, raise the session sizes, or submit the full party from your backend.too_many_attributeswiththe limit is 0: the ticket carriedattributes, which player tokens may not send until the operator raises the limit for your app.invalid_ticket_id: aticketIdwith characters outside letters, digits, and_ . : -. Omit it to have one generated.
401 (the credential is unusable; fix the token):
No
reason, messageMissing bearer token. Send an allocate-scope Discovery token (dsc_...) from your backend, or a player token (JWT) from the game client.: theAuthorizationheader is missing or does not start withBearer. SendAuthorization: Bearer <token>.malformed_token: the bearer is neither adsc_token nor a three-part JWT, is over 4096 bytes, has a header or payload that is not a JSON object, carries acritheader, or has akid,nbf,iss, orjtiof the wrong type or length. Check what your client puts in theAuthorizationheader and how your login service builds the token.unsupported_alg: the JWT headeralgis notES256(studio-signed) orHS256(anonymous). Sign with ES256.no_signing_keys: the token is signed but the app has no active signing key (none was generated, or every key was revoked). Distinct frombad_signature: nothing could be tried. Generate a key under Player Access.unknown_kid: the headerkidmatches no active key. The key was revoked or the id is mistyped; sign with the current key shown under Player Access.bad_signature: the signature did not verify against the selected key. Check that the private key in your login service pairs with an active key on the app and that the token was not altered in transit. An anonymous token that Discovery did not mint (wrongiss, or asubwithout theanon:prefix) reads the same way.missing_claim:aud,sub,exp, oriatis absent or has the wrong type. The message names the claim.invalid_sub:subis not 1 to 128 characters of letters, digits, and_ . : @ -.invalid_claims:substarts withanon:, which is reserved for anonymous tokens. Use your own player id.audience_mismatch:auddoes not contain this app's public ID. The message tells you the value to set.token_expired:exphas passed. Sign a new token, or request a new anonymous one.token_not_yet_valid:iatornbfis in the future beyond the 60 second skew. Check the clock on the signing machine.lifetime_too_long:exp - iatexceeds the maximum (3600 seconds for signed tokens). Issue shorter tokens.
403 (the token is fine, but the mode is off):
signed_tokens_disabled: turn on Studio-signed player tokens under Player Access, and allow up to 60 seconds.anonymous_tokens_disabled: turn on Anonymous player tokens under Player Access, and allow up to 60 seconds. Also returned by the token request itself.
404:
Unknown Discovery app.: the public ID in the path is unknown or the app is disabled. Both cases read the same on purpose.Unknown server.: on reserve, the server left the list, is hidden, or the id is malformed.Unknown reservation (it may have expired).: onGET, the hold expired, was released, or belongs to another player. All three read the same on purpose.Unknown ticket (it may have expired).: on a ticketGETorDELETE, the ticket expired, never existed, or belongs to another player or your backend. All of those read the same on purpose.
409:
no_seats: the party does not fit. Same meaning as for backend calls.too_many_reservations: this player already holds the maximum active reservations (2 by default; the body carrieslimitandactive). Release one withDELETE /v1/apps/{publicId}/reservations/{reservationId}or wait for it to expire.too_many_tickets: this player already has a queued ticket (1 by default; the body carrieslimitandactive). Cancel it withDELETE /v1/apps/{publicId}/tickets/{ticketId}or wait for it to match or expire.ticket_id_taken: theticketIdyou posted belongs to a ticket you may not see. Choose a fresh id (a UUID) or omitticketId.reservation_id_taken: on reserve, thereservationIdyou posted belongs to a reservation you may not see (another player's, or your backend's), so it cannot be replayed. On quick-join, the same for a replayedidempotencyKey. The message names the field (That reservationId is already in use. Choose a fresh id (a UUID) and retry.orThat idempotencyKey is already in use. Choose a fresh key (a UUID) and retry.). Use a UUID per hold.
429 (rate limited; honour Retry-After):
Too many requests. Slow down.: the shared per-address floor on the reservation routes (1000 a minute by default). A single game client should never reach it.Too many player-token requests from this address. Reuse the token you already have; tokens are valid for several hours.: you are requesting anonymous tokens too often. Cache the token.Too many requests for this player. Slow down and retry shortly.: one player exceeded 60 requests a minute.Player traffic for this app has reached its rate limit. Retry shortly.: the app-wide player budget (6000 a minute) is exhausted. Contact support if you hit this in normal use.
503 (transient; retry shortly):
Discovery is starting up and has not loaded its registry yet. Retry shortly.: the service is starting.anonymous_tokens_unavailable: anonymous tokens are not configured on this Discovery deployment. Use a studio-signed token or a backend allocate token, or contact PingCore support.
What player tokens cannot do
Player tokens work on exactly seven routes, all under /v1/apps/{publicId}/: reserve on a chosen server, quick-join, GET or DELETE on a reservation, and submit, GET, or DELETE on a matchmaking ticket. They are refused with 401 everywhere else: heartbeats and delisting, the fleet agent connection, allocations, the desired-capacity hint, fleet state, the matchmaking reads (matchmaking-stats and the matchmaking/queues, tickets, and matches inspector routes), joinable-session publishing, and the verify endpoint. A token is bound to one app through aud, so it cannot be replayed against another app.
Turning on a player-token mode never changes how servers register or how the list is built. Player tokens work on apps in either registration mode, including open registration, because the heartbeat token in a client-hosted game build is already public and the player path never relied on it being secret. On an open-registration app it is the fully client-hosted shape: the shipped game registers its own server and players join with a player token, all without a backend.
What Discovery never learns
With studio-signed tokens, Discovery receives only the token's sub, an opaque id you chose. It never calls your login service, never sees a password or session, and never holds your private key; the platform stores the public half only. With anonymous tokens, the identity is a random id Discovery generated, tied to nothing about the player. Reservation records live in Discovery only for the length of the hold, as described in Discovery Overview.
Related
Reservations and Quick-Join: the endpoints, request bodies, and responses these tokens call.
Matchmaking Integration: allocations (backend only) and tickets, which a player token can submit within the player floors.
Using Steam and EOS Lobbies with Discovery: take a party that already exists in a platform lobby into one ticket from the leader's client.
Discovery Overview: apps, public IDs, token scopes, and the revocation window.