# 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](./sending-activity.mdx), [facts](./fact-ingest.mdx) 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.

<Explainer id="linking" />

## How linking works

```mermaid
sequenceDiagram
  participant B as Your backend
  participant P as Your page
  participant PP as PlayerPal API
  participant L as PlayerPal link page
  B->>PP: POST /v1/link-intents
  PP-->>B: link_intent_id, link_url, expires_at
  B-->>P: link_url
  P->>L: open link_url in a popup
  L->>L: Player signs in or creates an Account, then consents
  L-->>P: postMessage { type: "playerpal:link-result", result_code }
  P->>B: result_code
  B->>PP: POST /v1/link-results/exchange
  PP-->>B: player alias (and provider aliases, if requested)
```

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](#provider-led-linking) differs only in a few places.

## Set up the integration key

Linking routes use the integration key PlayerPal issues during [onboarding](./onboarding.mdx), 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-id` for an operator, `x-integration-id` for a game provider integration.
- Keep the key server-side. Never ship it to a browser or app.

(Code sample: integration-key. See the page for curl, Node.js, Python, PHP, Java and C#.)

Environment:

```
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
```

## Create a link intent

`createLinkIntent` (see the API reference)

When the player clicks Connect, your backend creates an intent. Operators send three fields:

(Parameter table: see the API reference.)

**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.

(Code sample: link-intent. See the page for curl, Node.js, Python, PHP, Java and C#.)

Response:

```
{
  "link_intent_id": "pp_lnk_XXXX",
  "link_url": "<playerpal-link-page-url>",
  "expires_at": "2026-10-15T12:05:00.000Z"
}
```

Assertion claims:

```
{
  "iss": "pp_opr_XXXX",
  "sub": "<opaque-player-reference>",
  "aud": "<audience-from-onboarding>",
  "jti": "<unique-id>",
  "iat": 1760529600,
  "exp": 1760529900
}
```

## Send 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`:

- `type` is `playerpal:link-result`.
- `result_code` is 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.

Your page:

```
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)
})
```

## Exchange the result

`exchangeLinkResult` (see the API reference)

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`.

(Parameter table: see the API reference.)

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`.

(Code sample: link-exchange. See the page for curl, Node.js, Python, PHP, Java and C#.)

Response:

```
{
  "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" }
  ]
}
```

## Keep aliases in sync

`getAliasStatus` (see the API reference)

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](./webhooks.mdx). Because a notice can be missed, check alias status when you need certainty.

(Code sample: alias-status. See the page for curl, Node.js, Python, PHP, Java and C#.)

Response:

```
{ "status": "revoked" }
```

Not your alias:

```
{
  "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.

:::info[Planned]
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.

```json
{
  "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](./idempotency-and-errors.mdx).

## Related

  - [Send activity](./sending-activity.mdx): 
    Report settled rounds for linked players so they earn Points.
  
  - [Embed the widget](./widget.mdx): 
    Let players connect from inside your game.
  
  - [API reference](/api/partner): 
    Full request and response schemas for linking.
