Link players
A Link connects one player at your platform to a PlayerPal Account. The player confirms it on a PlayerPal page, and you receive an Alias: a random, pseudonymous identifier that only you and PlayerPal can resolve. You use that Alias for everything afterwards, including activity, facts and status checks. No player data reaches PlayerPal until a Link exists, and you never send PlayerPal a name, email address or phone number.
Watch first. This shows the one-time linking flow in five short scenes.
A one-time step
The casino starts linking, the player approves once, and no activity flows for that player until the link completes.
How linking works
The first Link creates the player's PlayerPal Account. At a later partner, the player either signs in to that Account, which joins the new relationship to it, or creates a separate Account. There is no later "claim" step. This guide follows the operator-led flow; provider-led linking differs only in a few places.
1Set up the integration key
Linking routes use the integration key PlayerPal issues during onboarding, not HMAC signing. Send it as x-api-key: <your-integration-key> or Authorization: Bearer <your-integration-key>.
- The audience comes from a header, never from the body:
x-operator-idfor an operator,x-integration-idfor a game provider integration. - Keep the key server-side. Never ship it to a browser or app.
# Linking and activity routes use the integration key PlayerPal issued with
# your integration, plus the ID of the operator or integration you act for.
INTEGRATION_KEY="${PP_INTEGRATION_KEY:-}"
OPERATOR_ID="${PP_OPERATOR_ID:-}" # pp_opr_… (operators)
INTEGRATION_ID="${PP_INTEGRATION_ID:-}" # pp_int_… (game providers)
export PP_API_BASE="https://<playerpal-api-host>"
export PP_INTEGRATION_KEY="<your-integration-key>"
export PP_OPERATOR_ID="pp_opr_XXXX" # operators
export PP_INTEGRATION_ID="pp_int_XXXX" # game providers
2Create a link intent
When the player clicks Connect, your backend creates an intent. Operators send three fields:
operator_assertionstringOperator-led only. A JWT (ES256 or RS256) signed with a key from your registered JWKS:iss= your operator public id,sub= your pseudonymous account id for the player, a uniquejti, and a lifetime of at most 5 minutes. Must not carry personal data claims (email,name,given_name,family_name,phone,phone_number); eachjtican be used once.return_originstringrequiredAbsolutehttp(s)origin the player returns to after linking, e.g.https://casino.example.com. No path, query or fragment.requested_provider_integrationsarray of stringOperator-led only. Integration public ids (pp_int_…) to link in the same flow; each must belong to your operator (integration_not_authorized_for_operator).
The operator assertion is a JWT signed with ES256 or RS256 by a key in the JSON Web Key Set you registered with PlayerPal. It proves which local player is connecting.
| Claim | Rule |
|---|---|
iss | Your operator ID, the same value as x-operator-id |
sub | Your stable, opaque player reference. Not an email or username |
aud | The audience PlayerPal gives you during onboarding |
jti | Unique per assertion. A reused jti is rejected as assertion_replay |
iat, exp | Both required, at most five minutes apart. The assertion is accepted for five minutes after iat |
Claims such as email, email_address, name, given_name, family_name, phone or phone_number are rejected with assertion_forbidden_claims. Send the assertion in the request body, never in a URL.
return_origin is the origin of the page that opens the popup, such as https://casino.example: absolute http or https, with no path, query or fragment.
requested_provider_integrations lists the game provider integrations you want aliases for in the same Link. Each must already be configured for your operator; listing one does not grant access to it (integration_not_authorized_for_operator).
The intent expires five minutes after it is created.
# OPERATOR_ASSERTION: a short-lived JWT you sign with your registered key (see Linking players).
curl -sS -X POST "$API/v1/link-intents" \
-H "x-api-key: $INTEGRATION_KEY" -H "x-operator-id: $OPERATOR_ID" \
-H 'Content-Type: application/json' \
-d "{\"operator_assertion\":\"$OPERATOR_ASSERTION\",\"return_origin\":\"https://casino.example\"}"
# 201 {link_intent_id, link_url, expires_at}: open link_url for the player
{
"link_intent_id": "pp_lnk_XXXX",
"link_url": "<playerpal-link-page-url>",
"expires_at": "2026-10-15T12:05:00.000Z"
}
{
"iss": "pp_opr_XXXX",
"sub": "<opaque-player-reference>",
"aud": "<audience-from-onboarding>",
"jti": "<unique-id>",
"iat": 1760529600,
"exp": 1760529900
}
3Send the player to link_url
Open link_url in a popup from a user action, such as a Connect button. It carries only an opaque one-time code: do not log or share it.
The player signs in or creates an Account on the PlayerPal page and consents to your exact connection. When they finish, the page posts a message to the window that opened it, targeted only at your return_origin:
typeisplayerpal:link-result.result_codeis a one-time code for your backend.
Check both the message origin and the source window before you trust it, then pass the code to your backend. If the player closes the popup or the intent expires, no message arrives; create a new intent to try again.
const popup = window.open(linkUrl, 'playerpal-link', 'width=480,height=720')
window.addEventListener('message', (event) => {
if (event.origin !== PLAYERPAL_LINK_ORIGIN || event.source !== popup) return
if (event.data?.type !== 'playerpal:link-result') return
sendToBackend(event.data.result_code)
})
4Exchange the result
Your backend exchanges the code for the alias. The code works once, and only while the intent is still valid; after that the call returns 404 result_not_found.
Store player_id against your local player. Pass each pp_plr_prv_… alias in provider_aliases to the matching game provider in your existing game launch data, so game launch never calls PlayerPal.
You get 201 with a player_id starting pp_plr_opr_, and one entry in provider_aliases for each integration you requested. Exchanging the same code again returns 404 result_not_found.
# When the player confirms, the PlayerPal link page posts a one-time result code to your page (window.postMessage, origin-checked against return_origin).
curl -sS -X POST "$API/v1/link-results/exchange" \
-H "x-api-key: $INTEGRATION_KEY" -H "x-operator-id: $OPERATOR_ID" \
-H 'Content-Type: application/json' \
-d "{\"result_code\":\"$RESULT_CODE\"}"
# 201 {player_id, status, provider_aliases, ...}: store player_id against your account
{
"connection_id": "<id>",
"player_id": "pp_plr_opr_XXXXXXXXXXXXXXXXXXXXXXXXXX",
"status": "active",
"effective_from": "2026-10-15T12:01:30.000Z",
"provider_aliases": [
{ "integration_id": "pp_int_XXXX", "player_id": "pp_plr_prv_XXXXXXXXXXXXXXXXXXXXXXXXXX" }
]
}
5Keep aliases in sync
The player Unlinks from their PlayerPal Account, not from your site. Check an alias with x-operator-id for an operator alias or x-integration-id for a provider alias. The answer is active or revoked; an alias outside your audience returns 404 alias_not_found, the same as an unknown one.
After an Unlink:
- The alias status becomes
revoked. The alias is never reassigned, and a new Link mints a new alias. - Activity before the Unlink time can still qualify; activity at or after it does not earn Points.
- Facts sent for a revoked alias are rejected as
player_ref_unknown. - Unlinking an operator also revokes the provider aliases issued for that operator's integrations. A provider-led Unlink affects only that integration.
- Play at your site continues, and Points already earned stay with the player.
PlayerPal can send you an Unlink notice; see Webhooks. Because a notice can be missed, check alias status when you need certainty.
curl -sS "$API/v1/aliases/$ALIAS" \
-H "x-api-key: $INTEGRATION_KEY" -H "x-operator-id: $OPERATOR_ID"
# 200 {"status":"active"} or {"status":"revoked"}
{ "status": "revoked" }
{
"statusCode": 404,
"message": "alias_not_found",
"error": "Not Found"
}
The aliases you receive
| Alias | Scope | Who can resolve it |
|---|---|---|
pp_plr_opr_… | One player at one operator | That operator and PlayerPal |
pp_plr_prv_… | One player at one provider-operator integration | That integration and PlayerPal |
Aliases are random 128-bit values, so two partners cannot match players by comparing them. The same player at Provider A × Operator A and Provider A × Operator B has two different provider aliases. Aliases are minted only for the audiences named on the completed intent; an integration added later has no alias for existing players yet.
A call for operators to request a provider alias for an integration enabled after the original Link is designed but not built.
Provider-led linking
A game provider can start a Link for one provider-operator integration without the operator's backend.
| Operator-led | Provider-led | |
|---|---|---|
| Audience header | x-operator-id | x-integration-id |
| Body | operator_assertion, return_origin, optional requested_provider_integrations | return_origin only |
| Exchange header | None | x-integration-id |
| You receive | pp_plr_opr_… plus a pp_plr_prv_… per requested integration | One pp_plr_prv_… for your integration |
A provider-led request that also sends x-operator-id, operator_assertion, requested_provider_integrations, operator_id or integration_id is rejected with scope_override_rejected. Scope always comes from your credentials and configuration, never from a body field.
{
"integration_id": "pp_int_XXXX",
"player_id": "pp_plr_prv_XXXXXXXXXXXXXXXXXXXXXXXXXX",
"status": "active",
"effective_from": "2026-10-15T12:01:30.000Z"
}
Errors
| Response | Cause | Fix |
|---|---|---|
400 return_origin_required, return_origin_invalid | Missing origin, or one with a path, query or fragment | Send a bare origin such as https://casino.example |
400 operator_assertion_required | No assertion on an operator-led intent | Sign and send operator_assertion |
400 assertion_invalid | Bad signature, algorithm or aud, or a missing iss, sub or jti | Sign with a key in your registered key set and include every claim |
400 assertion_expired | Assertion older than five minutes or with a longer lifetime | Sign a fresh assertion per intent |
400 assertion_iss_mismatch | iss differs from x-operator-id | Use your operator ID in both |
400 assertion_forbidden_claims | Personal data claims in the assertion | Remove them; send only an opaque sub |
400 jwks_unavailable | PlayerPal could not load your key set | Check the key set you registered is reachable and not empty |
400 integration_not_authorized_for_operator | A requested integration is not yours | Request only integrations configured for your operator |
400 scope_override_rejected | Operator fields on a provider-led request | Send only return_origin |
400 result_code_required | No result_code in the exchange | Pass the code from the link page |
400 provider_alias_missing | The Link has no alias for your integration | Check you exchanged a code from your own intent |
400 player_alias_required, audience_required | Alias status call without an alias or audience header | Send the alias and x-operator-id or x-integration-id |
401 assertion_replay | A jti was reused | Generate a new jti per assertion |
401 machine_auth_required | Integration key missing or wrong | Send it as x-api-key or Authorization: Bearer |
404 party_not_found, integration_not_found | Unknown operator or integration ID | Use the IDs PlayerPal gave you |
404 result_not_found | Code already used, unknown or expired | Start a new intent |
404 alias_not_found | Alias unknown or not issued to you | Treat the player as not linked |
The error code is in the message field. See Idempotency and errors.