# ADR NNNN: Title
Source: https://docs-internal.playerpal.io/docs/adr-template
## Status
Proposed | Accepted | Superseded by NNNN.
## Context
Why a decision is needed, including constraints and alternatives that were considered.
## Decision
The choice, stated so an implementer can follow it without re-litigating the trade-off.
## Consequences
What becomes easier, what becomes harder, and which documents or ADRs this affects.
---
# Insights and Ask
Source: https://docs-internal.playerpal.io/docs/analytics/insights
PlayerPal reads your KPIs for you and points out what changed, where the change
is concentrated, and where you stand against anonymous peers. Every insight is
computed by fixed rules and statistical tests over figures you can already see,
with the same privacy thresholds applied. No language model computes a number
or writes the text.
[Available]
## How it works
```mermaid
flowchart LR
K[Your KPIs
privacy floors applied] --> D[Detectors
fixed rules and tests]
D --> R[Ranking
importance score]
R --> S[Insights and
executive summary]
K --> A[Ask PlayerPal
fixed analytics functions]
```
1. **Inputs.** Overview KPIs, players and lifecycle figures, breakdowns by
market, game and counterpart, and your published benchmark position. These
are the same figures the API returns, after suppression.
2. **Detectors.** Six detectors look for material movements and patterns.
Each records its evidence, its method and a confidence score.
3. **Ranking.** Insights are ranked by magnitude × population × business
weight × confidence × freshness. Up to 12 are returned.
4. **Summary.** A templated executive summary picks three lead insights (a
positive, a risk and an opportunity where available) and labels the period
"Positive momentum", "Mixed signals" or "Needs attention".
## Insight types
| Detector | Insight types | What it reports |
|---|---|---|
| Movement | `_up`, `_down` | A KPI moved materially against the previous period: at least 8% relative, or 2 percentage points for rates |
| Driver | `growth_concentration`, `decline_concentration` | The market, game or counterpart carrying at least 30% of the net change in active players |
| Retention | `retention_gap`, `retention_strength` | A segment whose D7 retention is at most 0.75×, or at least 1.25×, that of your other segments |
| Portfolio | `high_potential_game` | A game with above-median retention but below-median reach |
| Benchmark | `benchmark_strength`, `benchmark_gap` | A top- or bottom-quartile position against anonymous peers, from published segments only |
| Anomaly | `activity_spike`, `activity_dip` | One of the last 14 days far outside the 14 days before it (robust z-score of at least 3.5 and a change of at least 25%) |
Each insight is also tagged with a presentation category (growth, decline,
risk, opportunity, anomaly, benchmark, retention, driver, lifecycle, loyalty or
commercial), and carries suggested next steps and follow-up questions. The
next steps are suggestions to test, not instructions.
A separate "watching" list shows up to six smaller moves (from 3%, or 1
percentage point) that did not reach the main threshold.
## Confidence
Confidence is 1 minus the p-value of a two-sided test, so 0.95 means a change
this large would appear by chance about 1 time in 20.
| Figure | Test |
|---|---|
| Counts, such as active players | Poisson test of now against before |
| Rates with a known base, such as retention | Two-proportion z-test against the rate's real base |
| Averages and rates without a known base | Grows with sample size, capped at 0.85 |
| Rates whose base is under 50 players | Never reported |
Each insight shows its confidence as high (0.95 or more), medium (0.85 or more)
or low, with the reasons: sample size, how many days of data arrived, and
whether the pattern holds across markets.
## Wording
Insight text describes association and concentration only: "concentrated
in", "coincides with". It never claims a cause. Where a finding suggests an
action, it is framed as something to test.
## Ask PlayerPal
[Available]
Ask answers plain-language questions about your data. It matches each question
to one of a fixed set of analytics functions and returns a headline, supporting
points, an optional table, follow-up questions and a note on what the answer is
based on. Each answer is computed from the same functions as the dashboards,
with the same privacy thresholds.
| Question topic | Example | What it runs |
|---|---|---|
| What changed | "What changed compared with last month?" | The top movement, risk and driver insights |
| Valuable games | "Which game is driving the most valuable players?" | Games ranked by gameplay GGR per player, with D7 retention |
| Counterparts | "Which operators are underperforming?" | Counterparts whose D7 retention sits below your median |
| Acquisition | "Where should we focus player acquisition?" | Markets that retain above your average but hold a small share of players |
| Cross-promotion | "Which games should we cross-promote?" | Which other games your largest title's players also play |
| Dormancy | "Why are players becoming dormant?" | Lifecycle movement into the dormant state |
| Network | "Where do we outperform the network?" | Your published benchmark position |
| Market retention | "Why is retention different in Finland?" | D7 retention in that market, by game and counterpart |
Questions are read up to 300 characters. A question that matches none of these
topics gets a clear "can't answer that one yet" response and a list of
questions it can answer. It never guesses.
## What it does not do
- **No language model.** Matching is by keywords and the text is templated.
Ask does not understand arbitrary phrasing.
- **No free-form queries.** No question reaches the database directly; only
the fixed functions run.
- **No new numbers.** Every figure in an answer is one the analytics API
already returns to you.
- **No individual players or named peers.** Suppressed segments stay
suppressed, and benchmark answers give quartile bands only.
- **No causal claims.** Answers show where a difference is concentrated, not
why it happened.
## Planned: language-model rephrasing
[Planned]
A language model may later rephrase insights and Ask answers in more natural
language. It would only reword the structured results; it would not compute or
change a number. This is not built.
## Availability
Insights and Ask are available through the [KPI API](../developers/kpi-api.mdx)
with the same credential as your other KPIs. Insights can be scoped to one
market, game or counterpart, and each insight can be fetched on its own with
its evidence. The partner portal's insights workspace is
[in development](./overview.mdx#partner-portal-analytics).
## Related
- [Metric dictionary](./metrics.mdx):
Definitions behind every figure an insight uses.
- [Privacy thresholds](./privacy-thresholds.mdx):
The floors every insight inherits.
- [Partner analytics](./overview.mdx):
Visibility tiers and where to get your figures.
---
# Metric dictionary
Source: https://docs-internal.playerpal.io/docs/analytics/metrics
This page defines every KPI a partner can read from PlayerPal. Each KPI has a
fixed ID and a fixed definition. Partners choose a KPI, a date range and a
grain; they never send free-form queries, so the same ID always means the same
calculation.
There are three groups:
1. **Own KPIs** (`own.*`): figures about your own players and operations.
2. **Network comparison KPIs** (`cmp.*`) and **benchmarks**: your position
against the anonymous network or peers.
3. **Overview KPIs**: the partner overview, computed from facts you send.
## Rules for every own KPI
[Available]
| Topic | Behaviour |
|---|---|
| Population | Only your organisation's data. Scope comes from your credential. |
| Time window | `from` and `to` are inclusive UTC dates, fewer than 400 days apart. Each KPI is dated by the timestamp named in its section below. |
| Grain | `day` (default), `week` (ISO weeks starting Monday) or `month`. Every period in the range is returned, including empty ones. |
| Total | Calculated over the whole range, not summed from the series. For distinct counts, the total is the distinct count over the range. |
| Previous period | The same number of days immediately before `from`, returned as a total and as an aligned series. The percentage change is empty when either total is missing or the previous total is zero. |
| Empty periods | Counts, EUR and points return `0`. Ratios and durations return `null`. |
| Market filter | Only on KPIs marked "Market: yes". The market is the licensed jurisdiction (ISO 3166-1 alpha-2) reported on each activity event. Events without a market count only in unfiltered figures. |
| Currency | EUR only. Activity in any other currency is rejected when it is received, and no conversion takes place. |
| Account floor | On KPIs marked "Floor: yes", any period with between 1 and 49 distinct accounts behind it is returned as suppressed. See [Privacy thresholds](./privacy-thresholds.mdx). |
## Own KPIs
[Available]
| ID | Name | Unit | Partner | Floor | Market | Breakdown |
|---|---|---|---|---|---|---|
| `own.rounds` | Rounds | count | Operator, game provider | Yes | Yes | Yes |
| `own.bet_eur` | Turnover | EUR | Operator, game provider | Yes | Yes | Yes |
| `own.win_eur` | Wins paid | EUR | Operator, game provider | Yes | Yes | Yes |
| `own.ggr` | GGR (bet − win) | EUR | Operator, game provider | Yes | Yes | Yes |
| `own.active_accounts` | Active accounts | count | Operator, game provider | Yes | Yes | Yes |
| `own.linked_players` | Linked players | count | Operator, game provider | Yes | No | No |
| `own.new_links` | New links | count | Operator, game provider | No | No | No |
| `own.unlinks` | Unlinks | count | Operator, game provider | No | No | No |
| `own.link_intents` | Link intents created | count | Operator | No | No | No |
| `own.link_conversion` | Link conversion | ratio | Operator | No | No | No |
| `own.points_issued` | Points issued | points | Operator, game provider | Yes | No | No |
| `own.ingest_acceptance` | Events accepted | ratio | Operator, game provider | No | No | No |
| `own.batch_latency_p95` | Batch latency p95 | seconds | Game provider | No | No | No |
Breakdowns split a KPI by market, game or counterpart (your own integration
partners). They are available for the five activity KPIs only.
### Activity KPIs
All five are built from **settled game rounds received in activity batches and
uploads**, dated by when the round occurred. One round counts once for its
operator and once for its game provider. Floor: yes, on distinct active
accounts. Market: yes.
#### `own.rounds`: Rounds
- **Definition:** number of settled rounds accepted for your organisation.
- **Grain:** one round.
- **Limitations:** counts every accepted round, whatever its stake.
#### `own.bet_eur`: Turnover
- **Definition:** sum of stakes on accepted rounds, in EUR.
- **Grain:** one round.
- **Limitations:** EUR only; no conversion.
#### `own.win_eur`: Wins paid
- **Definition:** sum of wins on accepted rounds, in EUR. A round reported
without a win counts as zero.
- **Grain:** one round.
#### `own.ggr`: GGR (bet − win)
- **Definition:** turnover minus wins paid for the period. Can be negative.
- **Grain:** derived per period.
- **Limitations:** a gameplay-based figure, not operator-reported GGR.
Operator-reported GGR and net revenue are overview KPIs (`ggr`,
`net_revenue`) fed by fact ingest.
#### `own.active_accounts`: Active accounts
- **Definition:** distinct accounts with at least one accepted round in the
period.
- **Grain:** distinct account per period.
- **Limitations:** a day period gives daily actives, a week weekly actives and
a month monthly actives. Periods do not add up to the total.
### Linking KPIs
#### `own.linked_players`: Linked players
- **Definition:** distinct players linked to you on at least one day of the
period.
- **Grain:** player per day.
- **Source:** daily record of player links in force. For a game provider this
includes players linked through its integrations.
- **Time:** the day the link was in force. A player counts on every day a link
was in force, including the day it ended.
- **Limitations:** rebuilt for recent days every 15 minutes by default, so it
can lag a new link briefly.
- **Floor:** yes, on itself.
#### `own.new_links`: New links
- **Definition:** distinct players whose link to you became effective in the
period.
- **Grain:** distinct player per period.
- **Source:** player link records, including links made through your
integrations.
- **Time:** when the link became effective.
- **Limitations:** a player linked through more than one route counts once.
- **Floor:** no. This KPI describes your own operations.
#### `own.unlinks`: Unlinks
- **Definition:** distinct players whose link to you ended (unlinked by the
player or revoked) in the period.
- **Grain:** distinct player per period.
- **Source:** player link records.
- **Time:** when the link ended.
- **Limitations:** a count, not a rate.
- **Floor:** no.
#### `own.link_intents`: Link intents created
- **Definition:** link intents you created in the period. A link intent is the
start of a link from your site.
- **Grain:** one intent.
- **Partner:** operators only.
- **Time:** when the intent was created.
- **Floor:** no.
#### `own.link_conversion`: Link conversion
- **Definition:** intents created in the period that were completed, divided
by intents created in the period. `null` when none were created.
- **Grain:** ratio per period.
- **Partner:** operators only.
- **Time:** when the intent was created.
- **Limitations:** an intent completed later still counts toward the period it
was created in, so recent periods can rise afterwards.
- **Floor:** no.
### Points KPI
#### `own.points_issued`: Points issued
- **Definition:** sum of points PlayerPal credited for activity from your
integrations.
- **Grain:** one ledger credit.
- **Source:** the PlayerPal points ledger.
- **Time:** when the credit was written.
- **Currency:** points, not money.
- **Limitations:** credits only. Redemptions and reversals are not netted off.
- **Floor:** yes, on distinct players who received points.
### Integration health KPIs
#### `own.ingest_acceptance`: Events accepted
- **Definition:** 1 minus (rejected events ÷ events received) over activity
events received through your integrations. `null` when none were received.
- **Grain:** one activity event.
- **Time:** when the event was received.
- **Limitations:** an event not yet processed counts as accepted until it is
rejected. Covers activity only, not fact ingest.
- **Floor:** no.
#### `own.batch_latency_p95`: Batch latency p95
- **Definition:** 95th percentile of the time from receiving an activity batch
to finishing it, in seconds.
- **Grain:** one activity batch.
- **Partner:** game providers only.
- **Time:** when the batch was received.
- **Limitations:** batches not yet finished are left out.
- **Floor:** no.
## Network comparison KPIs
[Available]
Comparison KPIs set your players against the **rest of the network**: players
of other partners of your type. They cover closed calendar months or closed ISO
weeks only, globally and per market. Each response gives your value, the
network's p25, p50, p75 and mean, an index (your value ÷ network median × 100)
and your quartile band.
| ID | Kind | Definition | Partner | Market split |
|---|---|---|---|---|
| `cmp.active_days` | Per player | Median distinct active days per player with you, against the per-player distribution elsewhere | Operator, game provider | Yes |
| `cmp.rounds_per_player` | Per player | Median rounds per player | Operator, game provider | Yes |
| `cmp.bet_per_player` | Per player | Median turnover per player, EUR | Operator, game provider | Yes |
| `cmp.ggr_per_player` | Per player | Median bet − win per player, EUR | Operator, game provider | Yes |
| `cmp.points_per_player` | Per player | Median points credited per player | Operator | Global only |
| `cmp.avg_stake` | Ratio | Total turnover ÷ rounds, EUR | Operator, game provider | Yes |
| `cmp.payout_ratio` | Ratio | Total wins ÷ total turnover | Operator, game provider | Yes |
| `cmp.churn_rate` | Ratio | Players active with you in the previous period who were not active in this one, ÷ players active in the previous period | Operator, game provider | Yes |
| `cmp.link_conversion` | Ratio | Completed ÷ created link intents | Operator | Global only |
| `cmp.multi_homing` | Cross-partner | Median number of partners of your type each of your players is active with | Operator, game provider | Yes |
| `cmp.overlap_rate` | Cross-partner | Share of your players also active with at least one other partner of your type (never which one) | Operator, game provider | Yes |
| `cmp.share_of_wallet` | Cross-partner | Your players' turnover with you ÷ their turnover with all partners of your type | Operator, game provider | Yes |
- **Per player:** one unit per player and partner relationship. Your value is
your players' median.
- **Ratio:** one value per partner. Your ratio is compared with other
partners' ratios.
- **Cross-partner:** describes your players' activity with other partners, so
your own value is shown only when that activity also passes the anonymity
rules.
A market that is too thin to publish falls back to the global figure and says
so. See [Privacy thresholds](./privacy-thresholds.mdx).
## Benchmark KPIs
[Available]
Benchmarks compare your own total for a closed period with anonymous peers of
the same partner type. The response gives the peers' p25, p50, p75 and mean,
the peer count as a band, and your quartile band.
| ID | Market split |
|---|---|
| `own.rounds` | Yes |
| `own.bet_eur` | Yes |
| `own.ggr` | Yes |
| `own.active_accounts` | Yes |
| `own.linked_players` | Global only |
| `own.ingest_acceptance` | Global only |
| `own.link_conversion` | Global only (operators) |
## Overview KPIs
The partner overview computes these KPIs from facts partners send through
[fact ingest](../developers/fact-ingest.mdx) and from the points ledger. Every
value carries a `dataAvailability` field, so a missing feed is never shown as a
zero.
| `dataAvailability` | Meaning |
|---|---|
| `available` | Computed from delivered facts |
| `requires_feed` | The feed it needs has not delivered for you yet; the response names the feed |
| `not_applicable` | Not shown to your partner type, for example commercial KPIs for game providers |
| `suppressed` | Fewer than 50 active players in the range |
Which players count: operators see their own players, game providers see
players of their games, and affiliates see only their verified-attributed
players. Ratio trends are in percentage points; other trends are relative.
### Engagement and retention
[Available]
Source: game provider gameplay feed. Shown to operators, game providers and
affiliates.
| ID | Name | Definition |
|---|---|---|
| `active_players` | Active players | Distinct players with gameplay in the period |
| `new_players` | New players | Players whose first gameplay with you falls in the period |
| `sessions_per_player` | Sessions per active player | Total sessions ÷ active players |
| `active_minutes_per_player` | Active minutes per player | Active gameplay minutes ÷ active players |
| `games_per_player` | Games per player | Distinct games played ÷ active players |
| `d1_retention` | D1 retention | New players who played again within days 1–7 |
| `d7_retention` | 7-day retention | New players who played again within days 7–13 |
| `d30_retention` | 30-day retention | New players who played again within days 30–36 |
| `at_risk_players` | Players at risk | Active in the 30 days before the last 14, but not in the last 14 |
Retention counts only new players whose whole observation window has passed,
so recent cohorts are not biased low.
### Commercial
[Available]
Source: operator commercial feed. Shown to operators and affiliates only.
| ID | Name | Definition |
|---|---|---|
| `depositors` | Depositors | Players with at least one successful deposit |
| `ftd_count` | First-time deposits | Players whose first successful deposit falls in the period |
| `deposits_amount` | Deposits | Successful deposit amount, EUR |
| `ggr` | GGR | Operator-reported gross gaming revenue, EUR |
| `net_revenue` | Net revenue (NGR) | Operator-reported GGR less bonus cost, EUR |
| `repeat_deposit_rate` | Repeat deposit rate | Depositors with more than one deposit ÷ depositors |
### Points
[Available]
| ID | Name | Definition | Shown to |
|---|---|---|---|
| `points_earned` | Points earned | PlayerPal points credited from gameplay | Operators, game providers, affiliates |
### Rewards and missions
[In development]
These KPIs depend on the rewards shop and missions, which are
[in development](../rewards/points-and-rewards.mdx). Definitions may change.
Shown to operators and affiliates.
| ID | Name | Definition |
|---|---|---|
| `points_redeemed` | Points redeemed | Points spent on fulfilled rewards |
| `reward_conversion_rate` | Reward conversion rate | Players who redeemed ÷ active players who could afford a reward |
| `repeat_redeemer_rate` | Repeat redeemers | Redeemers with two or more fulfilled rewards ÷ redeemers |
| `time_to_first_redemption` | Time to first redemption | Median days from first gameplay to first reward |
| `mission_completion_rate` | Mission completion rate | Missions completed in the period ÷ missions started in the period |
## Related
- [Privacy thresholds](./privacy-thresholds.mdx):
When a value is suppressed, and why.
- [Partner analytics](./overview.mdx):
Visibility tiers, surfaces and data freshness.
- [KPI API](../developers/kpi-api.mdx):
Request parameters, responses and errors.
---
# Partner analytics
Source: https://docs-internal.playerpal.io/docs/analytics/overview
PlayerPal gives each operator and game provider analytics about the players
linked to it: activity, linking, points and integration health, plus an
anonymous view of how those players compare with the wider PlayerPal network.
Every figure is aggregated. No partner ever sees an individual player, a
competitor's name or another partner's own numbers.
[Available]
## What you can see
Every figure belongs to exactly one visibility tier. Your tier is decided by
the credential you sign in with, never by a parameter in the request.
| Tier | Who sees it | Population | Protection |
|---|---|---|---|
| **Own** | Your organisation | Players linked to you. For an operator, players with an active link to you. For a game provider, players reached through its integrations. | Account floor on player figures |
| **Network comparison** | Your organisation | Your players compared with the rest of the PlayerPal network: players of other partners of your type | Account floor, peer floor, dominance rule, statistics only |
| **Benchmark** | Your organisation | Anonymous peers of the same partner type | Peer floor, dominance rule, statistics only |
| **Network** | PlayerPal staff only | The whole network | Staff permission; every access is audited |
The comparison and benchmark tiers publish only quartiles (p25, p50, p75),
the mean and the band your value falls in. They never publish a minimum, a
maximum, an exact rank or the size of the network as an exact number. See
[Privacy thresholds](./privacy-thresholds.mdx) for the full rules.
## Who can use it
| Partner | Own-tier KPIs | Comparison and benchmarks | Overview |
|---|---|---|---|
| Operator | Yes | Yes | Yes, including commercial KPIs from your own feed |
| Game provider | Yes | Yes | Yes, gameplay and points KPIs |
| Affiliate | Not yet | Not yet | Only for verified-attributed players |
## Where to get it
### KPI API
[Available]
A read-only HTTP API for machine access. You authenticate with OAuth client
credentials or a signed request, using a credential with the KPI read scope.
Your organisation is taken from the credential; there is no party parameter.
| Area | What it returns |
|---|---|
| Catalogue | The KPIs your partner type can read, with units, supported grains and whether the account floor applies |
| Own KPIs | One KPI over a date range, by day, ISO week or month, with the previous period for comparison |
| Breakdowns | An activity KPI split by market, game or counterpart, top 10 plus "Other" |
| Overview | Every overview KPI with its availability, source and last update time |
| Comparison and benchmarks | Your position against the network or peers, for closed weeks and months |
| Insights and Ask | Ranked, explainable insights and answers to common questions |
Every call is recorded with the calling organisation, client and KPI. See
[KPI API](../developers/kpi-api.mdx) and the [API reference](/api/partner).
### Partner portal analytics
[In development]
The partner portal has analytics pages for performance, players, games,
markets, counterparts, retention and benchmarks, backed by the same API and the
same privacy rules. Portal sign-in for partners is still being built, so the
portal is not yet available to partners.
## Data freshness
- **Activity KPIs** (rounds, turnover, wins, GGR, active accounts) are
refreshed every 15 minutes by default.
- **Linked players** are rebuilt for recent days every 15 minutes by default.
- **Comparisons and benchmarks** cover closed weeks and months only, and are
recomputed every 6 hours for recent periods.
- **Overview KPIs** carry a last-updated time for the feed behind each value.
These are refresh cycles, not service-level commitments.
## Data sources
Analytics are built only from data partners send to PlayerPal and from the
PlayerPal points ledger:
- **Activity**: settled game rounds sent as activity batches or uploads. See
[Sending activity](../developers/sending-activity.mdx).
- **Facts**: gameplay and commercial facts sent through fact ingest. See
[Fact ingest](../developers/fact-ingest.mdx).
- **Points**: credits PlayerPal writes when activity qualifies. See
[Points and rewards](../rewards/points-and-rewards.mdx).
All money values are in EUR. PlayerPal holds no player names, emails or phone
numbers, so none can appear in analytics.
## Related
- [Metric dictionary](./metrics.mdx):
Every KPI ID with its definition, grain, source and privacy rule.
- [Privacy thresholds](./privacy-thresholds.mdx):
Account floor, peer floor and dominance rules, and what a suppressed value looks like.
- [Insights and Ask](./insights.mdx):
Ranked, explainable insights computed by fixed rules.
- [KPI API](../developers/kpi-api.mdx):
Authenticate and read your KPIs over HTTP.
---
# Privacy thresholds
Source: https://docs-internal.playerpal.io/docs/analytics/privacy-thresholds
PlayerPal analytics never let a partner single out a player or a competitor.
Small groups are hidden, comparisons need enough independent peers, and no
average is published when one partner would dominate it. When a rule hides a
value, the response says so explicitly instead of returning a misleading zero.
[Available]
Watch first. This shows the thresholds below and why each one exists.
## Why these rules exist
- **Small groups identify people.** A figure about three players can reveal
what one of them did. The account floor hides any figure built on too few
accounts.
- **Thin networks identify competitors.** If a market has two operators,
"the network minus you" is the other operator. The peer floor requires
several independent partners.
- **Averages can belong to one partner.** If one operator holds most of a
market, the market average is in effect that operator's figure. The
dominance rule suppresses it.
- **Overlapping ranges can be subtracted.** Comparisons use fixed, closed
periods only, so two requests cannot be differenced to isolate a peer.
## The thresholds
| Rule | Default | Applies to |
|---|---|---|
| Account floor | At least 50 distinct accounts | Own KPIs about players, overview, comparisons, benchmarks |
| Peer floor (comparison) | At least 5 other partners of your type in the rest of the network | Network comparison |
| Peer minimum (benchmark) | At least 6 partners in the peer group: you plus at least 5 others | Benchmarks |
| Dominance | Suppress if one partner holds more than 50% of the measure | Network comparison, benchmarks |
| Dominance, top two | Suppress if the top two partners together hold more than 75% | Network comparison, benchmarks |
These are the defaults. PlayerPal does not run production below these values.
## Own KPIs and the overview
[Available]
The account floor applies to every own KPI that describes players (marked
"Floor: yes" in the [metric dictionary](./metrics.mdx)). KPIs about your own
operations, such as new links or ingest acceptance, have no floor.
- **Each period is tested on its own.** If between 1 and 49 distinct accounts
are behind a period, that period's value is replaced with a suppressed
marker. A period with no accounts at all is shown as zero, not suppressed.
- **Totals and previous periods are tested too.** If either total is
suppressed, the percentage change is empty.
- **Breakdowns drop small rows.** A market, game or counterpart under the
floor is left out and counted in `suppressed_rows`. It is never folded into
"Other", so it cannot be recovered by subtraction.
- **The overview** marks every KPI `suppressed` when fewer than 50 players
were active in the range. Markets under the floor are grouped into "Other",
and cohorts under the floor are returned without values.
- **Filters** only offer games, markets and counterparts with at least 50
players.
A suppressed period in an own-KPI series:
```json
{ "period": "2026-09-07", "value": null, "suppressed": true }
```
## Network comparison
[Available]
A comparison is published only when the **rest of the network** (players of
other partners of your type) passes the peer floor, the account floor and the
dominance rule. Your own side must also have at least 50 accounts; if it does
not, the network figures are still shown but your value is withheld.
For cross-partner KPIs (multi-homing, overlap, share of wallet), your own value
describes your players' activity with other partners. It is shown only when
that activity spans at least 5 other partners and is not dominated by one or
two of them.
If a market slice fails a rule, PlayerPal returns the global figure for the
same period and marks it with `"fallback": "global"`. If the global figure
also fails, the network side is suppressed with a reason. Your own value can
still appear when it passes the account floor, because it describes only your
players:
```json
{
"kpi": "cmp.bet_per_player", "period": "2026-09", "grain": "month",
"market": null, "fallback": null,
"you": 45, "network": null, "index": null, "band": null,
"suppressed": true, "reason": "peer_floor"
}
```
| `reason` | Meaning |
|---|---|
| `peer_floor` | The rest of the network spans fewer than 5 partners of your type |
| `account_floor` | The rest of the network has fewer than 50 accounts |
| `dominance` | One partner holds more than 50% of the measure, or two hold more than 75% |
| `y_floor` | Your own players number fewer than 50; the network is still shown |
| `elsewhere_floor` | Cross-partner KPI: your players' activity elsewhere is too concentrated or too thin |
| `not_computed` | No figures exist for that period yet |
## Benchmarks
[Available]
A benchmark segment is published only when at least 6 partners of your type
are in it, at least 50 accounts are behind it, and no partner dominates it. A
thin market segment falls back to global in the same way as comparisons. When
nothing can be published, `peers` and `band` are `null`, `suppressed` is
`true` and `reason` names the rule.
## What is never published
Even when a segment passes every rule, partners never receive:
- a minimum, a maximum or an exact rank;
- any single partner's value, name or ID;
- an exact network size. Sizes and peer counts are given only as bands:
`5-9`, `10-24`, `25-99`, `100-999`, `1k-99k` or `100k+`.
Published statistics are limited to p25, p50, p75, the mean and the quartile
band your value falls in.
## Staff access
PlayerPal staff can view a partner's own KPIs without the account floor, for
support. Every such access is recorded with the staff member as the actor.
## Related
- [Metric dictionary](./metrics.mdx):
Which KPIs carry the account floor.
- [Partner analytics](./overview.mdx):
Visibility tiers and where to get your figures.
- [Privacy](../trust/privacy.mdx):
How PlayerPal works without player personal data.
---
# Partner API authentication
Source: https://docs-internal.playerpal.io/docs/api/hmac-signing
The partner APIs (KPIs, studio analytics) accept two schemes. Both resolve to your party, so you only ever see data for the players linked to you. The decision is recorded in [ADR 0033](../decisions/0033-partner-kpi-platform.md).
| Scheme | Use when | Header |
|---|---|---|
| OAuth 2.0 client credentials (recommended) | You can run a token flow | `Authorization: Bearer ` |
| PP-HMAC-SHA256 request signing | You can't run OAuth, or want per-request integrity | `Authorization: PP-HMAC-SHA256 keyId=…, signature=…` |
A party ID sent in a query string or an `x-party-id` header is ignored. Your scope always comes from your credentials.
## Scopes
| Scope | Grants |
|---|---|
| `kpis.read` | `/v1/kpis/*` |
| `studio.read` | `/v1/analytics/studio*` (providers only) |
## OAuth client credentials
PlayerPal registers a service account for you and maps its client ID to your party. Request a token from the issuer with the client-credentials grant, using the PlayerPal partner API project as the audience. Then send it as `Authorization: Bearer `. Tokens are short-lived (5–15 minutes), so cache one and refresh it before it expires.
## PP-HMAC-SHA256
PlayerPal issues you a key ID (`pp_kid_…`) and a secret (`pp_sk_…`). The secret is shown **once**; store it in your secret manager. You can have two active keys at a time. After a rotation the old key keeps working for 24 hours.
### Headers
```
Authorization: PP-HMAC-SHA256 keyId=pp_kid_XXXX, signature=
X-PP-Timestamp:
X-PP-Nonce: <16–128 chars of A-Z a-z 0-9 _ ->
```
### Canonical string
Join these six lines with `\n` (no trailing newline):
1. HTTP method, uppercase
2. Path, exactly as sent, without the query string
3. Query string: pairs sorted by key and then by value, each key and value RFC 3986 percent-encoded, joined with `&`. Empty if there's no query string.
4. Lowercase hex SHA-256 of the exact request body bytes (the hash of the empty string for GET)
5. The `X-PP-Timestamp` value
6. The `X-PP-Nonce` value
`signature = base64url_nopad(HMAC-SHA256(secret, canonical))`
### Rules
- The timestamp must be within **±300 seconds** of PlayerPal's clock.
- A nonce can be used only once per key within the window. A replay is rejected with `replayed_nonce`.
- Sign the body bytes you actually send. Re-serialising JSON after signing breaks the signature.
Errors are returned as `401` with `message` set to one of: `invalid_signature`, `stale_timestamp`, `invalid_nonce` or `replayed_nonce`. A missing scope is returned as `403 partner_scope_missing`.
### Test vectors
These all use secret `pp_sk_test-vector-secret`, timestamp `1760000000` and nonce `nonce_0123456789abcdef`.
| # | Request | Canonical query | Body SHA-256 | Signature |
|---|---|---|---|---|
| 1 | `GET /v1/kpis/own?to=2026-09-30&kpi=own.rounds&from=2026-09-01` | `from=2026-09-01&kpi=own.rounds&to=2026-09-30` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | `NhQFk3mISFyIhDIMHBwZ-pWBoO5pKuFk_yWDVycaOyY` |
| 2 | `POST /v1/kpis/exports` with body `{"kpi":"own.rounds"}` | (empty) | `4dd0775e21e5aa993d44025a43efbd7aa90f24f91b463afdb3e80787704582df` | `7Jd4HZfVqJj3aJTY7K09ipVJHIDQ_6qfICxscuh1-kw` |
The API's test suite checks against these vectors (`services/api/src/auth/partner/hmac-signature.spec.ts`).
### Reference signer (TypeScript, Node 20+)
```ts
const enc = (s: string) =>
encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
export function signRequest(keyId: string, secret: string, method: string, url: URL, body = '') {
const query = [...url.searchParams.entries()]
.sort(([ak, av], [bk, bv]) => (ak === bk ? (av < bv ? -1 : 1) : ak < bk ? -1 : 1))
.map(([k, v]) => `${enc(k)}=${enc(v)}`)
.join('&')
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomBytes(16).toString('hex')
const canonical = [
method.toUpperCase(),
url.pathname,
query,
createHash('sha256').update(body).digest('hex'),
timestamp,
nonce,
].join('\n')
const signature = createHmac('sha256', secret).update(canonical).digest('base64url')
return {
authorization: `PP-HMAC-SHA256 keyId=${keyId}, signature=${signature}`,
'x-pp-timestamp': timestamp,
'x-pp-nonce': nonce,
}
}
```
### Reference signer (bash + openssl)
```bash
KEY_ID=pp_kid_XXXX; SECRET=pp_sk_XXXX
METHOD=GET; PATH_=/v1/kpis/catalogue; QUERY=""; BODY=""
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIG=$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_" "$QUERY" "$BODY_HASH" "$TS" "$NONCE" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
curl -s "https://api.playerpal.io$PATH_" \
-H "Authorization: PP-HMAC-SHA256 keyId=$KEY_ID, signature=$SIG" \
-H "X-PP-Timestamp: $TS" -H "X-PP-Nonce: $NONCE"
```
## Credential administration (PlayerPal staff)
| Endpoint | Purpose |
|---|---|
| `GET /v1/staff/partners/:partyPublicId/credentials` | List keys (never returns secrets) |
| `POST /v1/staff/partners/:partyPublicId/credentials` | Issue a key: `{ "scopes": ["kpis.read"], "expires_at": "…" }` (both optional). The response contains the secret, once. |
| `POST /v1/staff/partners/:partyPublicId/credentials/:keyId/rotate` | Issue a replacement; the old key expires in 24 hours |
| `DELETE /v1/staff/partners/:partyPublicId/credentials/:keyId` | Revoke immediately |
| `PUT /v1/staff/partners/:partyPublicId/oauth-clients/:clientId` | Map an OAuth client ID to the party: `{ "scopes": [...] }` |
Every change is recorded as a `partner.credential.*` domain event. These endpoints use the machine key until workforce SSO is in place.
---
# Partner fact ingestion (`POST /v1/ingest`)
Source: https://docs-internal.playerpal.io/docs/api/ingest
This implements the *PlayerPal Data, Analytics & Ingestion Specification v0.1*. The principle: partners send pseudonymous daily **facts**, and PlayerPal computes every **KPI** from them. Ingestion is batch-first; daily or periodic payloads are enough.
**Auth:** OAuth client credentials or PP-HMAC-SHA256 ([hmac-signing.md](hmac-signing.md)) with scope `ingest.write`. `source_id` must be your own party ID, and the schema must match your party type.
## Envelope (§4)
```json
{
"schema": "playerpal.provider.player_game_daily.v1",
"schema_version": 1,
"batch_id": "01JAXAMPLEBATCHID",
"source_party": "GAME_PROVIDER",
"source_id": "pp_prv_example",
"integration_id": "int_example_prod",
"period_start": "2026-10-12T00:00:00Z",
"period_end": "2026-10-13T00:00:00Z",
"generated_at": "2026-10-13T01:15:00Z",
"records": []
}
```
The response is a `202` receipt: `{ batch_id, schema, received_at, records_received, records_accepted, records_rejected, rejections: [{ index, reason }], replayed }`. You can fetch it again from `GET /v1/ingest/batches/:batchId`.
- **Idempotency.** Re-sending a `batch_id` with the same records replays the receipt. Different records under the same `batch_id` return `409 idempotency_conflict`.
- **Corrections (§15).** Send the corrected facts in a new batch. Records upsert by natural key, `revision` is incremented, and `received_at` is kept separately from the activity date.
- **Per-record validation.** One bad record never rejects the whole batch. If a batch contains duplicates, the last record for each natural key is kept and the earlier ones are rejected as `duplicate_in_batch`.
- **Limits.** At most 10,000 records per batch. Daily facts must fall within `[period_start day, period_end)`.
- **Currency.** v1 accepts `EUR` only, because the canonical currency policy is still open (§18).
- **Pseudonymous refs only.** `player_ref` must be an alias PlayerPal issued to you: `pp_plr_prv_…` for studios through their integrations, `pp_plr_opr_…` for operators. Anything else is rejected as `player_ref_unknown`. Email-shaped and other direct identifiers fail validation.
## v1 schemas (§19)
| Schema | Sender | Natural key | Notes |
|---|---|---|---|
| `playerpal.provider.player_game_daily.v1` | Studio | provider × player × game × day | `sessions`, `rounds`, `active_seconds`, `total_bet`, `total_win`, `jackpot_win`. `operator_ref` (a `pp_opr_…` ID) must be an operator you're integrated with; if omitted, it is inferred from the alias's integration. |
| `playerpal.provider.game_catalogue.v1` | Studio | provider × game | `name`, `category` (SLOTS/LIVE/TABLE/CRASH/INSTANT/OTHER), `release_date`, `rtp_percent`, `volatility`, `active`. |
| `playerpal.operator.player_commercial_daily.v1` | Operator | operator × player × day | Deposits, withdrawals, stake, win, `ggr`, `bonus_cost`, `ngr` (GGR and NGR may be negative), `first_deposit`. |
| `playerpal.affiliate.attribution.v1` | Operator (v1) | affiliate × player | `affiliate_ref` (a `pp_aff_…` ID), `campaign_ref`, `click_ref`, `attributed_at`, `attribution_status` (CLAIMED/OBSERVED/VERIFIED), `attribution_source`. Only operators can send attribution in v1, as the authoritative source (§8). |
Points, rewards, redemptions and missions are native to PlayerPal (§9), so partners don't send them.
## Privacy
Delete Account removes the player's fact rows. Reward redemptions are kept for loyalty accounting, but lose the link to the player.
---
# Partner KPI API
Source: https://docs-internal.playerpal.io/docs/api/kpis
Your KPIs, covering only the players linked to you. Authenticate with OAuth client credentials or PP-HMAC-SHA256 ([hmac-signing.md](hmac-signing.md)) using a credential with scope `kpis.read`. Your party is taken from the credential; there is no party parameter. Definitions are in [docs/kpis.md](../kpis.md).
## `GET /v1/kpis/catalogue`
Returns the KPIs your party type can read.
```json
{ "kpis": [ { "id": "own.rounds", "label": "Rounds", "unit": "count", "markets": true,
"grains": ["day", "week", "month"], "account_floor": true } ] }
```
## `GET /v1/kpis/own`
| Param | Required | Notes |
|---|---|---|
| `kpi` | yes | An ID from the catalogue |
| `from`, `to` | yes | `YYYY-MM-DD` (UTC), inclusive, under 400 days |
| `grain` | no | `day` (default), `week` (ISO weeks starting Monday) or `month` |
| `market` | no | ISO 3166-1 alpha-2, e.g. `MT`. Only for KPIs with `markets: true` |
```json
{
"kpi": "own.rounds", "label": "Rounds", "unit": "count",
"from": "2026-09-01", "to": "2026-09-30", "grain": "week", "market": "MT",
"series": [ { "period": "2026-08-31", "value": 1240 },
{ "period": "2026-09-07", "value": null, "suppressed": true } ],
"total": { "value": 5120 },
"previous": { "from": "2026-08-02", "to": "2026-08-31", "value": 4800 },
"delta_pct": 0.0667,
"generated_at": "2026-10-13T12:00:00.000Z"
}
```
- Every period in the range is returned. A period with no activity has value `0`; for ratios and latencies it is `null`.
- A cell is `suppressed` when fewer than 50 distinct accounts are behind it (ADR 0026). This applies to KPIs with `account_floor: true`.
- `previous` is the period of the same length immediately before `from`. `delta_pct` is `null` when either total is missing or the previous total is `0`.
Errors (`400`): `kpi_unknown`, `kpi_not_available_for_party`, `date_range_invalid`, `date_range_too_long`, `grain_invalid`, `market_invalid` and `market_not_supported`. `503 kpi_backend_not_configured` and `502 kpi_backend_error` are server-side problems.
Every call is recorded as a `kpi.api.accessed` domain event (party, client, KPI).
## Own-tier KPIs (v1)
| ID | Unit | Who | Account floor | Market |
|---|---|---|---|---|
| `own.rounds` | count | operator, provider | ✓ | ✓ |
| `own.bet_eur` / `own.win_eur` / `own.ggr` | EUR | operator, provider | ✓ | ✓ |
| `own.active_accounts` | count | operator, provider | ✓ | ✓ |
| `own.linked_players` | count (distinct in period) | operator, provider | ✓ | |
| `own.new_links` / `own.unlinks` | count | operator, provider | | |
| `own.link_intents` / `own.link_conversion` | count / ratio | operator | | |
| `own.points_issued` | points | operator, provider | ✓ | |
| `own.ingest_acceptance` | ratio | operator, provider | | |
| `own.batch_latency_p95` | seconds | provider | | |
## `GET /v1/kpis/overview`
This is the partner overview (Spec v0.1 §13), computed from the canonical facts ([ingest.md](ingest.md)). Pass `from` and `to` as `YYYY-MM-DD`. The previous period is the one of the same length immediately before.
Every KPI in the registry (`analytics/overview/kpi-registry.ts`) comes back typed:
```json
{ "id": "net_revenue", "name": "Net revenue (NGR)", "description": "…", "unit": "eur",
"value": 841.54, "trend": { "previous": 1003.2, "delta": -0.161, "kind": "relative", "direction": "up" },
"dataAvailability": "available", "source": ["operator_commercial"], "requirement": null,
"lastUpdated": "2026-10-13T12:40:05.000Z" }
```
`dataAvailability` is never faked as a zero:
| Value | Meaning |
|---|---|
| `available` | Computed from delivered facts |
| `requires_feed` | `requirement` names the missing feed, e.g. "Requires operator commercial feed" |
| `not_applicable` | Not shown to this audience, e.g. commercial and reward KPIs for studios (§13.2) |
| `suppressed` | Fewer than 50 active players in the range |
Which players count:
- **Operators:** their own players.
- **Studios:** players of their games.
- **Affiliates:** only their **verified-attributed** players (§13.3).
Ratio trends are in percentage points; other trends are relative changes.
Retention uses fixed observation windows: D1 = days 1–7, D7 = days 7–13, D30 = days 30–36. Only new players whose whole window has elapsed are counted, so recent cohorts aren't biased low.
`panels` carries the chart data, each with its own `availability`:
- `activity`
- `cohorts` (weekly, or monthly when weekly cohorts would fall under the floor)
- `mix`
- `geo` (markets under the floor fold into Other)
- `segments` (now and previous)
- `campaigns` (affiliates)
- `rewards`
- `missions`
- `reward_impressions` (`requires_feed`: impressions are not a v1 feed)
## `GET /v1/kpis/breakdown`
Same parameters as `/own`, plus `dimension` set to `market`, `game` or `partner`. A partner is one of your own integration counterparts: a studio if you are an operator, an operator if you are a studio. Activity KPIs only.
The response contains the top 10 rows by value, each with its `share` of the visible total (additive KPIs only), then `Other (n)`. Rows under the account floor are never listed; they are counted in `suppressed_rows`.
## `GET /v1/kpis/cohorts/retention`
Takes `from` and `to` as `YYYY-MM`, defaulting to the last 12 closed months. Each cohort is the month of a player's first round with you. `retention[o]` is the share of that cohort active again `o` months later. Cohorts under the account floor are suppressed.
`network` is the pooled retention of other parties of your type. It is only published when at least 5 parties contribute, there are at least 50 accounts, and no party dominates; otherwise `network_reason` says why.
## `GET /v1/kpis/funnels/link`
Takes `from` and `to` as `YYYY-MM-DD`.
- **Operators:** link started → linked → played within 30 days.
- **Studios:** linked through your integrations → played your games within 30 days.
Each step gives its `rate` against the first step and its `step_rate` against the previous step. The whole funnel is suppressed when the first step is under the account floor.
`/own` also returns `previous_series`: the period before, aligned by position with `series`, so charts can overlay it.
## Comparison and benchmark periods
Comparisons and benchmarks use fixed, closed periods only. `grain=month` with `period=YYYY-MM`, or `grain=week` with `period=YYYY-Www` (ISO week). An open or future period is rejected with `period_not_closed`. Results are recomputed every 6 hours for recent periods.
## `GET /v1/kpis/compare`
Compares your players with the rest of the PlayerPal network: players of other parties of your type. See [kpis.md, Network comparison KPIs](../kpis.md#network-comparison-kpis-cmp).
| Param | Required | Notes |
|---|---|---|
| `kpi` | yes | A `cmp.*` ID (table below) |
| `period`, `grain` | yes | See above |
| `market` | no | If that market is too thin to publish, you get the global figure with `fallback: "global"` |
```json
{
"kpi": "cmp.bet_per_player", "period": "2026-09", "grain": "month",
"from": "2026-09-01", "to": "2026-09-30", "market": null, "fallback": null,
"you": 45,
"network": { "baseline": "rest_of_network", "p25": 20, "p50": 36, "p75": 52, "mean": 38.4,
"size_band": "100-999", "parties_band": "5-9" },
"index": 125, "band": "p50-p75",
"suppressed": false, "reason": null, "computed_at": "…"
}
```
- `you` is your players' median for per-player KPIs, or your ratio for party KPIs.
- `index` is `you ÷ network median × 100`.
- `band` is the quartile of the network distribution your value falls in.
- Network size and party count are given only as bands.
`reason` (when anything is withheld):
| Reason | Meaning |
|---|---|
| `peer_floor` | The rest of the network spans fewer than 5 parties of your type |
| `account_floor` | The rest of the network has fewer than 50 accounts |
| `dominance` | One party has more than 50% of the measure, or two have more than 75% |
| `y_floor` | Your own population is under 50 accounts; the network is still shown |
| `elsewhere_floor` | Cross-party KPI where your players' activity elsewhere spans fewer than 5 parties, or is dominated by one |
| `not_computed` | No data for that period yet |
| `cmp.*` KPI | Who | Kind |
|---|---|---|
| `cmp.active_days`, `cmp.rounds_per_player`, `cmp.bet_per_player`, `cmp.ggr_per_player` | operator, provider | Your players' median vs the network's player distribution |
| `cmp.points_per_player` | operator | As above (global only) |
| `cmp.avg_stake`, `cmp.payout_ratio`, `cmp.churn_rate` | operator, provider | Your ratio vs other parties' ratios |
| `cmp.link_conversion` | operator | As above (global only) |
| `cmp.multi_homing`, `cmp.overlap_rate`, `cmp.share_of_wallet` | operator, provider | Cross-party (`elsewhere_floor` applies) |
## `GET /v1/kpis/compare/summary`
Takes `period`, `grain` and optionally `market`. Returns every `cmp.*` KPI for your party type in one response, as `{ …period fields, kpis: [ { kpi, fallback, you, network, index, band, suppressed, reason } ] }`.
## `GET /v1/kpis/benchmarks`
Compares you with anonymous peers of your party type. Takes the same `period`, `grain` and `market` parameters.
```json
{ "kpi": "own.bet_eur", "period": "2026-09", "grain": "month", "market": null, "fallback": null,
"peers": { "party_type": "OPERATOR", "count_band": "5-9", "p25": 1200, "p50": 1800, "p75": 2600, "mean": 1950 },
"band": "p0-p25", "suppressed": false, "reason": null, "computed_at": "…" }
```
Benchmarkable KPIs: `own.rounds`, `own.bet_eur`, `own.ggr` and `own.active_accounts`. These are global and per market. `own.linked_players`, `own.ingest_acceptance` and `own.link_conversion` are global only. A segment is published only when at least 6 parties are in it (you plus at least 5 others) and no peer dominates it.
## Staff
`GET /v1/staff/kpis/own?party=&kpi=…` takes the same parameters. It is not floored and is audited as `kpi.api.accessed` with actor `staff`.
## Analytics workstation (`/v1/kpis/explore/*`)
These endpoints back the partner portal's Analytics pages. Every one is scoped to the authenticated party. Each takes `from` and `to` (`YYYY-MM-DD`) and these optional filters:
| Filter | Format | Example |
|---|---|---|
| `game` | game ref | `orbit-breaker` |
| `market` | ISO 3166-1 alpha-2 | `MT` |
| `partner` | counterpart public id | `pp_opr_…` |
A filter value the party has no data for returns `400 filter_invalid`.
| Endpoint | Returns |
|---|---|
| `explore/options` | The games, markets and counterparts this party can filter by. Only values with at least 50 players are offered. |
| `explore/performance` | Engagement KPIs (`MetricValue`: value, previous value, change, formula, sample size, availability class, source, freshness) and a series per period against the previous period. |
| `explore/dimension?dim=game\|market\|partner` | One row per entity: active players and change, new players, sessions, minutes, repeat rate, D7/D30 (players returning to that entity), game GGR (bet − win) per player, and an 8-week series. Also deterministic highlights. Rows under 50 players are left out and counted in `suppressed`. |
| `explore/players` | Lifecycle KPIs, a 7-state mix by week, and the flows between the previous and current period. Flows under 50 players are left out and counted. |
| `explore/retention?months=` | Monthly first-play cohorts × D1…D90 (`DN` = played again in days N to N+6). A cell is shown only once the whole cohort has had its window. Also your curve against the pooled network curve. |
| `explore/benchmark-history` | Your stored monthly comparisons against the network quartiles. Unpublished segments carry no network values. |
**Network curve.** It pools every other party of your type, excluding you. It is returned only with at least 5 peers and no peer above 50% of the pooled cohort. Otherwise the response carries `available: false` and a `reason`. No peer is ever returned on its own.
**Lifecycle states.** Each player is classified by their own activity with you. The first matching state wins, in this order:
| State | Rule |
|---|---|
| New | First play in the last 14 days |
| Reactivated | Playing in the last 14 days after 30+ quiet days |
| Highly engaged | 8+ active days in the last 28, not slowing down |
| Declining | Playing, on fewer than half the days of the 14 before |
| Active | Played in the last 14 days |
| At risk | Last played 15–30 days ago |
| Dormant | Last played 31–119 days ago |
`compare/summary` also returns `position: { favourable, assessed }`: the metrics in the better half of the network, out of those where a direction is defined.
## Insights (`/v1/kpis/intelligence/insights?from&to`)
These are ranked, explainable insights plus an executive summary. They are computed by fixed rules and statistical tests over the KPI engine's outputs, so every privacy floor already applies. No language model computes or writes them.
**Detectors**
| Detector | What it reports |
|---|---|
| Change | A material move against the previous period: at least 8% relative, or 2pp for rates. |
| Driver | The market, game or counterpart carrying at least 30% of the net change in active players. |
| Retention gap / strength | A segment whose D7 retention is at most 0.75×, or at least 1.25×, that of your other segments. |
| Portfolio opportunity | A game with above-median retention but below-median reach. |
| Benchmark | A top- or bottom-quartile position against anonymous peers. |
| Anomaly | One of the last 14 days far outside the 14 days before it (robust z of at least 3.5, and a change of at least 25%). |
**Confidence**
Confidence is 1 − p from a two-sided test:
- **Counts:** a Poisson test.
- **Rates:** a two-proportion z-test against the rate's real base.
- **Rates with an unknown base:** capped at 0.85.
- **Rates whose base is under the 50-player floor:** never reported.
**Ranking.** Insights are ranked by magnitude × population × business weight × confidence × freshness. `primary` holds three of them: a positive, a risk and an opportunity.
**Wording.** The text describes association and concentration only. Tests reject causal wording.
---
# Architecture
Source: https://docs-internal.playerpal.io/docs/architecture
PlayerPal is a privacy-preserving loyalty and analytics sidecar for operators, game providers, affiliates and reward partners. It maintains a persistent player account, loyalty ledger, campaign progress and analytics, and does not store a player's email address, name or telephone number.
This page is the system overview. Identity and authentication detail: [spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md](../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md) (wins on conflict). Topic pages under [docs/](README.md) are the day-to-day source of truth.
## Design principle
```text
ONE PRIVATE PLAYER IDENTITY
MANY AUDIENCE-SPECIFIC EXTERNAL ALIASES
NO PLAYER EMAIL STORED BY PLAYERPAL
```
- Players may register directly, from an operator, or from a provider when the provider is the integration entry point.
- A successful authentication resolves to one private PlayerPal player. The player can see `pp_plr_...`. Operators and providers never receive it.
- Connecting an operator issues `pp_plr_opr_...`. Reporting activity through a provider-operator integration issues `pp_plr_prv_...`.
- PlayerPal is not on the game critical path. If every PlayerPal service is down, the casino and provider must still authenticate the player, launch the game, accept bets, settle rounds and pay winnings. See [ingestion-and-availability.md](ingestion-and-availability.md) and ADR 0015.
## Components
```text
Player and admin clients
→ API gateway / BFF
→ IAM (Keycloak or ZITADEL) protocols, credentials, sessions
→ Identity Service players, aliases, links, consent
→ Authorization Service organisation, audience, resource
→ Ingestion API
→ durable inbox / object storage
→ Loyalty engine
→ Analytics pipeline
```
Logical components (spec §22):
| Component | Owns |
|---|---|
| IAM platform | OIDC/OAuth, passkeys, social federation, MFA, enterprise SSO, machine tokens |
| Player BFF | Browser session boundary |
| Identity Service | Players, auth mappings, aliases, links, Unlink, Delete Account |
| Integration Registry | Operators, providers, credentials, namespaces |
| Authorization Service | Organisation and resource decisions |
| Ingestion API | Authenticated batch acceptance (integration-scoped) |
| Event stream | Decoupled processing (Postgres inbox first; Redpanda/Kafka later) |
| Loyalty engine | Ledger, rules, campaigns |
| Analytics | Provider studio cohort and per-alias numbers; no partner-facing identity keys |
Do not store loyalty balances, aliases, operator connections or gameplay facts as IAM attributes. See ADR 0011.
## Initial stack
Technology-neutral in the spec; pragmatic first deployment (spec §24, §35):
| Stage | Initial choice |
|---|---|
| IAM | ZITADEL for local POC ([ADR 0021](decisions/0021-nestjs-zitadel-local-poc.md)); production pick still needs §23.2 |
| Core API (control plane) | NestJS modular monolith (`services/api`) |
| Data-plane workers | Go (`services/worker`) when ingestion load justifies it |
| Identity / ledger | PostgreSQL (TypeORM DDL, ADR 0023) |
| Player web | React portals + NestJS BFF; popup + `postMessage` for embedded operator flows |
| Cache | Valkey/Redis where needed; never source of truth |
| Bulk landing | S3-compatible object storage |
| Batch jobs | Dagster |
| Observability | OpenTelemetry, Prometheus/Grafana |
Start as a modular monolith plus an independent ingestion worker. Split services only at measured hotspots. Airflow is not the default orchestrator.
## Delivery
Spec §42 (preferred over the earlier four-phase sketch in §28 where they differ):
0. Contract and failure proof. Freeze identifiers and error codes; prove play continues while PlayerPal is blocked; no-email IAM POC.
1. Thin vertical slice. ZITADEL (local POC), Nest-owned passkey/password/social, one social method, Bucks Casino linking, Pragmatic Play alias in the link exchange, Postgres, points ledger, player wallet.
2. Production hardening. Provider outbox, gzip batches, idempotency, object-storage profile, key rotation, SLOs.
3. Scale where observed. Redpanda, analytical store, regional ingestion, policy engine only if application authZ is no longer enough.
## Related ADRs
- [0001](decisions/0001-generic-party-model.md) generic party model
- [0004](decisions/0004-async-first-ingestion.md) async-first ingestion
- [0005](decisions/0005-phased-event-log-infrastructure.md) Postgres inbox then Redpanda
- [0011](decisions/0011-iam-authenticates-playerpal-owns-identity.md) IAM vs identity graph
- [0015](decisions/0015-async-loyalty-sidecar.md) off the game path
- [0026](decisions/0026-analytics-subject-and-provider-cohort.md) analytics subject and cohort
---
# Authentication
Source: https://docs-internal.playerpal.io/docs/authentication
An IAM platform authenticates humans and machines. PlayerPal remains authoritative for the player identity graph, pairwise aliases, consent, loyalty ownership, analytics and business authorization.
Long-form: spec §5-§9, §13-§14, §19, §23. Decisions: ADR 0011, 0012, 0016, 0020, 0021, 0023, 0024.
## IAM vs PlayerPal
The IAM subject answers: which authentication account logged in?
The PlayerPal player answers: which loyalty identity owns this activity, balance and relationship graph?
Keeping them separate gives IAM vendor portability, no IAM lookup on each gameplay event, product-controlled aliases, and audited link/unlink/merge. Do not store loyalty or alias data as IAM attributes, groups or roles.
Local/dev POC uses ZITADEL with a NestJS BFF ([ADR 0021](decisions/0021-nestjs-zitadel-local-poc.md), [ADR 0024](decisions/0024-nest-owned-portal-auth.md), [infra/README.md](../infra/README.md)). Production IAM still requires the §23.2 checklist. The IAM subject is not the PlayerPal player.
## Security domains
Isolate four populations (separate Keycloak realms or ZITADEL projects/organisations, subject to POC):
```text
IAM platform
├── Player authentication
├── Partner administration (operator / provider / affiliate)
├── PlayerPal workforce
└── Machine identities (operator / provider / batch clients)
```
At minimum, separate clients, scopes, signing/audience policies and administrative permissions.
## Player methods
| Method | Email required | Status |
|---|---|---|
| Passkey / WebAuthn | No | Required; preferred |
| Google / social OIDC using opaque `iss + sub` | No, if configuration permits | Required after provider POC |
| Apple / Microsoft OIDC using opaque subject | No, if claim filtering is valid | Recommended |
| PlayerPal ID + password | No | Offered on player portal; provisional A0 (ADR 0020, 0024) |
| Recovery codes | No | Required |
| TOTP / hardware key | No | Optional / recommended MFA |
| Email magic link | Yes | Excluded |
| SMS OTP | Phone required | Excluded |
Player-facing OIDC clients request `openid` only. Do not request `email`, `profile` or `phone` by default. Never link accounts by matching email. If a provider still emits email, product/legal/security must decide whether transient receipt plus immediate filtering is allowed; otherwise do not enable that provider (ADR 0012).
Workforce (PlayerPal staff and partner staff) may use business email and enterprise SSO. That domain is separate from player identity.
### Local POC (Nest-owned portal auth)
For the NestJS + ZITADEL local vertical ([ADR 0021](decisions/0021-nestjs-zitadel-local-poc.md), [ADR 0024](decisions/0024-nest-owned-portal-auth.md)):
- The player portal is **http://player.localhost:8080/** (host routing, ADR 0018). Nest is same-origin under `/auth`, `/v1`, `/health` so the BFF cookie is first-party after social return.
- Portal chrome: **Sign in** / **Sign up**. Passkey and Google are first-class; PlayerPal ID + password under an OR divider. No email or phone fields.
- **Password + passkey** stay on the portal card via Nest (`POST /auth/password-signin`, `POST /auth/password-signup`, `POST /auth/passkey/begin|finish`). Login V2 is not the password/passkey UX.
- **Password sign-in resolution:** `pp_plr_…` → `authentication_identities.iam_subject` (local) → else ZITADEL ListUsers by username (cold) → Session API with opaque `userId`. Session `loginName`-only checks often 404.
- **After social:** **Account** sidebar → Add password / Add passkey (`POST /auth/factors/*`). Uses `player_sessions.iam_user_id` while signed in. Assurance stays A1 when already A1 (ADR 0020).
- **Social** starts from Nest (IdP intent / `GET /auth/login?method=…` → `/auth/idp/success`). IAM human username = `pp_plr_…`; throwaway password never shown. No Login V2 email form.
- **Discoverable passkey:** empty PlayerPal ID → Nest challenge with empty `allowCredentials`; verify against `webauthn_credentials` public-key mirror (bridges Session API `COMMAND-eeR2e` / [zitadel#8899](https://github.com/zitadel/zitadel/issues/8899)). Identifier-first passkey may use Session API with `userId`.
- **WebAuthn RP:** `WEBAUTHN_RP_DOMAIN` (default hostname of `APP_BASE_URL`, e.g. `player.localhost`). Passkeys enrolled on Login V2 (`localhost`) are a different RP.
- IAM may hold a synthetic verified placeholder email for User API create; PlayerPal never stores it (ADR 0012).
- IdP resource ids: `ZITADEL_*_IDP_ID` on the API; otherwise `GET /auth/methods` reports unavailable.
- **Logout** clears the PlayerPal cookie only (no ZITADEL `end_session` / Login V2 park).
- Login V2 fork ([ADR 0022](decisions/0022-official-login-app-fork.md)) remains optional for skins; not the authoritative password/passkey path.
See [infra/README.md](../infra/README.md) for login-policy and Google IdP setup. Schema: TypeORM ([ADR 0023](decisions/0023-typeorm-data-layer.md)).
## Sessions and tokens
Player web: BFF with `HttpOnly`, `Secure`, `SameSite` cookies (ADR 0016). Do not store long-lived tokens in `localStorage`. Cookie may be set after Nest Session password/passkey or Nest social success (ADR 0024), or after classic Auth Code + PKCE when used.
Active portal sessions live in `player_sessions`. Players list them on Account (`GET /v1/me/sessions`) and may revoke other devices (`DELETE /v1/me/sessions/:id`, `POST /v1/me/sessions/revoke-others`). Revoking the current session is Sign out / `POST /auth/logout` only. At create, Nest stores a truncated User-Agent and a derived `client_label` for display; no IP, fingerprint, or geolocation. Device hints are never used for merge or assurance (ADR 0019). Staff POC: `GET/DELETE /v1/staff/players/:pp_plr/sessions` with `MACHINE_API_KEY` (workforce SSO later). Never return token hashes, `iam_user_id`, `oidc_id_token`, or the internal player UUID.
Embedded widget: first-party PlayerPal popup; one-time codes; exact `postMessage` origin checks; no third-party cookies; no refresh tokens in the widget.
Machine clients: short-lived access tokens (recommended 5-15 minutes), `private_key_jwt`, key overlap during rotation. Token validation uses cached JWKS. No introspection on every call.
The public player token carries the IAM subject, not the private PlayerPal UUID and not live loyalty state. The API maps `iss + sub` server-side. Opaque `iam_subject` / session `iam_user_id` are never returned on `/v1/me`.
This supersedes ADR 0003 (JWT at operator login for an embedded widget). See ADR 0015 and ADR 0016.
## Recovery
Because PlayerPal does not store email or phone (spec §19):
- Encourage at least two passkeys.
- Issue one-time recovery codes (PlayerPal-owned hashed codes — not ZITADEL MFA backup codes).
- Password reset (if a password exists) uses recovery code, an existing authenticated device/passkey, or a linked social method — not email, SMS, or operator take-over ([ADR 0028](decisions/0028-v1-recovery-without-operator-assist.md)).
### Local POC recovery codes
Nest owns recovery end-to-end:
- `POST /v1/me/recovery-codes/rotate` — generate/replace (plaintext once; `Cache-Control: no-store`). Requires recent reauth (`reauth_at` within 10 minutes, set at full login or via `POST /auth/reauth/password`).
- `POST /v1/me/recovery-codes/confirm` — marks setup complete (separate from generation).
- `GET /v1/me/recovery-codes` / `GET /v1/me.recovery` — status (remaining, generated_at, setup_confirmed).
- `POST /auth/recovery/verify` — public; consumes one code atomically; issues a short-lived `session_scope=recovery` cookie.
- During recovery, only factor enroll (`/auth/factors/password`, passkey begin/finish), `GET /auth/factors`, `GET /v1/me` (scope metadata), and logout are allowed. Dashboard/partner/session-management APIs stay full-session only. Saving a credential upgrades the session to `full` and revokes other sessions.
- Codes are HMAC-SHA256 with `SUBJECT_PEPPER`; presentation separators are stripped on verify. Uniform failure message; rate-limited attempts.
Staff/business portals are unchanged (fake sessionStorage sign-in).
v1 does not include operator-assisted recovery. Staff may list or revoke
portal sessions; they must not reset credentials or take ownership of an
Account ([ADR 0028](decisions/0028-v1-recovery-without-operator-assist.md)).
---
# Authorization
Source: https://docs-internal.playerpal.io/docs/authorization
IAM roles may name broad personas. PlayerPal permissions name business operations. Fine-grained authorization stays server-side.
Long-form: spec §5.3, §17. v1 is application-native Nest checks
([ADR 0029](decisions/0029-application-native-authorization.md)). A policy
engine waits until a second service shares the same PlayerPal permissions
or non-engineers must author those rules.
## Personas (IAM)
```text
player
operator_admin
provider_admin
affiliate_admin
playerpal_support
playerpal_platform_admin
```
These are not the complete authorization model. Never treat IAM roles as sufficient for alias, ledger or report access.
## Permissions (PlayerPal)
```text
loyalty.self.read
operator.player.loyalty.read
operator.player.alias.issue
provider.activity.write
provider.batch.status.read
reports.aggregate.read
reports.player.read
campaign.read
campaign.manage
integration.read
integration.manage
identity.connection.revoke
support.session.create
```
Every decision must evaluate principal, organisation, integration, audience, resource owner, permission, data-sharing agreement, and any support or delegated-access context.
## Rules
- Partners may only use aliases issued to their audience. Cross-audience attempts return the same response as an unknown alias (spec §37).
- Provider/operator scope comes from authenticated integration configuration, credentials, certificate or upload location, not a free-text payload field (ADR 0014).
- Support impersonation and cross-operator access are explicit, audited capabilities, not UI tricks.
- Portal UI hiding a nav item is not enforcement.
Capability gating by assurance level (A0-A3) is described in [linking-and-bootstrap.md](linking-and-bootstrap.md) and ADR 0020.
---
# Semantic / conventional commits
Source: https://docs-internal.playerpal.io/docs/commits
This repo uses [Conventional Commits](https://www.conventionalcommits.org/).
```text
():
[optional body]
[optional footers]
```
## Types
| Type | Use |
|---|---|
| `feat` | New user-facing capability |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `refactor` | Code change that is not a feat or fix |
| `test` | Tests only |
| `chore` | Tooling, deps, housekeeping |
| `ci` | CI config |
| `perf` | Performance |
| `build` | Build system |
| `revert` | Revert a prior commit |
## Scopes
Prefer one of: `api`, `portals`, `infra`, `auth`, `docs`, `tooling`, `demos`, `identity`, `linking`.
## Authorship
Author and committer are the human `user.name` / `user.email` only. Do not add
`Co-authored-by: Cursor`, `Co-authored-by: Claude`, or other agent trailers.
Cursor may inject one; the Husky `commit-msg` hook strips it before Commitlint.
## Enforcement
- Root `commitlint.config.cjs` + Husky `commit-msg` hook
- Install once from repo root: `npm install`
- Hook strips agent `Co-authored-by` trailers, then runs `commitlint`
## Examples
```text
feat(api): add hashed recovery codes and session scope
fix(api): resolve IAM before consuming a recovery code
docs(auth): document Nest-owned recovery codes
chore(tooling): add commitlint and husky
```
---
# Contributing to the docs
Source: https://docs-internal.playerpal.io/docs/contributing/docs
The site is Docusaurus over this `docs/` folder, plus an API reference that
Scalar renders from OpenAPI generated out of the API code
([ADR 0034](../decisions/0034-documentation-site.md)). Pages stay plain
Markdown, so they read the same on GitHub, on the site and to coding agents.
## Run it
```bash
task docs:dev # live preview of the internal site
task docs:build # internal build; fails on broken links or anchors
task docs:build:public # public build + scan of its output; fails if anything internal leaks
task api:openapi # regenerate the partner and internal OpenAPI documents
```
## Hosting
The internal site is on Cloudflare Pages (project `playerpal-docs-internal`)
behind Cloudflare Access. The `deploy-internal` job in
`.github/workflows/docs.yml` builds and deploys it on every push to `main`
that touches the docs, once the checks pass. PRs only build. For a manual
deploy, run `DOCS_SITE_URL= task docs:deploy`, which needs
`wrangler login` or `CLOUDFLARE_API_TOKEN`.
One-time setup:
1. Create the Pages project `playerpal-docs-internal` as a direct upload, not
Git-connected, and add the internal docs hostname as its custom domain.
2. Create an Access application covering **all three** of the custom domain,
`playerpal-docs-internal.pages.dev` and `*.playerpal-docs-internal.pages.dev`.
If the `pages.dev` hosts are left out, anyone can read the internal docs
there without logging in.
3. In the GitHub repo, add the secrets `CLOUDFLARE_API_TOKEN` (Account >
Cloudflare Pages > Edit) and `CLOUDFLARE_ACCOUNT_ID`, and the variable
`DOCS_INTERNAL_URL` (the custom domain URL).
To check the setup, run `curl -sI https://playerpal-docs-internal.pages.dev`.
It must redirect to `cloudflareaccess.com`, never return `200`.
The public site isn't deployed yet. It will be a second Pages project with
no Access, enabled in the same workflow once a founder signs off.
## Front matter
```yaml
---
title: Partner quickstart
description: One sentence for search and link previews.
status: implemented # implemented | in-progress | proposed (omit on mixed pages)
demo: true # only when the feature exists only as demo or mock code
audience: internal # internal (default) | public
verified: 53fd444 # commit the page was last checked against
---
```
On a page that mixes statuses, start each section with a status line:
```markdown
> **Status: In progress (demo only)** — evidence: [shopServer.test.ts](...)
```
## Rules
1. **Evidence or it did not happen.** Every behaviour claim links the code,
test, ADR or spec section it comes from. A page, mockup, seed or demo UI is
never enough to call something implemented.
2. **Status follows code.** Raise a status only in the PR that adds the code
or tests. Update `verified` when you re-check a page.
3. **Do not invent.** No pricing, legal conclusions, retention periods, SLAs or
decisions that the repo does not contain. Write an open question in
[open-questions.md](../open-questions.md) or propose an ADR instead.
4. **Fail closed.** Pages are internal unless marked `audience: public`, and a
founder reviews every page before it becomes public. Public pages never
contain hostnames, IPs, infrastructure vendors, credentials, seeded IDs or
links into the repository; the public build enforces the links part.
5. **Ship with the code.** A PR that changes behaviour or an endpoint updates
the page and runs `task api:openapi`. CI fails when the committed OpenAPI
files differ from the code, or when a KPI in the catalogue has no entry in
[Metric definitions](../analytics/metrics.mdx).
6. **Never hand-edit** `docs-site/static/openapi/partner.json` or `docs-site/static-internal/openapi/internal.json`. Change the
decorators or schemas in `services/api/src/openapi/` and regenerate.
7. **Links.** Use relative links to files. Links to files outside `docs/`
become GitHub links on the internal site.
## Working with AI coding tools
Agents read [docs/agents/docs.md](../agents/docs.md), linked from `AGENTS.md`. When an
agent drafts a page, ask it to list every claim it could not back with a file
path, and check those before merging. AI review is fine for internal pages;
public pages and changes to the partner API reference need a founder's review.
Commit scope is `docs`, or the code scope when the docs ship with code
([commits.md](../commits.md)).
## Where things live
| What | Source of truth |
|---|---|
| Endpoints, parameters, schemas, auth | Decorators in `services/api`, exported to OpenAPI |
| Integration prose, flows, examples | `docs/integrate/`, `docs/api/` |
| Metric definitions | [Metric definitions](../analytics/metrics.mdx), checked against the KPI catalogue |
| Decisions | [decisions/](../decisions/README.md) |
| Terminology | `CONTEXT.md` (rendered as the Glossary) |
| Identity and auth behaviour | The spec, which wins on conflict ([docs/README.md](../README.md)) |
---
# Writing style and page kit
Source: https://docs-internal.playerpal.io/docs/contributing/style
The site has three readers: **partners and prospects** (operators, game
providers, affiliates, reward partners), **investors and advisors**, and the
**internal team**. Public pages serve the first two and must be good enough to
send to a prospect as-is.
## Voice
- Plain, confident, specific. Second person for guides ("you send…"), third
person for product pages ("PlayerPal links…").
- Lead with what the reader gets, then how. One idea per paragraph, 1–4
sentences. Prefer tables, steps and diagrams to long prose.
- No hype words (revolutionary, seamless, cutting-edge, best-in-class), no
exclamation marks, no emoji in prose.
- British-neutral spelling as used in the repo (behaviour, authorisation is
fine either way; be consistent within a page). Use the glossary terms:
Player, Account, Operator, Game provider, Affiliate, Reward partner, Link,
Unlink, Audience, Alias, Activity, Points.
- Never invent product behaviour, pricing, commercial terms, SLAs, legal
conclusions, certifications, customer names or numbers. If the repo does not
say it, leave it out or say "contact PlayerPal" / "not yet decided".
## Status labels
Readers see three labels. Front matter keeps the precise value:
| Front matter `status` | Reader label | Meaning |
|---|---|---|
| `implemented` | Available | Built and tested in the platform |
| `in-progress` | In development | Being built; behaviour may change. Add `demo: true` if it exists only as demo code |
| `proposed` | Planned | Designed or decided, not built yet |
Inline: `[In development]`. On a page that mixes statuses, give
each section its own `` right under the heading. A mockup or demo
UI never makes something Available.
## Public pages
Front matter `audience: public`. In addition to the rules above:
1. **No links into the repository** (source files, ADRs, internal pages). The
public build fails on them. Link only to other public pages (relative
`.mdx` paths) or to the API reference at `/api/partner`.
2. **Keep evidence in MDX comments**, invisible to readers but reviewable:
``
under each claim-heavy section. HTML comments (``) do not work in
MDX.
3. **No internals**: no hostnames, IPs, cloud vendors, cluster or database
details, environment variable names, seeded or demo IDs, internal team
process, known bugs or code-level discrepancies. Describe behaviour and
guarantees, not implementation. Known gaps that affect partners are stated
as product status ("In development", "Planned"), not as bugs.
4. Examples use placeholders (`pp_kid_XXXX`, ``) and paths only
(`POST /v1/ingest`). If a full URL is unavoidable, use
`https://`. Never a real or made-up hostname.
## Page kit (MDX)
New pages are `.mdx`. These components are available on every page without
imports:
```mdx
- [Link players](./linking-players.mdx):
Connect a casino account to PlayerPal without sharing personal data.
### Get credentials
…
### Sign a request
…
**curl**
…code block…
**Node.js**
…code block…
[Available]
```
Admonitions: `:::note`, `:::tip`, `:::info`, `:::warning`, `:::danger`, with
an optional title: `:::info[In development]`. Diagrams: ` ```mermaid `
fences (flowchart, sequenceDiagram, stateDiagram-v2).
MDX pitfalls: a bare `<` or `{` in prose breaks the build. Write `<`, use
backticks (`` `a < b` ``), or put it in a code block. Leave a blank line
before and after JSX blocks and inside `` around code fences.
## Developer guide kit
Developer guides use a two-column layout: prose on the left, a sticky code
column on the right. Add `wide: true` and `hide_table_of_contents: true` to
the front matter, then:
```mdx
## Send your first batch
`ingestSubmit` (see the API reference)
Prose, lists and tables for this step.
(Parameter table: see the API reference.)
What the reader should see now.
(Code sample: send-batch. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{ "replayed": false }
```
```
- `Endpoint` and `Params` read the generated partner OpenAPI document by
`operationId`, so they always match the code. `Params` takes
`in="body" | "query" | "path" | "header" | "response"`.
- `Sample` shows a named region (`docs:start ` … `docs:end `) of
the files in `docs-site/samples/signing/` in six languages, with a language
choice remembered across the site. Those files run against the HMAC test
vectors in CI (`npm run check:samples`), so change code there, never inline.
- `Example` is for static payloads such as responses and errors; pass the
text as a template literal so MDX does not parse it.
- `Explainer` is a short animated walkthrough (scenes with play, pause and
scrub). Pick one with `id`: ``. The ids are in
`docs-site/src/components/explainer/registry.ts`. Each explainer is
hand-built in `explainers/`, so its values must match the sources its page
cites; the signing values live in `data.ts` and must match the test vectors.
Use one per concept, near the top of its page, and give it an evidence
comment like any other claim. Add scenes in the explainer's file. Each scene
is a pure function of its progress, so keep it that way.
## Page template
```mdx
---
title: Link players # short, task-shaped
description: One sentence that works as a search snippet.
sidebar_label: Linking players # optional
status: implemented
audience: public
verified:
---
# Link players
One-paragraph summary: what this does for the reader and when to use it.
## How it works (diagram or steps)
## Before you start (prerequisites)
## Step by step / Reference
## Errors and edge cases
## Related (2–4 cards or links)
```
Aim for 80–220 lines. Every public page ends with a short Related section.
---
# Data model
Source: https://docs-internal.playerpal.io/docs/data-model
Buildable DDL: TypeORM migrations in [`services/api/src/db/migrations/`](../services/api/src/db/migrations/) (ADR 0023). Historical SQL: [`schema/historical/`](../schema/historical/).
Demo seeds: [`schema/seeds/demo.sql`](../schema/seeds/demo.sql).
Conceptual identity and party model from spec §15 / §46.9 and ADRs 0001, 0013, 0014, 0026.
## Party (organisations)
Generic `party` with a type enum (ADR 0001): `PLAYER`, `OPERATOR`, `PROVIDER`, `AFFILIATE`, `MERCHANT`, `BRAND`, `APPLICATION`, plus integration. External IDs stay typed (`pp_opr_...`, `pp_prv_...`, `pp_int_...`), never `pp_party_...`.
## Player identity
Tables (TypeORM entities / baseline migration):
- `players`: private UUIDv7 `id` + `public_player_id` (`pp_plr_...`); optional `reservation_expires_at` while status=`reserved` (A0 pre-mint); `analytics_subject` (`pp_anl_...`) minted at activation; `anonymised_at` / status=`anonymised` after Delete Account
- `authentication_identities`: `issuer` + subject cipher + lookup HMAC (`UNIQUE(issuer, subject_lookup_hmac)`); opaque `iam_subject` (OIDC sub / ZITADEL user id) for password sign-in reverse lookup — not PII, never on `/v1/me`
- `player_aliases`: `pp_plr_opr_...` / `pp_plr_prv_...` (revoked aliases stay reserved)
- `external_identities`: partner-issued IDs
- `integrations`: provider × operator
- `identity_links`: consent / `effective_from` / `unlinked_at` / `revoked_at`
- `link_intents`: short-lived assertion exchange
- `player_registrations`: bootstrap state machine
- `player_sessions`: BFF cookie sessions; optional `iam_user_id` for signed-in factor add; optional `oidc_id_token` for logout handle only
- `webauthn_credentials`: public-key mirror for discoverable portal passkeys (no private keys)
- `player_account_merges`: dual-proof merge (UI later)
- `party_jwks` / `assertion_jti`: operator assertion verify + replay protection
Loyalty, Activity, and serving tables (TypeORM):
- `activity_batches` / `activity_uploads` / `activity_events` / `points_ledger` / `campaign_versions`
- Daily serving: `analytics_account_day`, `analytics_account_provider_day`, `analytics_partner_player_day` (provider Party + that provider’s `pp_plr_prv_...` + day), `analytics_studio_account_day` (provider Party + day, distinct Accounts)
The live ledger may reference `players.id`. Partner studio reads do not. After Delete Account, retained Activity keeps `round_ref` and analytics subject with `player_id` / `player_alias` nulled; partner-player-day rows for those aliases are deleted.
## HTTP API
NestJS control plane (`services/api`): `/auth/*`, `/v1/me`, `DELETE /v1/me/connections/:audienceId` (Unlink), `DELETE /v1/me` (Delete Account), `/v1/player-reservations`, `/v1/link-intents*`, `POST /v1/activity-batches`, `POST /v1/activity-uploads`, `POST /v1/activity-uploads/:uploadId/complete`, `GET /v1/analytics/studio`, `GET /v1/analytics/studio/aliases/:alias`. See [`infra/README.md`](../infra/README.md). Normative examples remain in spec §37.
---
# ADR 0001. Generic Party Model
Source: https://docs-internal.playerpal.io/docs/decisions/0001-generic-party-model
## Status
Accepted.
## Context
PlayerPal needs to represent operators, providers, affiliates, merchants
(reward partners), brands, applications, and configured integrations.
Modeling only "players" and "casinos" would not generalize beyond iGaming,
and would require a new table for every new organisation type.
An early draft also treated the player as a `party` row. Specialised player
tables (spec §15) made that redundant: loyalty identity, aliases, auth
mappings, and links do not fit the org/integration shape.
## Decision
Use one generic `parties` entity with a `party_type` enum for **network
organisations and integrations only**:
`OPERATOR`, `PROVIDER`, `AFFILIATE`, `MERCHANT`, `BRAND`, `APPLICATION`,
`INTEGRATION` (`pp_int_...`).
**Players are not parties.** Private loyalty identity lives in dedicated
tables (`players`, `player_aliases`, `authentication_identities`,
`identity_links`, …). Do not create a `parties` row for a player. The
`PLAYER` enum value is legacy and must not be used for new rows; remove it
from the schema when convenient.
Org/integration relationships (campaign ownership, integrations, external
IDs owned by a partner) reference `parties`. Player relationships reference
`players.id` (internal) or typed aliases (external).
## Consequences
- One reusable framework for partner/org entities.
- Externally, still expose **typed** IDs (`pp_opr_...`, `pp_prv_...`,
`pp_int_...`, …). See ADR 0002 / 0013 rather than a generic
`pp_party_...`.
- Adding a new organisation type is a new enum value, not a parallel
table family.
- See [docs/data-model.md](../data-model.md) and `CONTEXT.md`.
---
# ADR 0002. Random Pairwise External IDs (not derived from internal UUID)
Source: https://docs-internal.playerpal.io/docs/decisions/0002-random-pairwise-external-ids
## Status
Accepted for generation and encoding. Partner-facing **prefix** superseded by
[ADR 0013](0013-typed-pairwise-aliases.md): operators receive `pp_plr_opr_...`,
providers receive `pp_plr_prv_...`, and `pp_plr_...` is player-visible only.
Random 128-bit pairwise values, Crockford Base32, stored mapping, and rejection
of `Base32(internal UUID)` / HMAC derivation still stand. Aliases are generated
when the one-time link completes ([ADR 0009](0009-mandatory-one-time-link-before-data-flows.md)),
not at passive first exposure.
## Context
PlayerPal's core privacy requirement: no partner may correlate the same
player across other partners. Two generation strategies were evaluated:
1. Encode the internal UUID directly (`Base32(internal_uuid)`), or derive
the external ID deterministically via `HMAC(secret, internal_id + audience_id)`.
2. Generate a **fresh, unrelated random 128-bit value per (player, audience)
pair**, store the mapping, and Base32-encode it for external use.
Option 1's first variant (direct encoding) is reversible. Anyone holding
it can decode back to the same internal UUID, which is then a shared,
correlatable value across every partner. This would have violated the
privacy requirement outright and was caught before implementation.
The HMAC variant avoids exposing the UUID but introduces its own problems:
secret management becomes safety-critical, key rotation is hard, and a
mapping table ends up being useful anyway for audit/lifecycle purposes,
undercutting the main reason to derive rather than store.
## Decision
Use **random pairwise IDs**: a fresh cryptographically random 128-bit
value per `(subject_party_id, audience_party_id)` pair, generated at
first exposure, stored in `party_identifier`, and rendered externally as
`pp__`.
## Consequences
- Requires a stored mapping lookup (acceptable trade-off; also needed for
revocation/rotation and audit regardless).
- Strong unlinkability: no mathematical relationship between a player's
aliases across different audiences.
- Alias can be revoked/rotated per-audience without affecting others.
- Compromise of one audience's alias mapping does not reveal another
audience's alias for the same player.
- Internal IDs remain UUIDv7 (time-ordered, index-friendly, native DB
support); only the *external* representation uses random pairwise
values, never a transform of the internal ID.
---
# ADR 0003. Authenticate at a Boundary Point, Not Per-Action
Source: https://docs-internal.playerpal.io/docs/decisions/0003-session-jwt-at-boundary
## Status
Superseded by [ADR 0015](0015-async-loyalty-sidecar.md) (PlayerPal is off the
game path) and [ADR 0016](0016-bff-cookies-and-machine-oauth.md) (BFF cookies,
popup linking, machine OAuth).
The original "signed JWT at operator login for an embedded widget" model is
not current. Identity is still resolved at a control-plane boundary, not per
gameplay action. That part stands, but the token and transport design does not.
## Context
Gameplay actions can be extremely high frequency. Requiring PlayerPal
identity resolution (a DB lookup) on every action would put PlayerPal in
the critical path of gameplay, adding latency, cost, and a failure
coupling that shouldn't exist.
## Decision
Resolve/authenticate identity once, at a boundary point (operator login),
and issue a short-lived **signed JWT** carrying scoped claims. Both the
operator/app and the embedded PlayerPal widget can read it without a
fresh DB hit. The token stays small, avoids sensitive PII, and excludes
fast-changing state (e.g. live balance). That is fetched separately via
`GET /v1/me/loyalty`.
## Consequences
- No DB lookup per gameplay action; gameplay stays fast and decoupled
from PlayerPal's availability.
- Token content is a trade-off: richer claims reduce round-trips but
increase staleness risk and token size. Claim set is not yet finalized.
- Revocation is harder with self-contained JWTs than with server-side
sessions. Token lifetime needs to be short enough that this is
acceptable (still an open decision).
---
# ADR 0004. Async-First Ingestion (Control Plane vs. Data Plane)
Source: https://docs-internal.playerpal.io/docs/decisions/0004-async-first-ingestion
## Status
Accepted.
Tightened by spec §34 and [ADR 0015](0015-async-loyalty-sidecar.md): gameplay
must not call PlayerPal at all. Ingestion remains async-first. ACK after durable
acceptance (`202`). See [docs/ingestion-and-availability.md](../ingestion-and-availability.md).
## Context
The naive design validates, queries, calculates campaigns, updates points,
updates analytics, then responds. That puts a large amount of synchronous work
in the request path of every single activity event. At scale this is
both slow and expensive, and couples ingestion availability to the
reward engine's availability.
## Decision
Split the system into:
- **Control plane** (low frequency, session establishment, identity
resolution, token issuance, campaign/integration config): may touch
DB/cache directly.
- **Data plane** (high frequency, bets, rounds, transactions,
telemetry): the ingestion request does auth + minimal envelope
validation + durable append only, then responds 202. Everything else
(normalization, rule evaluation, ledger updates, analytics) happens
asynchronously off a durable event log.
Real-time and batch ingestion both normalize into **one shared event
model** feeding **one** reward engine, never two parallel engines.
## Consequences
- A reward-engine outage does not block activity acceptance.
- Ingestion API is stateless and trivially horizontally scalable.
- Requires idempotent processing downstream (see ADR 0008) since
durability here is at-least-once, not exactly-once.
- Requires an explicit choice on ACK timing (broker-confirmed vs.
local-append-then-forward). See the architecture brief §4.8 for the
trade-off; not a separate ADR since it's phase-dependent, not fixed.
---
# ADR 0005. Phased Event-Log Infrastructure
Source: https://docs-internal.playerpal.io/docs/decisions/0005-phased-event-log-infrastructure
## Status
Accepted.
Phase 2 is triggered by spec §35.4 measurements, not a calendar date:
sustained inbox write/worker saturation, independent consumers that need
replay, lag that must be isolated, material ordering/partitioning,
backfills competing with live ingest, or operational benefit that exceeds
running another stateful platform. Dagster is the initial batch/backfill
scheduler; Airflow is not the default (spec §35.1).
## Context
PlayerPal must be cheap to run at low volume and scale to high volume
without a redesign. Committing to Kafka/Redpanda on day one adds
operational overhead (cluster management, JVM footprint or equivalent,
monitoring, minimum viable cluster size) that isn't justified at low
traffic. By contrast, an ordinary application log file lacks replication,
consumer offsets, replay tooling, and backpressure handling. It isn't
a substitute for a real durable log at higher volume.
## Decision
Two-phase approach:
**Phase 1 (lean MVP):** `API -> Postgres append table -> workers`, or
for batch-heavy activity, `API -> object storage append/batches ->
workers`. Appropriate while volume is low/moderate and operational
simplicity matters most.
**Phase 2 (real event log):** `API -> Redpanda/Kafka-compatible log ->
reward worker / analytics writer / archival`. Move here when traffic
growth is sustained, more independent consumers appear, replay becomes
important, or Postgres ingestion becomes a bottleneck.
Redpanda is the leading Kafka-compatible candidate (C++, no JVM, no
ZooKeeper, generally lower operational overhead) but its licensing for
managed/enterprise tiers is **not confirmed free** and must be checked
against current terms before committing. Self-hosting vs. managed is
a real cost decision, not just a technical one.
## Consequences
- No premature complexity at low volume.
- Phase 2 starts when a §35.4 condition is observed in production metrics,
not when a date or guessed events/sec target arrives. Record the measured
evidence in a new ADR before introducing Redpanda. Confirm Redpanda
licensing at that time; it is still unchecked.
---
# ADR 0006. Large Batches via Object Storage + Manifest, Not One Giant HTTP Payload
Source: https://docs-internal.playerpal.io/docs/decisions/0006-batch-ingestion-via-object-storage
## Status
Accepted.
Inline batch endpoint in the current spec is `POST /v1/activity-batches`
(spec §37.6), not `POST /v1/event-batches`. Formats (NDJSON, NDJSON.gz,
Parquet, CSV via signed upload + manifest) still stand. There is no
`api/openapi.yaml` in the repository yet; cite spec §37.
## Context
Not every partner supports real-time integration; many will export
periodically (every few minutes to end-of-day). Sending millions of
records as one HTTP JSON payload is expensive and fragile (memory
pressure, timeout risk, poor retry semantics).
## Decision
Small/medium batches may be submitted inline via `POST /v1/event-batches`.
For very large exports: request a signed upload URL, upload compressed
NDJSON/Parquet/CSV directly to S3-compatible object storage, then submit
a manifest referencing that upload for asynchronous processing.
## Consequences
- Batch and real-time paths still converge on the same normalized event
model post-ingestion (see ADR 0004). This only changes how bytes
arrive, not how they're processed afterward.
- Requires manifest/checkpoint design and idempotent batch processing
(same `(source_party_id, source_event_id)` uniqueness as real-time).
- Exact accepted formats are fixed (NDJSON, NDJSON.gz, Parquet, CSV).
See `api/openapi.yaml`.
---
# ADR 0007. Versioned/Snapshotted Campaign Configuration
Source: https://docs-internal.playerpal.io/docs/decisions/0007-versioned-campaign-configuration
## Status
Accepted.
Spec §38 uses `campaign_rule_version_id` on ledger uniqueness. The snapshot
principle is unchanged. See [docs/loyalty.md](../loyalty.md).
## Context
Campaigns and promotion rules will be edited over time. If a
`reward_event` only references a mutable `campaign` row, past reward
decisions become inexplicable once the campaign is later edited. You
can no longer answer "why was this reward given" accurately.
## Decision
Introduce `campaign_version` as an explicit, immutable snapshot of a
campaign's configuration at a point in time (`valid_from`/`valid_to`).
`reward_event` references a specific `campaign_version_id`, never the
mutable `campaign` directly.
## Consequences
- Past reward decisions remain fully explainable/auditable regardless of
later campaign edits.
- Requires versioning discipline in the campaign-authoring tooling
(every meaningful edit creates a new version rather than mutating in
place).
- The same engine/schema shape works for both gameplay-triggered and
commerce-triggered rules (e.g. `wager >= 10 EUR` vs.
`purchase.total >= 100 EUR`). The domain model is intentionally
generic, not casino-specific.
---
# ADR 0008. At-Least-Once Delivery + Idempotent Processing (Not Exactly-Once)
Source: https://docs-internal.playerpal.io/docs/decisions/0008-at-least-once-plus-idempotency
## Status
Accepted.
Uniqueness keys follow spec §38.1, not the earlier `source_party_id` pairing:
```text
batch uniqueness: (integration_id, batch_id)
event uniqueness: (integration_id, event_id)
ledger uniqueness: (campaign_rule_version_id, activity_event_id, ledger_action)
```
Identical retry returns the original receipt. Reused ID with a different
checksum returns `409 idempotency_conflict`.
## Context
Exactly-once delivery is expensive to guarantee end-to-end and usually
unnecessary if processing is idempotent. Given async, multi-hop
processing (ingestion -> log -> reward worker -> ledger), some
duplicate delivery is expected in failure/retry scenarios.
## Decision
Accept at-least-once delivery. Enforce idempotency explicitly at two
levels:
- Ingestion: `UNIQUE (integration_id, event_id)` on activity events, plus
`UNIQUE (integration_id, batch_id)` on batches (spec §38.1).
- Reward/ledger side: `UNIQUE (campaign_rule_version_id, activity_event_id,
ledger_action)` so the same activity cannot apply the same ledger action
twice.
A duplicate delivery is treated as a successful no-op (identical retry
returns the original receipt; spec §38.1). It is not an error requiring
partner intervention.
## Consequences
- Simpler infrastructure than exactly-once semantics would require.
- Partners can safely retry on ambiguous failures (timeout, connection
drop) without risking duplicate rewards.
- Raw activity is corrected via adjustment/reversal/compensating events,
never by mutating or deleting history. See [docs/loyalty.md](../loyalty.md).
---
# ADR 0009. Mandatory One-Time Link Before Any Data Reaches PlayerPal
Source: https://docs-internal.playerpal.io/docs/decisions/0009-mandatory-one-time-link-before-data-flows
## Status
Accepted.
Link mechanics (signed assertion, PlayerPal-hosted popup, result-code
exchange) are [ADR 0017](0017-operator-assertion-popup-linking.md). Product
policy in spec §33 confirms no pre-registration shadow profiles and no
historical points by default.
## Context
A PlayerPal identity must not come into existence purely from passive
first-contact, i.e. the player never acting, never seeing PlayerPal,
data simply starting to flow. Two problems rule that out:
1. PlayerPal is a standalone product in its own right, not invisible
infrastructure fully hidden behind whichever party integrates it
(operator or provider). A player should be aware they have a
relationship with it.
2. Also, there was no point at which the player had agreed
to anything from PlayerPal, or even been told PlayerPal exists. Data
about them was being transmitted to and processed by a third party
with zero touchpoint. That is not a defensible legal-basis story, and
it doesn't match the intended product positioning either.
## Decision
No activity data for a given player is transmitted to or processed by
PlayerPal until that player has **registered or linked at least once**.
This is a one-time, per-`(player, audience)` action, not a per-session
login. Concretely:
- A `party_identifier` row (and any associated identity/ledger state) is
created only at the point this one-time link/registration completes,
not at "first exposure" as earlier phrasing in §2.3a implied.
- Before that link exists, PlayerPal has no record of the player and
processes nothing for them; that activity stays entirely inside the
audience's (operator's or provider's) own systems.
- After the one-time link, subsequent earning is automatic. The player
does **not** need to log into PlayerPal again on every visit or every
event. The friction goal from the original requirement is preserved at
the session/event level; it just no longer extends to eliminating the
touchpoint entirely.
- This one-time action is also the natural place to satisfy a lawful
basis for processing (consent/notice specific to PlayerPal). The
mechanics of what that touchpoint actually shows and who presents it
(the integrating party's own UI vs. a PlayerPal-hosted widget) are
still open, see `docs/open-questions.md`.
Linking establishes one `(player, audience)` relationship; it does not
by itself unify relationships across different audiences. A player could
in principle be linked independently at several operators and/or
providers, whichever parties integrate PlayerPal directly, without
ever unifying them. Whether and how a later link joins an existing
account instead of creating a new one is decided in ADR 0010.
## Consequences
- §1's non-negotiable requirement is reworded: earning requires no
*repeated* login, not no touchpoint at all.
- §2.3a's "generated at first exposure to that audience" is corrected to
"generated at the point the one-time link completes."
- §2.4's "a loyalty identity can exist before any explicit sign-up" is no
longer accurate and is removed.
- §4 (ingestion) gains an implicit precondition: an event for a
`(player, audience)` pair that has never linked has nowhere to be
attributed and is out of scope for PlayerPal. It is the integrating
party's (operator's or provider's) event, not PlayerPal's, until linked.
- New open question: the concrete UX/consent mechanics of the one-time
link flow (surface, content, timing, who presents it) are undefined.
---
# ADR 0010. Linking to an Existing PlayerPal Account Unifies Relationships
Source: https://docs-internal.playerpal.io/docs/decisions/0010-linking-unifies-no-separate-claim
## Status
Accepted.
Unification-at-link still stands. A richer optional profile **must not** use
email, name or telephone ([ADR 0012](0012-never-receive-player-pii.md)).
Account discovery without email is [ADR 0019](0019-scoped-bootstrap-proofs-controlled-merge.md).
Assurance gating is [ADR 0020](0020-assurance-levels-provisional-password.md).
## Context
ADR 0009 makes linking mandatory at every audience. A player cannot
earn anywhere without a one-time link/registration, and that's already
the point where a PlayerPal identity is created. Given that, a separate
"claim" concept for cross-audience unification would be redundant: the
player is already taking an explicit registration-like action at every
audience, so there's no reason unification should need a further,
distinct action rather than happening as part of linking itself.
## Decision
There is one account-creation event, not two:
- The **first time** a player links to any audience, PlayerPal creates
their account.
- At **every subsequent audience**, linking means the player either:
1. **signs in as that existing PlayerPal account** (proving it's the
same person), in which case the new audience relationship is
unified with all previously-linked ones immediately, with no
further "claim" step. Linking *is* the unification event; or
2. **does not identify as an existing user** (declines, forgets, or
the flow doesn't surface the option), in which case a new,
separate, independent account is created for that audience, which
is an expected outcome of player choice/behavior, not a bug.
"Claiming" as a distinct, later, richer-account concept is retired.
There is just one PlayerPal account, created at first link, which
accumulates additional linked audiences over time whenever the player
signs into it again.
This does not remove the option of a **richer optional profile**
(username/password/email, self-service redemption, direct login to a
PlayerPal-branded portal outside of any operator/provider session). That
remains a valid optional enhancement to an existing account, but it is no
longer what triggers unification. Unification is now purely a function of
linking a new audience to an account that already exists.
## Consequences
- `architecture-brief.md` §1, §2.3, §2.3c, and §2.4 are reworded to drop
the "claim" framing and describe linking-to-existing-account as the
unification mechanism.
- New open question: the "sign in as existing PlayerPal account" mechanic
at a second+ audience is undefined. Does the player enter an
email/phone that PlayerPal verifies, get redirected through a
PlayerPal-hosted auth page, scan a code, something else? This
determines how much friction unification actually costs versus staying
as fragmented, independent accounts. See `docs/open-questions.md`.
- A player who never signs into an existing account at subsequent
audiences simply ends up with multiple independent PlayerPal accounts.
This is a legitimate, expected state, not an error case to design
around.
---
# ADR 0011. IAM Authenticates; PlayerPal Owns the Identity Graph
Source: https://docs-internal.playerpal.io/docs/decisions/0011-iam-authenticates-playerpal-owns-identity
## Status
Accepted.
## Context
PlayerPal needs passkeys, social OIDC, MFA, enterprise SSO and machine OAuth.
Implementing those protocols in product code is expensive and fragile. Using
the IAM user record as the PlayerPal player would couple loyalty, aliases and
consent to a vendor schema, force an IAM lookup on gameplay events, and make
email-shaped IAM profiles hard to avoid.
## Decision
Use a mature IAM platform (Keycloak initially, ZITADEL evaluated) for
authentication protocols, credentials, federation, sessions and token issuance.
PlayerPal remains authoritative for:
- private `pp_plr_...` players
- mapping `iss + sub` to players
- pairwise aliases, consent, link/unlink/merge
- loyalty ownership, analytics and business authorization
The IAM subject is not the PlayerPal player. Product records reference
PlayerPal player IDs, not IAM database IDs. IAM integration sits behind an
adapter.
Keycloak versus ZITADEL is a proof of concept, not this ADR. The POC must
satisfy spec §23.2 (passkey without email, filtered social claims, exportable
mappings, separate admin/player policies).
## Consequences
- IAM outages must not block already accepted ingestion or gameplay.
- Loyalty, aliases and gameplay facts must not be stored as IAM attributes,
groups or roles.
- Switching IAM vendor later is possible if mappings keep issuer and subject
semantics.
- See [docs/authentication.md](../authentication.md) and spec §5-§6.
## Amendment
Local/dev POC IAM is **ZITADEL** ([ADR 0021](0021-nestjs-zitadel-local-poc.md)).
Production vendor choice remains the §23.2 checklist, not this ADR.
---
# ADR 0012. Never Receive or Store Player Email, Name or Telephone
Source: https://docs-internal.playerpal.io/docs/decisions/0012-never-receive-player-pii
## Status
Accepted.
Resolves spec §33.1. Tightens the earlier "do not store email" product rule
to **no receipt** into PlayerPal-controlled systems, not merely no persistence.
## Context
Operators will not adopt a loyalty network that becomes a second copy of their
player PII. PlayerPal's privacy position is that it does not collect player
email, name or telephone. Workforce identity (staff and partner admins) still
needs business email for SSO and audit. Those are different populations.
Spec §29 asked whether "must not store email" meant no persistence or no
receipt anywhere. §33 answers: infrastructure must never receive or store it.
## Decision
PlayerPal-controlled persistence, tokens, logs, traces, events and analytics
must never contain player email, name or telephone number.
Prohibited claims are removed at the social/IAM boundary before they reach
PlayerPal. Player OIDC clients request `openid` only. Automatic account
linking by matching email is disabled.
If a social provider emits email regardless of scopes, product/legal/security
must decide whether transient receipt plus immediate filtering complies. If
PlayerPal may never receive email at all, that provider/configuration is not
enabled.
Workforce business email is allowed only in the separate workforce identity
domain (ADR 0018 portals, spec §7).
Email magic links and SMS OTP are excluded as player authentication methods.
## Consequences
- Recovery cannot use email or SMS. Use passkeys, recovery codes, existing
devices, or a later operator-assisted design ([ADR 0028](0028-v1-recovery-without-operator-assist.md)).
- Account discovery cannot offer email/phone lookup (ADR 0019).
- [ADR 0010](0010-linking-unifies-no-separate-claim.md) must not be read as
allowing an email-based richer profile.
- Google on the local ZITADEL IdP uses `openid` only; Nest keeps `iss` +
`sub` and drops IdP `rawInformation` before any PlayerPal write. Transient
extra claims plus immediate drop is the local path; those claims must still
never persist. If a future live capture shows PlayerPal logs, cookies, or
DB receiving email, disable that IdP until the config changes.
## Amendment
ZITADEL User API create may require a **synthetic verified placeholder email**
on the IAM side only. PlayerPal never persists it in DB, tokens, cookies, or
logs ([ADR 0024](0024-nest-owned-portal-auth.md)). Real player email/name/phone
remain forbidden.
---
# ADR 0013. Typed Pairwise Aliases (`pp_plr_`, `pp_plr_opr_`, `pp_plr_prv_`, …)
Source: https://docs-internal.playerpal.io/docs/decisions/0013-typed-pairwise-aliases
## Status
Accepted.
Supersedes the partner-facing prefix in [ADR 0002](0002-random-pairwise-external-ids.md).
Random 128-bit generation, Crockford Base32 and stored mappings still apply.
## Context
Early ID architecture used `pp_plr_...` as the external alias given to every
audience. That made the player-visible PlayerPal ID indistinguishable from a
partner handle and invited sending `pp_plr_...` to operators. Party IDs such
as `pp_opr_...` were also sometimes mistaken for player aliases.
Alias audiences beyond operator and provider (affiliate, application, reward
partner) need distinguishable prefixes before they are implemented, so logs
and APIs do not invent ad-hoc strings later.
## Decision
- Internal primary keys are UUIDv7 and are never exposed.
- `pp_plr_...` is the player-visible PlayerPal identity. Player and PlayerPal
only. Never sent to an operator, provider, affiliate, application, or
reward partner.
- Reserved **player-alias** prefixes (audience-scoped):
- `pp_plr_opr_...`, operator (active)
- `pp_plr_prv_...`, provider (active; integration-scoped by default; ADR 0014)
- `pp_plr_aff_...`, affiliate (reserved; audience entity undecided)
- `pp_plr_rwd_...`, reward partner (reserved; audience entity undecided)
- `pp_plr_app_...`, application (reserved only; not an active alias audience yet)
- Brand is a party type only, never an alias audience.
- `pp_opr_...`, `pp_prv_...`, `pp_aff_...`, `pp_int_...` (and other party
prefixes) identify parties or integrations. They are not player aliases.
Aliases remain cryptographically random 128-bit values, Crockford Base32
encoded, unique per `(audience_id, external_alias)` and one active alias per
`(player_id, audience_id)`. They are created at completed link/consent for
that audience relationship, not at passive first exposure. Do not mint
affiliate / reward-partner aliases until their audience entity is decided.
## Consequences
- Partner APIs and launch payloads must never echo `pp_plr_...`.
- Logs and support tooling can tell player-visible IDs from partner aliases
by prefix.
- Encoding detail: [docs/identity.md](../identity.md). Spec §4 and §30.
Glossary: `CONTEXT.md`.
---
# ADR 0014. Provider Aliases Are Provider-Operator Scoped
Source: https://docs-internal.playerpal.io/docs/decisions/0014-provider-operator-alias-scope
## Status
Accepted.
Supersedes the earlier open-question resolution that "no third scope type is
needed." Default: a provider-global player alias is not permitted.
## Context
A provider may serve many operators. If PlayerPal issued one `pp_plr_prv_...`
per provider, that provider could correlate the same person across casinos.
An untrusted payload field claiming an operator would let a provider submit
another audience's activity.
## Decision
Each provider player alias is scoped to one configured provider-operator
integration (`pp_int_...`). The same player at Provider A × Operator A and
Provider A × Operator B receives two different `pp_plr_prv_...` values.
Provider/operator scope is taken from authenticated integration configuration,
credentials, certificate or upload location, not from a free-text payload
field alone.
Cross-audience alias use returns the same response as an unknown alias.
Provider-global aliases are an explicit **contract exception** only; the
default and implementation posture remain integration-scoped. Do not mint
or resolve a provider-global alias without that exception recorded for the
provider.
**Current exception list: none.** A named provider may be added only by a
new ADR plus an explicit contract. A provider-global **cohort** is a
different decision ([ADR 0026](0026-analytics-subject-and-provider-cohort.md)).
## Consequences
- Link-result exchange can return requested provider aliases for integrations
already authorized for that operator (spec §37.4).
- Ingestion resolves aliases only inside the authenticated integration's
audience (spec §12).
- See [docs/identity.md](../identity.md) and spec §11.
---
# ADR 0015. PlayerPal Is an Async Loyalty Sidecar
Source: https://docs-internal.playerpal.io/docs/decisions/0015-async-loyalty-sidecar
## Status
Accepted.
Together with [ADR 0016](0016-bff-cookies-and-machine-oauth.md), supersedes
[ADR 0003](0003-session-jwt-at-boundary.md) for gameplay. Strengthens
[ADR 0004](0004-async-first-ingestion.md).
## Context
If PlayerPal sits in game launch, wager or payout, an identity outage becomes
a casino outage. Partners will not accept that. Loyalty can be delayed;
settlement cannot.
## Decision
If every PlayerPal service is unavailable, the operator and provider must
still authenticate the player, launch the game, accept bets, settle rounds
and pay winnings.
- No PlayerPal call before or during game launch.
- No PlayerPal call per wager or round.
- The provider receives `pp_plr_prv_...` in the operator's existing launch
metadata (provisioned at link time).
- Activity is written to a local outbox and submitted in asynchronous batches.
- Overlay/status reads are optional and must fail open with respect to play.
Interactive linking may fail; the game remains usable.
## Consequences
- Token refresh is per integration worker (minutes), not per player.
- IAM outages must not invalidate already accepted batches.
- Interactive/core and ingestion scale independently (spec §41).
- See [docs/ingestion-and-availability.md](../ingestion-and-availability.md)
and spec §34.
---
# ADR 0016. BFF Cookie Sessions and Machine OAuth
Source: https://docs-internal.playerpal.io/docs/decisions/0016-bff-cookies-and-machine-oauth
## Status
Accepted.
Supersedes [ADR 0003](0003-session-jwt-at-boundary.md) (signed JWT at operator
login for an embedded widget). Gameplay independence is [ADR 0015](0015-async-loyalty-sidecar.md).
## Context
A partner-readable session JWT on every game action would put PlayerPal
claims in the hot path, encourage putting PII or live balance in tokens, and
depend on third-party cookies for widgets. Browser `localStorage` refresh
tokens are a theft target.
## Decision
**Player web:** Authorization Code + PKCE through a backend-for-frontend.
Session is an `HttpOnly`, `Secure`, `SameSite` cookie. The browser token, if
any, carries the IAM subject, not the private player UUID and not live
loyalty state. Mapping `iss + sub` → player is server-side.
**Embedded operator/provider UX:** first-party PlayerPal popup, opaque
one-time codes, exact `postMessage` origin checks. No assertion or access
token in URLs. No third-party cookies. No refresh tokens in the widget.
**Machine clients:** OAuth 2.0 client credentials with `private_key_jwt`.
Access-token lifetime 5-15 minutes. Cached JWKS for validation. Key overlap
during rotation.
## Consequences
- Claim-set and token-lifetime open questions from ADR 0003 are closed for
the profiles above (spec §13).
- Embedded widgets cannot silently keep a PlayerPal session after popup close.
- See [docs/authentication.md](../authentication.md) and spec §14.
## Amendment
Local POC also establishes the BFF cookie via Nest Session/User API password
and passkey flows and Nest-owned social IdP success
([ADR 0024](0024-nest-owned-portal-auth.md)), not only Auth Code + PKCE through
Login V2. Cookies must be first-party on the portal host
(`player.localhost` path-routed Nest).
---
# ADR 0017. Operator Linking via Signed Assertion and PlayerPal Popup
Source: https://docs-internal.playerpal.io/docs/decisions/0017-operator-assertion-popup-linking
## Status
Accepted.
Supplies the link-flow mechanics left open under
[ADR 0009](0009-mandatory-one-time-link-before-data-flows.md).
## Context
The player must consent to PlayerPal on a PlayerPal-controlled surface. The
operator must prove which local player is connecting without sending PII or
putting secrets in query strings. After consent, the operator needs its
alias (and requested provider aliases) without a per-launch PlayerPal call.
## Decision
1. The operator (or provider) calls `POST /v1/link-intents` with a short-lived
signed assertion in the POST body (lifetime about five minutes; replay
protected `jti`/nonce; no email/name/phone claims).
2. PlayerPal returns a `link_url` containing only an opaque one-time code.
3. The player completes authentication and consent on a first-party PlayerPal
page (popup or redirect). Results return to the embedder via origin-checked
`postMessage`.
4. The operator exchanges a one-time result code (`POST /v1/link-results/exchange`)
and receives `pp_plr_opr_...` plus requested `pp_plr_prv_...` values for
integrations already authorized for that operator.
`requested_provider_integrations` does not grant scope. Partner assertion
proves context; it is not a permanent player credential.
HTTP examples: spec §10 and §37.3-§37.5.
## Consequences
- The earlier open question on "where the one-time link surface lives" is
closed: PlayerPal-hosted ceremony, operator-triggered intent.
- Game launch does not call PlayerPal (ADR 0015).
- See [docs/linking-and-bootstrap.md](../linking-and-bootstrap.md).
---
# ADR 0018. URL-Isolated Portals, No In-Product Role Picker
Source: https://docs-internal.playerpal.io/docs/decisions/0018-url-isolated-portals
## Status
Accepted.
## Context
A single back office with a role dropdown implies the user chooses their
security domain. That is the wrong boundary: authentication and authorization
should determine what a principal may do inside a portal they were sent to.
Mixing player, partner and staff sessions in one origin also collides cookies
and OAuth clients.
## Decision
Each audience is a separate URL (or path that is treated as a separate
security audience), with its own OAuth client, redirect URIs, session cookie
and permission policy:
| Audience | Example |
|---|---|
| Player | `my.playerpal.io` |
| PlayerPal staff | `admin.playerpal.io` |
| Operator | `partners.playerpal.io/operator` |
| Provider | `partners.playerpal.io/provider` |
| Affiliate | `partners.playerpal.io/affiliate` |
There is no role selector inside the product. The wrong portal returns an
access error, not a switcher. One person with several roles uses several URLs.
UI visibility is not authorization. APIs enforce tenant, audience and
permission (spec §17).
## Consequences
- Demo apps under `demos/apps/portals` fix audience by path.
- Shared UI packages are allowed; shared session cookies across player/staff/
partner origins are not.
- See [docs/portals.md](../portals.md).
---
# ADR 0019. Scoped Bootstrap Proofs and Controlled Merge
Source: https://docs-internal.playerpal.io/docs/decisions/0019-scoped-bootstrap-proofs-controlled-merge
## Status
Accepted.
Closes the "sign in as existing PlayerPal account" mechanics left open under
[ADR 0010](0010-linking-unifies-no-separate-claim.md).
## Context
Without email, name or government ID, PlayerPal cannot guarantee one natural
person equals one account. Silent merge on IP, device or timing would be both
a privacy violation and an account-takeover path. Retrying registration must
not create duplicate players for the same trusted proof.
## Decision
Prevent accidental duplicates and same-proof double registration.
Raise the cost of abuse. Provide an audited correction path. This is
Sybil-resistance, not civil-identity deduplication.
- Repeatable proofs (social `iss + sub`, operator assertion subject,
integration-scoped provider subject) map through a non-reversible
issuer-scoped lookup key. An active proof cannot map to two players.
- Account discovery offers passkey, approved social, PlayerPal ID + password,
existing-device QR, or recovery code. Never email/phone lookup. Uniform
errors; do not reveal whether a PlayerPal ID exists.
- IP, device fingerprint, timing, gameplay or profile similarity must never
become durable linking keys or automatic merge criteria.
- Merge requires recent independent proof of **both** accounts, a preview,
programme reconciliation, immutable ledger with compensating entries, and
session invalidation. Unresolved value or compliance conflicts block merge.
Fragmented independent accounts (player does not sign in at a later audience)
remain an expected outcome of ADR 0010.
## Consequences
- Registration is an idempotent state machine (spec §46.3).
- Password-only accounts are too weak to treat as unique persons (ADR 0020).
- See [docs/linking-and-bootstrap.md](../linking-and-bootstrap.md) and spec §46.
---
# ADR 0020. Assurance Levels A0-A3; Password-Only Is Provisional
Source: https://docs-internal.playerpal.io/docs/decisions/0020-assurance-levels-provisional-password
## Status
Accepted.
**Amendment (ADR 0022 / 0024):** PlayerPal ID + password **is offered** on the
player portal (provisional A0). Create and sign-in are Nest-owned
([ADR 0024](0024-nest-owned-portal-auth.md)), not an open product question.
Whether PlayerPal ID + password is offered at all was open when this ADR was
written; **if** offered, the table below still constrains it.
## Context
A `pp_plr_...` identifier does not exist before registration, so it cannot be
the first proof of a unique person. Password knowledge also does not establish
uniqueness. Treating password-only signup as a full loyalty account would let
automated accounts redeem, transfer or claim acquisition bonuses.
## Decision
Passkey (WebAuthn) is the default bootstrap. Approved social OIDC is supported
after a no-PII POC. Partner assertion plus passkey is the preferred embedded
flow.
If PlayerPal ID + password is offered, the account is **provisional**:
| Level | Minimum proof | Economic capabilities |
|---|---|---|
| `A0_PASSWORD_ONLY` | PlayerPal ID + password | Session, add credential, connect partner. No redeem, transfer, or acquisition bonus |
| `A1_PLAYER_CONTROLLED` | Passkey or approved social | Normal access; policy may step up high-value actions |
| `A2_PARTNER_CONFIRMED` | A1 plus valid partner relationship | Earn from that integration; standard redemption per programme |
| `A3_CORROBORATED` | Multiple independent trusted proofs | Higher-value actions where policy permits |
Assurance is not a permanent badge. Revocation, exclusion or partner
disconnect can reduce capabilities. Authorization evaluates level, action,
programme, jurisdiction, risk and partner scope at execution time.
Player-chosen usernames are not in the initial product.
## Consequences
- Upgrade proofs change capabilities without changing `pp_plr_...`.
- Password reset cannot use email or SMS (ADR 0012).
- See spec §45.8, §46.4 and [docs/linking-and-bootstrap.md](../linking-and-bootstrap.md).
---
# ADR 0021. NestJS Control Plane + ZITADEL Local POC (Google IdP)
Source: https://docs-internal.playerpal.io/docs/decisions/0021-nestjs-zitadel-local-poc
## Status
Accepted.
## Context
PlayerPal needs a real local vertical: IAM, Google social login, Postgres identity
graph, BFF sessions, and link-intent. The team's preferred backend split is:
- NestJS for main business logic, CRUD, and the interactive control plane
- Go for small, parallel, latency-sensitive data-plane workers later
ZITADEL publishes a NestJS PKCE example
(https://zitadel.com/docs/sdk-examples/nestjs). Spec §35 suggested Go for the
core API; that remains appropriate for ingestion workers, but the control plane
aligns better with NestJS next to the React portals.
Open question #1 (Keycloak vs ZITADEL) needs a POC vehicle for local/dev.
## Decision
1. Use **ZITADEL** as the local/dev IAM POC (Google configured as a ZITADEL IdP).
2. Implement the PlayerPal **control plane** as NestJS (`services/api`):
Auth Code + PKCE, HttpOnly session cookies, `iss+sub` → `pp_plr_...`,
link-intent stubs. Follow ZITADEL's NestJS/PKCE pattern; request `openid` only.
3. Keep **PostgreSQL** (`playerpal-db`) as the PlayerPal system of record for
players, aliases, and links, separate from ZITADEL's DB. **DDL ownership**
is TypeORM migrations in Nest (ADR 0023), not hand-maintained `schema.sql`.
4. Reserve Go (`services/worker`) for future ingestion/loyalty workers.
5. Never persist player email, name, or phone (ADR 0012). Strip prohibited claims
before any PlayerPal write.
6. Treat passkey, Google, and provisional PlayerPal ID as first-class portal
entry points into hosted login (`prompt=login` / `prompt=create`). PlayerPal
does not implement WebAuthn credential storage; RP-initiated logout clears
the IAM session after the PlayerPal cookie.
Keycloak remains a valid alternative if the ZITADEL POC fails spec §23.2.
## Amendment (ADR 0023 / 0024)
Item 6 is superseded for the local player portal: password/passkey and social
create are Nest-owned; a public-key mirror may be stored for discoverable
passkeys; logout clears the PlayerPal cookie only. See
[ADR 0024](0024-nest-owned-portal-auth.md). DDL path is TypeORM
([ADR 0023](0023-typeorm-data-layer.md)).
## Consequences
- Local runbook: `infra/README.md` (login policy, Google IdP id, passkeys).
- Production IAM pick is still gated on the §23.2 checklist, not this ADR alone.
- Data-plane scale path stays open for Go without rewriting NestJS identity CRUD.
- Google OAuth redirect URI is ZITADEL `/idps/callback`, not the NestJS app.
- Silent SSO off a leftover console session is not acceptable for the player portal.
---
# ADR 0022. Official ZITADEL Login App Fork for Portal Sign-In
Source: https://docs-internal.playerpal.io/docs/decisions/0022-official-login-app-fork
## Status
Accepted.
## Context
Portal sign-in must feel like PlayerPal, not stock ZITADEL. Console branding
cannot change layout or hide email-centric paths enough for player rules
(ADR 0012, ADR 0020, ADR 0021). Building a login UI from scratch against the
Session API duplicates what ZITADEL already ships as MIT `apps/login`. The
community `zitadel-login-ui` is useful as visual inspiration only: it is not
an official product, documents `IAM_OWNER`, and is unverified against Login V2
finalize / OIDC proxy requirements.
All portals (player, staff, operator, provider, affiliate) will eventually
need a custom login look. Starting with five skins or five deploys is premature
while only the player portal has real Nest + ZITADEL auth.
Password-on-first-signup conflicts with a naive reading of ADR 0020: a
`pp_plr_...` does not exist before registration, and player-chosen usernames
are out. Cold create still needs a non-email loginname in IAM.
## Decision
1. Customize portal login by **forking the official ZITADEL Login V2 app**
(`apps/login`), not by console-only branding, not by build-your-own Session
UI, and not by adopting community `zitadel-login-ui` as the product.
2. Keep Nest as BFF (Auth Code + PKCE, `openid` only, `iss+sub` → player).
The Login app stays on the **same public origin** as the ZITADEL API
(POC: Traefik `/ui/v2/login` on `localhost:8080`). PlayerPal does not store
WebAuthn credentials.
3. **v1 scope:** one fork, one deploy, **player-portal look**. Per-portal
skins and method sets come later; do not split merge trains yet.
4. Community login UI may inform layout and polish only. Service principal
remains `IAM_LOGIN_CLIENT`, not `IAM_OWNER`.
5. Player create on the fork must support **passkey**, **Google**, and
**provisional PlayerPal ID + password on the first signup screen**. For
password create: PlayerPal **pre-mints** `pp_plr_...`, IAM uses that value
as the loginname (never email, never a player-chosen username), Nest binds
`iss+sub` to that row, assurance starts at **A0** (ADR 0020) until a
stronger proof is added. No email verification on the player path;
`EMAIL_VERIFICATION` stays off for players.
6. ZITADEL + this fork remain **contingent** on ADR 0021 / §23.2. Kill the
fork path if the IAM checklist fails, player flows cannot stay email-free,
or upstream Login merges cost more than we will budget (default signal:
about five engineer-days per quarter). Local passkeys are disposable;
durable enrollments only on the permanent auth host.
## Consequences
- Infra will replace or overlay `ghcr.io/zitadel/zitadel-login` with the
forked image; upstream sync of login + `@zitadel/client` / proto is an
ongoing cost (see ZITADEL fork-and-deploy docs).
- `docs/authentication.md` and the local runbook should describe the forked
Login entry, player-portal chrome, and pre-minted `pp_plr_...` password
create.
- Later per-portal looks should prefer one fork / one merge train with
client- or path-selected themes, not independent forks.
- Open question “whether PlayerPal ID + password is offered” is **yes** for
player portal signup, under ADR 0020 provisional rules plus pre-mint here.
- Does not by itself close production IAM vendor choice (§23.2).
## Amendment (ADR 0024)
For the local player portal, password create/sign-in and passkey ceremonies
are Nest Session/User API paths, not “on the Login V2 first screen.” Nest may
store a WebAuthn **public-key** mirror for discoverable login. The Login V2
fork remains optional for skins/experiments; authoritative player
password/passkey UX is [ADR 0024](0024-nest-owned-portal-auth.md).
---
# ADR 0023. TypeORM Owns Nest Data Access and DDL
Source: https://docs-internal.playerpal.io/docs/decisions/0023-typeorm-data-layer
## Status
Accepted.
## Context
The Nest control plane used raw `pg` via `DbService` and hand-maintained
`schema/schema.sql` + numbered SQL migrations. Applying schema changes required
manual `psql`. ADR 0021 named `schema/schema.sql` as the system-of-record DDL
path; that conflicts with wanting Nest-owned migrations and typed repositories.
## Decision
1. Use **TypeORM** (`@nestjs/typeorm`) as the Nest data layer for PlayerPal
Postgres: entities + repositories / `EntityManager`, `synchronize: false`.
2. **TypeORM migrations** under `services/api/src/db/migrations/` are the
canonical DDL for `playerpal-db`. Fresh volumes start empty; the API
entrypoint (or `task db:migrate`) runs migrations before serving.
3. Move former `schema/schema.sql` and `schema/migrations/*.sql` to
`schema/historical/` (do not extend). Keep `schema/seeds/demo.sql` for
`task db:seed`.
4. Do not put TypeORM in the Go worker; workers may consume SQL dumps later.
5. Never store player email, name, or phone (ADR 0012). Opaque IAM subjects
(`iam_subject`, session `iam_user_id`) are not PII and must not appear on
`/v1/me`.
## Consequences
- Local: `task db:migrate` then `task db:seed` (or rebuild API so entrypoint migrates).
- Existing volumes: prefer `task down:volumes` once, or rely on idempotent
baseline `CREATE IF NOT EXISTS` + EnsureColumns migration.
- ADR 0021 item 3 is amended: Postgres remains the system of record; DDL path
is TypeORM, not `schema/schema.sql`.
---
# ADR 0024. Nest-Owned Portal Auth (Session API + Social IdP Intents)
Source: https://docs-internal.playerpal.io/docs/decisions/0024-nest-owned-portal-auth
## Status
Accepted.
## Context
ADR 0022 chose a Login V2 fork for branded portal sign-in. In the local POC,
password and passkey on Login V2 conflicted with player rules (no email forms,
PlayerPal ID as username, RP id on `player.localhost`) and with Session API
limits (CreateSession cannot challenge without a user id — zitadel#8899).
Social still needs ZITADEL IdP intents; password/passkey need to stay on the
portal card. Same-origin Nest under `player.localhost` is required so the
BFF session cookie is first-party after Google return (ADR 0016).
## Decision
1. **Password and passkey** for the player portal are owned by Nest against
ZITADEL User + Session APIs (`POST /auth/password-*`, `/auth/passkey/*`,
`/auth/factors/*`). Login V2 is **not** the password/passkey UX.
2. **Social** (Google / Apple / Facebook) starts from Nest (`GET /auth/login`
or IdP intent → `/auth/idp/success`). Creates/links an IAM human whose
**username is `pp_plr_…`**, throwaway password the player never sees, then
issues the PlayerPal BFF cookie. No Login V2 email form (ADR 0012).
3. Store a **public-key mirror** in `webauthn_credentials` for discoverable
(usernameless) passkey login on the portal RP. Never store private keys
or player PII. Identifier-first passkey may still use Session API with
`userId`.
4. Persist opaque IAM subject as `authentication_identities.iam_subject` for
password sign-in (local lookup first; ListUsers by username only when
missing). Keep `player_sessions.iam_user_id` for signed-in factor add.
Neither is player PII; neither appears on `/v1/me`.
5. **Logout** clears the PlayerPal cookie only (no ZITADEL `end_session` hop
that parks on Login V2).
6. Login V2 fork (ADR 0022) remains available for experiments / future skins;
it is **not** the authoritative player password/passkey path for this POC.
## Consequences
- Supersedes ADR 0021 item 6 and ADR 0022 items 2 and 5 for the local player
portal auth path (see amendment notes on those ADRs).
- WebAuthn RP is the portal host (`WEBAUTHN_RP_DOMAIN` / `APP_BASE_URL`, e.g.
`player.localhost`), not `localhost` Login V2.
- Day-to-day docs: [docs/authentication.md](../authentication.md),
[infra/README.md](../../infra/README.md).
- Does not close production IAM vendor choice (§23.2).
---
# ADR 0025. Provider Launch Sessions and PlayerPal Widget Distribution
Source: https://docs-internal.playerpal.io/docs/decisions/0025-provider-launch-sessions-and-widget-distribution
## Status
Accepted.
## Context
ADR 0015 keeps PlayerPal out of game launch and settlement, while ADR 0017
defines operator-led linking. A realistic provider integration still needs to
bootstrap a cross-origin game, optionally present PlayerPal inside the
provider-owned experience, and support both operator-led and provider-led
adoption without making browser payloads authoritative.
Copying the widget into a provider build makes its provenance and upgrade path
provider-controlled. Passing launch credentials in an iframe URL leaks them
through browser history, logs and referrers. Letting a provider call an
operator to perform linking also reverses the intended ownership boundary.
## Decision
1. The operator creates a short-lived **Provider launch session** through the
provider's authenticated backend API. PlayerPal is not called in this path.
2. Browser bootstrap uses a single-use, expiring POST body. Access tokens,
signed assertions and player aliases never appear in the iframe URL. The
provider backend consumes the bootstrap and supplies immutable, sanitized
session context to its own game client. When the game client is on a
different origin from the consume endpoint, the handoff uses same-tab
`window.name` (never query/fragment secrets); the provider page then
publishes verified context to the PlayerPal widget via a same-document
event. Parent `postMessage` is not used for launch context.
3. The operator remains authoritative for the seamless wallet. The provider
owns game mathematics and Provider rounds; PlayerPal receives only
asynchronous, post-settlement Activity batches from the provider backend.
4. Providers load an exact immutable PlayerPal widget build from a
PlayerPal-controlled asset origin with Subresource Integrity. They do not
rebuild or redistribute the widget. Widget failure never blocks play.
5. Integrations may use three **Linking modes**:
- **pre-linked** — an existing provider alias is present at launch;
- **operator-led** — the operator completes linking before launch and places
only the provider alias in launch metadata;
- **provider-led** — the provider's Link broker creates and exchanges a
provider-scoped Link intent under authenticated integration scope.
6. A provider never calls an operator to perform linking. If an operator-led
launch has no alias, the provider continues without PlayerPal.
7. The provider surface receives only its integration-scoped
`pp_plr_prv_…` alias and coarse connection/outbox status. Global Points,
ledger details, operator aliases and the private PlayerPal ID stay on
PlayerPal-controlled surfaces.
## Consequences
- Extends ADRs 0015 and 0017; it does not replace their asynchronous sidecar or
PlayerPal-hosted consent requirements.
- The local demonstration needs distinct operator, provider and PlayerPal
backend boundaries and separately owned persistence, even when infrastructure
is shared on one development machine.
- A refreshed or replayed consumed bootstrap cannot resume a game. The operator
requests a new Provider launch session; persisted wallet and round state make
recovery idempotent.
- The original PixiJS game remains play-money demonstration software and is not
certified real-money gambling mathematics.
---
# ADR 0026. Random Analytics Subject and Provider-Global Cohort (not alias)
Source: https://docs-internal.playerpal.io/docs/decisions/0026-analytics-subject-and-provider-cohort
## Status
Accepted.
Does not change [ADR 0014](0014-provider-operator-alias-scope.md): a
provider-global **alias** remains forbidden by default. This record allows a
provider-global **cohort** (Account counts, numbers only).
## Context
Partners want enrichment without correlating a person across Audiences.
Two tempting shortcuts both fail the identity invariants:
1. Hang partner analytics off `players.id` or expose UUID / `pp_plr_...` /
another Audience’s alias on a partner read.
2. Mint one `pp_plr_prv_...` per provider so that provider can join the same
person across operators. ADR 0014 already rejects that default.
A third need appears at Delete Account: gameplay facts may be retained, but
the live UUID join must disappear. A PlayerPal-only clustering key is required
so retained Activity is not re-attached to a person on a partner API, and so
studio KPIs can count distinct Accounts rather than aliases.
Deriving that key from UUID or `pp_plr_...` would make it reversible.
Summing per-alias serving rows would double-count a Player with two
integrations at the same provider.
## Decision
1. Mint a cryptographically random analytics subject (`pp_anl_...`) at Account
activation. Never derive it from UUID, `pp_plr_...`, PII, or a partner
identifier. Never return it on player or partner APIs (including
`/v1/me`, link exchange, activity ingest, and studio reads).
2. Live loyalty processing may use Account id in memory. Persisted
partner-facing analytics and serving rows must not expose Account id.
Partner studio HTTP is machine-authenticated and provider-Party scoped
(`x-party-id` as `pp_prv_...`), not ingest’s `x-integration-id`, and not a
payload `operator_id`.
3. Per-alias studio lookup uses only a `pp_plr_prv_...` that provider was
issued. A foreign alias is the same unknown as a missing alias. Two aliases
for the same Account at that provider return the same Account-level numbers.
“On you” is all of that provider’s integrations for the Account, not one
casino. Response body is numbers only: `from`, `to`, `bets`, `wins`,
`bet_eur`, `share_of_their_slot_turnover`, `rounds_on_you`,
`rounds_elsewhere`.
4. Studio rollup counts distinct Accounts for that provider Party and is not
implemented as a sum of per-alias serving rows. It includes `accounts`.
Windows below a configurable Account floor are suppressed (production
default 50; tests/demo may lower it). Per-alias lookup is n=1 by design.
5. Unlink freezes new partner-player-day rows for that alias; historical days
remain. Delete Account deletes partner-player-day (and related serving)
rows for that Player’s aliases, nulls `player_id` / `player_alias` on
retained Activity, destroys the UUID↔analytics-subject mapping, and leaves
`round_ref` and the analytics subject on retained facts. Live per-alias GET
for those aliases is unknown.
6. v1 stores UUID↔analytics-subject in application storage.
Crypto-shredding / KMS envelope encryption is out of scope. A restored
pre-delete backup can still join; do not market otherwise.
## Consequences
- ADR 0014 still governs aliases. Open question 5 (which providers, if any,
get a provider-global **alias** exception) stays open. Cohort is not that
exception.
- Identity and loyalty topic pages must not say partner analytics hang off
`players.id`. Internal ledger and identity tables still may.
- Closes open question 6 for **output** (numeric cohort and per-alias
enrichment; never another Audience’s identifier) and open question 7
(Unlink vs Delete Account retention). Legal joint-controller contracts stay
a product-permission assumption, not an identifier rule.
- Glossary: `CONTEXT.md`. Day-to-day: [docs/loyalty.md](../loyalty.md),
[docs/identity.md](../identity.md). Unlink skeleton: spec §36.6.
---
# ADR 0027. Mint aliases at link complete for listed integrations
Source: https://docs-internal.playerpal.io/docs/decisions/0027-alias-provisioning-at-link
## Status
Accepted (as listed in the decisions index).
Link exchange already returns provider aliases (spec §37.4). The remaining
choice was whether to mint every possible future integration up front, or wait
until first activity. Minting on ingest would be passive first exposure and
breaks the glossary. Minting every future integration wastes aliases and
creates consent for audiences the Player never linked.
## Decision
Mint Pairwise aliases only for audiences on the completed link intent:
operator alias when the flow is operator-led; one `pp_plr_prv_...` per
integration listed on that intent. Provider-led complete still does not mint
an operator alias.
Do not mint on Activity ingest. Do not mint for integrations that did not
exist or were not listed at link time.
When an operator enables a new provider integration later, mint through
`POST /v1/operator-players/{alias}/provider-aliases:resolve` (spec §37.5),
not as a side effect of gameplay.
## Consequences
- Normal game launch can cache aliases from the original exchange (spec §11).
- The resolve endpoint is the later-integration path; until it exists,
operators only have aliases from the original link.
- Ingestion keeps resolving inside the authenticated integration audience
(ADR 0014). Unknown alias stays unknown.
---
# ADR 0028. v1 recovery is codes, passkeys, and linked factors
Source: https://docs-internal.playerpal.io/docs/decisions/0028-v1-recovery-without-operator-assist
## Status
Accepted (as listed in the decisions index).
Spec §19 requires recovery without email or SMS, including operator-assisted
recovery with a cooling-off period. Inventing a staff take-over now would let
an operator or support key own a PlayerPal Account, which §19 forbids.
## Decision
v1 recovery is Player-held factors only:
- one-time recovery codes (already Nest-owned)
- additional passkeys (encouraged, not a signup gate)
- an already-linked social method
- an existing password, only after a recovery-scoped session
Device loss: revoke other portal sessions from a remaining device; if none
remain, consume a recovery code, enrol a new passkey, and revoke other
sessions on that upgrade. Social email match still must not recover an
Account (ADR 0012, ADR 0019).
Operator-assisted recovery is out of scope until a workforce identity domain
exists and a later ADR defines cooling-off, dual control, and audit. Staff
may list or revoke portal sessions; they must not reset credentials or take
ownership.
## Consequences
- Account UX should prompt for recovery-code confirm and a second passkey
after first login, without blocking play.
- Password reset stays recovery-code or existing-device, never email or SMS.
- [docs/authentication.md](../authentication.md) no longer treats
operator-assisted recovery as an unspecified v1 requirement.
---
# ADR 0029. Application-native authorization until a second service needs shared policy
Source: https://docs-internal.playerpal.io/docs/decisions/0029-application-native-authorization
## Status
Accepted (as listed in the decisions index).
Spec §17 allows OPA, Cerbos, or OpenFGA, and also allows ordinary
database-backed authorization initially. A policy engine now would duplicate
rules that already live next to Audience and integration checks in Nest.
## Decision
v1 authorization is application-native: Nest guards, authenticated
integration audience, and PlayerPal permission checks in the service that
owns the resource. IAM roles remain personas only (ADR 0011).
Revisit a policy engine when either is true: more than one service must
enforce the same PlayerPal permissions, or non-engineers must author those
rules without a deploy.
## Consequences
- [docs/authorization.md](../authorization.md) is the v1 rule list.
- Adding OpenFGA or Cerbos without the trigger above is a reopen of this ADR,
not a default improvement.
---
# ADR 0030. Unlink and Delete Account live on the PlayerPal Account
Source: https://docs-internal.playerpal.io/docs/decisions/0030-unlink-delete-on-playerpal-account
## Status
Accepted (as listed in the decisions index).
The APIs exist (`DELETE /v1/me/connections/:audienceId`, `DELETE /v1/me`).
Players will look for the verbs on Bucks Connected services. Hosting them
there makes Unlink look like a casino setting. The widget may grow the same
verbs later; v1 does not.
## Decision
The Player Unlinks one Audience and Deletes the Account only on the PlayerPal
Account screen. Each Connected partners row is one Audience, labelled with
Party `display_name` (not PII). Operator-led Unlink of that operator Party
cascades provider connections for that operator; provider-led Unlink is that
integration only.
Reauth reuses the recovery-code step-up. Unlink confirms consequences (play
continues, Points stay, later Activity does not earn, relink mints a new
alias). Delete Account is a danger zone on the same screen: reauth plus a
checkbox that Bucks/Google stay. After Unlink, stay on Account. After Delete
Account, sign out to the player landing with a one-shot flash.
Bucks Connected services deep-links to that Account. It does not gain an
Unlink control. Linked copy names the PlayerPal verbs. On show/focus it
re-queries operator link status so Connect returns when Bucks knows. Demo
Bucks applies an async PlayerPal notice and may reconcile the stored alias
([ADR 0032](0032-unlink-partner-notice.md)). If the notice has not landed and
PlayerPal is unreachable, stale-linked plus Open portal is still allowed.
## Consequences
- `GET /v1/me` connections include a config label from Party `display_name`.
- Widget Unlink is a later slice, not a missing v1 control.
- Casino Log out remains a presenter reset; it is not the Unlink path.
---
# ADR 0031. Group Connected partners; confirm names the cascade
Source: https://docs-internal.playerpal.io/docs/decisions/0031-account-connection-groups
## Status
Accepted (as listed in the decisions index).
ADR 0030 keeps Unlink on the PlayerPal Account, one Audience per Unlink
target, operator-led Unlink cascading provider-operator integrations for that
operator, and provider-led Unlink as that integration only. The Account list
was a flat row per connection. Unlink of the operator Party then looked like
“one item, everything gone,” because confirm named the cascade in a sentence
and not the Audiences.
`GET /v1/me` also omitted which operator Party a provider-operator
integration belongs to, so the portal could not list cascade members without
guessing labels or treating every Provider row as in-scope.
## Decision
Connected partners **groups** provider-operator integrations under the
operator Party they are scoped to. Each Unlink target remains one Audience.
Nesting is chrome, not a new Audience kind.
Unlink controls:
- **Operator parent** — Unlink of that operator Party. Confirm **names every
Audience** that will drop (the operator Party and each nested
provider-operator integration). Cascade is unchanged.
- **Nested provider row** — Unlink of that provider-operator integration
only. The operator parent stays, including when it has no children left.
- **Top-level Provider row** — a provider-led integration with **no**
operator Party connection on this Account. No synthetic operator parent.
Unlink is that integration only.
`GET /v1/me` connections that are `provider_operator` include
`operator_audience_id`: the operator Party public id (`pp_opr_...`), the
same value as that operator row’s `audience_id`. PlayerPal sets it from
authenticated integration configuration (`operator_party_id`), never from a
partner payload. It is not a private UUID and not `pp_plr_...`.
Do not guess the cascade set from labels. Do not send `operator_id` on
Unlink; the path param is the Audience being Unlinked.
## Consequences
- Operator Unlink confirm can list the real cascade set from `/v1/me`.
- Provider-led-only integrations stay Unlinkable without inventing an
operator Audience.
- ADR 0030 is not rewritten. Widget Unlink remains a later slice.
- Identity invariants 2, 8, and 12 still hold: no private player UUID to a
partner, no operator scope from an untrusted provider payload, no player
PII in the new field.
---
# ADR 0032. Demo Bucks unlink notice and alias reconcile
Source: https://docs-internal.playerpal.io/docs/decisions/0032-unlink-partner-notice
## Status
Accepted.
## Context
Unlink commits on PlayerPal (`revoked_at`) and may notify the partner asynchronously
(spec §36.6.5). Failure of that notice must not fail Unlink. Play at the partner
continues. ADR 0030 allowed casino chrome to stay stale-linked until Bucks knew.
Demo Bucks only read a local alias row. `notifyRevoked` was a no-op, so Account and
Game kept showing Connected after PlayerPal Unlink. Casino chrome must not wait on
Unlink, must not call PlayerPal on every wager, and must never send the private
player UUID. Audience for partner APIs comes from authenticated client headers,
not from an untrusted `operator_id` body field.
## Decision
PlayerPal pushes a fire-and-forget status delta to demo Bucks after Unlink
(`POST /v1/player-link-notices` with `MACHINE_API_KEY`, lookup by `pp_plr_opr_…` /
`pp_plr_prv_…`). Unlink HTTP success does not wait on that POST.
Bucks does not expose `DELETE /v1/player-links/:playerRef`. Casino Account/Game
still refresh `GET /v1/player-links/:playerRef`. After a local hit, Bucks
reconciles once with PlayerPal `GET /v1/aliases/:playerAlias` (operator or
integration header). `revoked` or `404` applies the same cache update as the
notice. PlayerPal network/5xx keeps last known (stale-linked).
Delete Account still does not command partner erasure.
Demo routing uses `BUCKS_UNLINK_NOTICE_URL` for `pp_opr_01DEMOBUCKS` and
`pp_int_ORBIT_BUCKS` only. Per-party webhook URLs and OAuth machine clients are
later.
## Consequences
- Connect returns on Bucks when the notice lands or the next status refresh
reconciles a missed notice.
- Stale-linked remains when the notice fails and PlayerPal is unreachable.
- Compose Nest reaches host Bucks via `host.docker.internal:3002` unless
`BUCKS_UNLINK_NOTICE_URL` is set otherwise.
---
# ADR 0033. Partner KPI Platform: Visibility Tiers, Anonymous Comparison, Cube Behind Nest
Source: https://docs-internal.playerpal.io/docs/decisions/0033-partner-kpi-platform
## Status
Accepted.
Extends [ADR 0026](0026-analytics-subject-and-provider-cohort.md) (distinct
Account counts and the Account floor) from studio analytics to every
partner-facing KPI. Adds HMAC request signing alongside the machine OAuth in
[ADR 0016](0016-bff-cookies-and-machine-oauth.md), for KPI reads only.
Authorization stays application-native ([ADR 0029](0029-application-native-authorization.md)).
The event log stays Phase 1 under [ADR 0005](0005-phased-event-log-infrastructure.md).
## Context
Operators, providers and affiliates each want KPIs about the players linked
to them. They also want to know how those players compare with the wider
PlayerPal network and with their competitors. Every portal dashboard number
is mock data today, and no table records funnels, logins or data access.
Four constraints shape the design:
1. **Scope.** A party may only see players linked to it. The existing studio
endpoints trust a caller-asserted `x-party-id` behind one shared machine
key, so any key holder can claim any party.
2. **Anonymity.** Comparison figures must never let a party identify a
competitor. This applies directly (names, ranks) and by inference: if a
market has two operators, "network minus you" reveals the other one, and
if one operator holds most of a market, a "market average" is in effect
that operator's figure.
3. **Cost.** No paid BI and no heavy new stateful platform. Metabase was
rejected because it needs a JVM pod, a metadata database and, ideally, a
read replica.
4. **Market dimension.** Country is needed for comparisons, but PlayerPal
never stores player PII ([ADR 0012](0012-never-receive-player-pii.md)).
## Decision
1. **Four visibility tiers.** Every KPI value belongs to exactly one tier:
- **Own:** the party's own players (operator: active links; provider:
Accounts reached through its integrations).
- **Network comparison:** your players (Y) against the rest of the
PlayerPal network (R = all active Accounts minus Y), expressed as
per-player rates, an index and a percentile band.
- **Benchmark:** anonymous peers of the same party type.
- **Network:** staff only.
Definitions are in [docs/kpis.md](../kpis.md).
2. **Scope comes from the authenticated principal, never from the request.**
Partner routes ignore `party_id` in query strings and in `x-party-id`
headers.
3. **Anonymity rules**, enforced in Nest guards at write time (suppressed
segments store no values) and again at read time:
- **Account floor:** at least 50 distinct Accounts per cell (ADR 0026).
- **Peer floor:** at least 5 other parties of the requester's type in the
comparison population.
- **Dominance rule:** suppress the segment if any single other party
contributes more than 50% of the measure, or the top two more than 75%.
- **Statistics only:** p25, p50, p75, mean, and the requester's
percentile band. Never min/max, exact rank, per-peer values or names.
- **No absolute network totals for partners.** Network size is shown only
as a band.
- **Fixed segments only:** closed weeks and months, and markets from the
catalogue. No arbitrary ranges or ad-hoc filters, so a party cannot
difference two segments.
- A segment that fails a rule falls back from market to global, or is
suppressed with a reason.
- The thresholds are configuration (`BENCHMARK_MIN_PEERS`,
`BENCHMARK_MIN_ACCOUNTS`, `BENCHMARK_DOMINANCE_P`, `BENCHMARK_DOMINANCE_P2`).
Production must not set them below the values above.
4. **Market comes from activity, not the player.** Providers and operators
report an ISO 3166-1 alpha-2 `market` (the licensed jurisdiction) per
activity event or batch, and it is checked against the integration's
declared markets. A player's market is derived per day from their
activity and is never stored on the identity.
5. **Domain event log.** Funnels, logins, linking, credential changes and
KPI data access are recorded in `domain_events`, an append-only table
written in the same transaction as the state change. This is Phase 1 of
ADR 0005. It records no PII, and Delete Account nulls `player_id` and
keeps the event.
6. **Cube Core as an internal semantic layer behind Nest.**
- Cube is an app service (`services/cube`): self-hosted, Apache 2.0, a
single container, with pre-aggregations stored in Postgres.
- It holds the metric definitions and does the caching.
- Cube is never reachable by partners. Nest maps each public catalogue
request to a fixed Cube query and signs a short-lived internal JWT that
carries the principal's scope.
- As defence in depth, Cube's `queryRewrite` also enforces the party
filter.
- Staff ad-hoc work uses Cube's SQL API over a port-forward.
7. **Partner KPI authentication.**
- **OAuth 2.0 client credentials** (ADR 0016) is the default.
- **HMAC-SHA256 request signing** is an additional scheme for partners
who cannot run OAuth.
- Per-party keys, with two active at once for rotation.
- Secrets are encrypted at rest.
- Each request carries a timestamp and a nonce, and is rejected if the
timestamp is more than 5 minutes off or the nonce was seen before.
- **Portal sessions** cover the browser.
- All three resolve to one principal (party, party type, client, scopes)
checked by the same guard.
## Consequences
- The studio analytics endpoints take the provider from the partner
principal. The legacy shared key with `x-party-id` stays behind
`STUDIO_LEGACY_PARTY_HEADER` (default on, so local demos keep working) and
must be set to `false` in production. The response shape is unchanged.
- Comparisons can be suppressed in thin markets. Early on, with only a
handful of operators per market, partners will mostly see global
figures. This is intended.
- KPI definitions live in `services/cube/model` and are tested against
hand-written SQL. `docs/kpis.md` must be kept in sync with them.
- Network comparisons and peer benchmarks are computed by a job in the API
process, directly over the same `analytics` views the Cube model uses,
because per-player distributions and per-requester populations are not a
good fit for Cube. The anonymity rules live in one pure module
(`analytics/compare/comparison.math.ts`) and are applied before anything
is stored.
- One more service to run (Cube). If volume grows, Cube Store or ClickHouse
can sit behind Cube without changing the public API.
- HMAC adds a secret type that must be decrypted to verify, so a master-key
rotation procedure is needed before production.
- Glossary additions: Visibility tier, Network comparison, Market.
Day-to-day reference: [docs/kpis.md](../kpis.md).
---
# ADR 0034. Documentation site: Docusaurus, OpenAPI, Scalar; internal first
Source: https://docs-internal.playerpal.io/docs/decisions/0034-documentation-site
## Status
Accepted.
## Context
Docs were plain Markdown in `docs/`, `spec/` and per-service READMEs, with no
site, link checking or API specification. Routes are hard-coded `v1/...`
prefixes validated by hand-written TypeScript; there was no OpenAPI. Partners
need integration guides and an API reference; founders need architecture,
decisions and operations in one place, with honest implementation status.
## Decision
1. **Docusaurus** (`docs-site/`, own `package.json` and lockfile) renders
`docs/` in place. No files move. `markdown.format: 'detect'` keeps `.md`
as CommonMark; `.mdx` opts into MDX. Broken links and anchors fail the
build. Links to files outside the site are rewritten to GitHub
(`docs-site/plugins/remark-repo-links.mjs`).
2. **OpenAPI is the source of the API reference**, generated code-first with
`@nestjs/swagger` decorators (`services/api/src/openapi/`). Two documents:
`internal.json` (every route; in `docs-site/static-internal/`, never copied
into the public build) and `partner.json` (operations marked
`x-audience: partner`). Both are committed under
`docs-site/static/openapi/`; `npm run openapi:check` fails when they differ
from the code. Unbuilt endpoints are not added to OpenAPI.
3. **Scalar** renders both documents, self-hosted: the renderer is vendored
from a pinned `@scalar/api-reference` at build time, not loaded from a CDN.
Request testing and the client button are off and no proxy is configured,
until a sandbox exists.
4. **Internal first, fail closed.** Every page is internal unless its front
matter says `audience: public`. The public build (`DOCS_AUDIENCE=public`)
contains only those pages and the partner reference, and fails if a public
page links to an internal page or into the repository. The internal site
is hosted on Cloudflare Pages behind Cloudflare Access, which is already
used for other hosts. CI deploys it on push to `main`; setup is in
[Contributing to the docs](../contributing/docs.md#hosting). The public
build is not deployed yet.
5. **Status labels.** Page front matter `status: implemented | in-progress |
proposed`, optional `demo: true` and `verified: `; section-level
`> **Status: …** — evidence: …` lines on mixed pages. A status must cite
code or tests; a mockup alone never makes something implemented.
6. **Spec and glossary** (`spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md`,
`CONTEXT.md`) are rendered on the internal site only, from copies made at
build time. They stay where they are and the spec still wins on conflict.
## Defaults chosen for open questions
These were taken as defaults when the site was built and can be revisited:
| Question | Default |
|---|---|
| Public site now or later | Later. No page is marked public. |
| Code-first or spec-first OpenAPI | Code-first |
| Sandbox for request testing | None yet; reference is read-only |
| PR previews | None; local preview plus CI build |
| App location | `docs-site/` with its own lockfile |
| Publish the spec | No; internal build only |
| Partner doc scope | Includes routes behind the shared machine key (activity batches and uploads, link intents and results, aliases) because integrations call them today. Revisit before any public release: per-partner credentials do not exist for these routes yet. |
Still undecided and not answered by this ADR: retention schedule and purpose
limits, reward-partner scope, the canonical redemption state model, packaging
and pricing. Pages describing them say so.
## Amendment: shareable public site
The site now serves partners, prospects and investors as well as the team.
Public pages are `.mdx` under `product/`, `developers/`, `analytics/`,
`rewards/` and `trust/`, written to [contributing/style.md](../contributing/style.md):
reader-facing status labels (Available / In development / Planned), evidence
kept in MDX comments, no repository links or internals. The PlayerPal brand
theme, a landing page, offline search and a social preview image are part of
the build. `npm run check:public` scans the public build output for hostnames,
infrastructure vendors, configuration keys, non-placeholder IDs, repository
links and the internal OpenAPI document, and runs in CI.
## Consequences
- A behaviour change updates its page and, for endpoints, the regenerated
OpenAPI files in the same PR; CI checks the OpenAPI part and the KPI IDs.
- The API gains decorators and hand-written JSON schemas that must track the
hand-written validators until validation moves to a schema library.
- Internal hosting needs a Cloudflare Access application before the docs image
is added to `build-push.yml` and `k8s/apps/`.
---
# Architecture Decision Records
Source: https://docs-internal.playerpal.io/docs/decisions
One decision per file. Template: [../adr-template.md](../adr-template.md).
Do not rewrite a decided ADR in place; mark it superseded and add a new number.
| ADR | Title | Status |
|---|---|---|
| [0001](0001-generic-party-model.md) | Generic party model | Accepted |
| [0002](0002-random-pairwise-external-ids.md) | Random pairwise external IDs | Accepted (prefix → 0013) |
| [0003](0003-session-jwt-at-boundary.md) | Session JWT at boundary | Superseded by 0015, 0016 |
| [0004](0004-async-first-ingestion.md) | Async-first ingestion | Accepted |
| [0005](0005-phased-event-log-infrastructure.md) | Phased event log | Accepted |
| [0006](0006-batch-ingestion-via-object-storage.md) | Large batches via object storage | Accepted |
| [0007](0007-versioned-campaign-configuration.md) | Versioned campaign configuration | Accepted |
| [0008](0008-at-least-once-plus-idempotency.md) | At-least-once + idempotency | Accepted |
| [0009](0009-mandatory-one-time-link-before-data-flows.md) | Mandatory one-time link | Accepted |
| [0010](0010-linking-unifies-no-separate-claim.md) | Linking unifies relationships | Accepted |
| [0011](0011-iam-authenticates-playerpal-owns-identity.md) | IAM authenticates; PlayerPal owns identity | Accepted |
| [0012](0012-never-receive-player-pii.md) | Never receive player email/name/phone | Accepted |
| [0013](0013-typed-pairwise-aliases.md) | Typed pairwise aliases | Accepted |
| [0014](0014-provider-operator-alias-scope.md) | Provider-operator alias scope | Accepted |
| [0015](0015-async-loyalty-sidecar.md) | Async loyalty sidecar | Accepted |
| [0016](0016-bff-cookies-and-machine-oauth.md) | BFF cookies and machine OAuth | Accepted |
| [0017](0017-operator-assertion-popup-linking.md) | Assertion + popup linking | Accepted |
| [0018](0018-url-isolated-portals.md) | URL-isolated portals | Accepted |
| [0019](0019-scoped-bootstrap-proofs-controlled-merge.md) | Scoped proofs and controlled merge | Accepted |
| [0020](0020-assurance-levels-provisional-password.md) | Assurance A0-A3 | Accepted |
| [0021](0021-nestjs-zitadel-local-poc.md) | NestJS control plane + ZITADEL local POC | Accepted |
| [0022](0022-official-login-app-fork.md) | Official Login app fork for portal sign-in | Accepted (player password/passkey path → [0024](0024-nest-owned-portal-auth.md)) |
| [0023](0023-typeorm-data-layer.md) | TypeORM data layer + DDL ownership | Accepted |
| [0024](0024-nest-owned-portal-auth.md) | Nest-owned portal password/passkey + social IdP | Accepted |
| [0025](0025-provider-launch-sessions-and-widget-distribution.md) | Provider launch sessions + PlayerPal widget distribution | Accepted |
| [0026](0026-analytics-subject-and-provider-cohort.md) | Random analytics subject + provider-global cohort (not alias) | Accepted |
| [0027](0027-alias-provisioning-at-link.md) | Mint aliases at link complete for listed integrations | Accepted |
| [0028](0028-v1-recovery-without-operator-assist.md) | v1 recovery without operator assist | Accepted |
| [0029](0029-application-native-authorization.md) | Application-native authorization for v1 | Accepted |
| [0030](0030-unlink-delete-on-playerpal-account.md) | Unlink and Delete Account on PlayerPal Account | Accepted |
| [0031](0031-account-connection-groups.md) | Group Connected partners; confirm names the cascade | Accepted |
| [0032](0032-unlink-partner-notice.md) | Demo Bucks unlink notice and alias reconcile | Accepted |
| [0033](0033-partner-kpi-platform.md) | Partner KPI platform: visibility tiers, anonymous comparison, Cube behind Nest | Accepted |
| [0034](0034-documentation-site.md) | Documentation site: Docusaurus + OpenAPI + Scalar, internal first | Accepted |
---
# Authenticate API calls
Source: https://docs-internal.playerpal.io/docs/developers/authentication
Every partner call to PlayerPal is authenticated as your organisation, never as a player. Your credential decides which party you are, which routes you can call and which data you see. A party ID sent in a query string or an `x-party-id` header is ignored on partner-credential routes: your scope always comes from the credential.
## Choose a method
| Method | Routes | What you send | Status |
|---|---|---|---|
| **HMAC request signing** | `/v1/ingest`, `/v1/kpis/*`, `/v1/analytics/studio*` | `Authorization: PP-HMAC-SHA256 …` plus `X-PP-Timestamp` and `X-PP-Nonce` | [Available] |
| **OAuth bearer token** | The same routes as HMAC signing | `Authorization: Bearer ` from the client-credentials grant | [Available] |
| **Integration key** | `/v1/activity-batches`, `/v1/activity-uploads`, `/v1/link-intents`, `/v1/link-results/exchange`, `/v1/aliases/{playerAlias}` | `x-api-key: ` plus an audience header | [Available] |
| **Per-partner credentials** for the activity and linking routes | The integration-key routes above | Not decided yet | [In development] |
HMAC signing and OAuth are interchangeable: use whichever PlayerPal registered for you.
## Sign requests with PP-HMAC-SHA256
Watch first. This walks through the same steps as the text below, using the test-vector values.
PlayerPal issues you a key ID (`pp_kid_…`) and a secret (`pp_sk_…`). The secret is shown once, so store it in your secret manager. Every signed request carries three headers.
The signature is an HMAC-SHA256 of a **canonical string**: six lines joined with `\n`, with no trailing newline.
| Line | Value |
|---|---|
| 1 | HTTP method, uppercase |
| 2 | Path exactly as sent, without the query string |
| 3 | Query pairs sorted by key, then by value, each key and value RFC 3986 percent-encoded, joined with `&`. Empty if there is no query string. |
| 4 | Lowercase hex SHA-256 of the exact request body bytes (the hash of the empty string for a `GET`) |
| 5 | The `X-PP-Timestamp` value |
| 6 | The `X-PP-Nonce` value |
Then `signature = base64url_nopad(HMAC-SHA256(secret, canonical))`.
- The timestamp is Unix seconds and must be within **300 seconds** of PlayerPal's clock, in either direction.
- The nonce is 16 to 128 characters from `A-Z a-z 0-9 _ -`. Generate a fresh random one per request.
- A nonce can be used once per key within the window. A repeat is rejected as `replayed_nonce`. The nonce is only recorded after the signature checks out, so a forged request cannot use up your nonces.
- Sign the body bytes you actually send. Re-serialising JSON after signing breaks the signature.
- Signatures are compared in constant time.
Headers:
```
Authorization: PP-HMAC-SHA256 keyId=pp_kid_XXXX, signature=
X-PP-Timestamp:
X-PP-Nonce: <16-128 chars of A-Z a-z 0-9 _ ->
```
(Code sample: sign. See the page for curl, Node.js, Python, PHP, Java and C#.)
## Check your signer against the test vectors
Run your signer with secret `pp_sk_test-vector-secret`, timestamp `1760000000` and nonce `nonce_0123456789abcdef`. The vectors test the signing algorithm only, not a live call.
| # | Request | Canonical query |
|---|---|---|
| 1 | `GET /v1/kpis/own?to=2026-09-30&kpi=own.rounds&from=2026-09-01` | `from=2026-09-01&kpi=own.rounds&to=2026-09-30` |
| 2 | `POST /v1/kpis/exports` with body `{"kpi":"own.rounds"}` (illustrative path, not a live route) | (empty) |
Vector 1 gives `NhQFk3mISFyIhDIMHBwZ-pWBoO5pKuFk_yWDVycaOyY`. Vector 2 gives `7Jd4HZfVqJj3aJTY7K09ipVJHIDQ_6qfICxscuh1-kw`. Every sample on this site is tested against these values.
Vector 1 canonical string:
```
GET
/v1/kpis/own
from=2026-09-01&kpi=own.rounds&to=2026-09-30
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1760000000
nonce_0123456789abcdef
```
Vector 2 canonical string:
```
POST
/v1/kpis/exports
4dd0775e21e5aa993d44025a43efbd7aa90f24f91b463afdb3e80787704582df
1760000000
nonce_0123456789abcdef
```
## Or use an OAuth access token
If PlayerPal registered an OAuth client for your organisation, it is mapped to your party and scopes. Request an access token from the identity provider with the client-credentials grant, using the token endpoint and audience PlayerPal gives you during [onboarding](./onboarding.mdx). The token endpoint belongs to the identity provider, not to the PlayerPal API.
PlayerPal verifies each token as follows:
1. The signature, against the issuer's published signing keys.
2. The issuer (`iss`), audience (`aud`) and expiry (`exp`).
3. The client ID, read from `client_id` (or `sub`), must belong to an active client registered to a partner.
Tokens are short-lived (5 to 15 minutes). Cache one per worker and refresh it before it expires, rather than fetching one per request. Signed requests and bearer tokens reach the same routes with the same scopes.
Bearer header:
```
GET /v1/kpis/catalogue
Authorization: Bearer
```
## Call activity and linking routes with the integration key
The activity and linking routes use an integration key issued by PlayerPal. Send it as `x-api-key: ` or `Authorization: Bearer `. These routes take the audience from a header, never from the request body.
| Route | Audience header |
|---|---|
| `POST /v1/activity-batches`, `GET /v1/activity-batches/{batchId}`, `/v1/activity-uploads*` | `x-integration-id` (required) |
| `POST /v1/link-intents`, `GET /v1/aliases/{playerAlias}` | `x-operator-id` (operators) or `x-integration-id` (game providers) |
| `POST /v1/link-results/exchange` | `x-integration-id` for the game provider exchange; without it you get the operator result |
:::info[In development]
Per-partner credentials for the activity and linking routes are being built. Until then, treat the integration key like any production secret: keep it server-side and never ship it to a browser or app.
:::
(Code sample: integration-key. See the page for curl, Node.js, Python, PHP, Java and C#.)
`getAliasStatus` (see the API reference)
## Ask for the right scopes
HMAC keys and OAuth clients carry scopes. A route checks its scope after authentication succeeds.
| Scope | Grants |
|---|---|
| `ingest.write` | `POST /v1/ingest`, `GET /v1/ingest/batches/{batchId}` |
| `kpis.read` | `/v1/kpis/*` |
| `studio.read` | `/v1/analytics/studio*` (game providers only) |
An HMAC key issued without named scopes gets `kpis.read` only. Ask for `ingest.write` if you send data. The integration key has no scopes; its routes are limited by the audience header instead.
`ingestSubmit` (see the API reference)
`kpisOwn` (see the API reference)
`studioRollup` (see the API reference)
## Rotate keys without downtime
You can hold **two active HMAC keys** at a time. PlayerPal rotates and revokes keys for you on request.
1. Ask PlayerPal to rotate your key. You receive a new key ID and secret, with the same scopes as the old key.
2. The old key keeps working for **24 hours**. Roll the new secret out to every worker in that window.
3. After 24 hours the old key stops working, and requests signed with it fail with `invalid_signature`.
Rotation needs a free slot: if you already hold two active keys, one is revoked first. A revoked key stops working immediately, so ask for a revocation, not a rotation, if a secret leaks.
Rotation timeline:
```
t = 0 new key issued, old key still active
t < 24 h both keys accepted; deploy the new secret
t = 24 h old key expires; only the new key works
```
## Errors
Auth failures use the standard error body, with the code in `message`. Do not retry them unchanged: fix the credential, clock or signer first.
```json
{ "statusCode": 401, "message": "stale_timestamp", "error": "Unauthorized" }
```
| Status | `message` | Cause |
|---|---|---|
| `401` | [`partner_auth_required`](./idempotency-and-errors.mdx#partner_auth_required) | No `PP-HMAC-SHA256` or `Bearer` authorization header |
| `401` | [`invalid_signature`](./idempotency-and-errors.mdx#invalid_signature) | Malformed header, unknown, expired or revoked key, or signature mismatch |
| `401` | [`stale_timestamp`](./idempotency-and-errors.mdx#stale_timestamp) | Timestamp missing, malformed or more than 300 seconds off |
| `401` | [`invalid_nonce`](./idempotency-and-errors.mdx#invalid_nonce) | Nonce missing or outside 16-128 allowed characters |
| `401` | [`replayed_nonce`](./idempotency-and-errors.mdx#replayed_nonce) | Nonce already used with this key |
| `401` | [`invalid_token`](./idempotency-and-errors.mdx#invalid_token) | Token signature, issuer, audience or expiry failed |
| `401` | [`unknown_client`](./idempotency-and-errors.mdx#unknown_client) | Token is valid but the client is not registered to a partner |
| `401` | [`hmac_not_configured`](./idempotency-and-errors.mdx#hmac_not_configured), [`oauth_not_configured`](./idempotency-and-errors.mdx#oauth_not_configured) | The scheme is not enabled in this environment; contact PlayerPal |
| `401` | [`machine_auth_required`](./idempotency-and-errors.mdx#machine_auth_required) | Integration key missing or wrong |
| `403` | [`partner_scope_missing`](./idempotency-and-errors.mdx#partner_scope_missing) | Credential lacks the route's scope |
## Related
- [Quickstart](./quickstart.mdx):
Sign a request and send your first batch.
- [Idempotency and errors](./idempotency-and-errors.mdx):
Safe retries and every error code by endpoint family.
- [Security](../trust/security.mdx):
How PlayerPal protects partner and player data.
---
# Design for availability
Source: https://docs-internal.playerpal.io/docs/developers/availability
PlayerPal is an asynchronous loyalty layer that sits beside your platform, not in front of it. If every PlayerPal service were unavailable, your players could still log in, launch games, place bets, have rounds settled and be paid. This page explains the rule and the outbox pattern that keeps it true in your integration.
## The rule
[Available]
:::info[The rule]
**Login, game launch, wagering, settlement and payouts never call PlayerPal and never wait for it.** Everything PlayerPal needs reaches it later, in batches, from your own outbox.
:::
- **No PlayerPal call before or during game launch.** The operator creates the launch session with the game provider directly.
- **No PlayerPal call per wager or round.** The operator's wallet stays authoritative and the game provider owns the round.
- **The alias travels with the launch.** A game provider receives its PlayerPal player alias (`pp_plr_prv_…`) in the operator's launch metadata. PlayerPal issues it when the player links, so nothing is looked up at launch.
- **Activity is sent later, in batches.** Write it to a local outbox and deliver it asynchronously.
- **The widget is optional and fails open.** If it cannot load, play is unaffected.
## What depends on PlayerPal
| Moment | Calls PlayerPal? | Needed for play? | If PlayerPal is unavailable |
|---|---|---|---|
| Login and game launch | No | No | No impact |
| Each wager, settlement or payout | No | No | No impact; activity stays in your outbox |
| Batch delivery | One call per batch | No | Keep the batch and retry with backoff |
| Player links an account | Yes | No | Show "unavailable"; games stay usable |
| Widget or status display | Optional read | No | Show a neutral or unavailable state |
| Viewing or redeeming Points | Yes | No | Not available until PlayerPal recovers |
## Recommended outbox pattern
[Available]
Record eligible activity in your own database and let a background worker deliver it. The worker is the only part of your system that talks to PlayerPal.
```mermaid
sequenceDiagram
participant P as Player
participant G as Your platform
participant DB as Your database
participant W as Outbox worker
participant PP as PlayerPal API
P->>G: Place bet
G->>DB: Settle round + write outbox row (one transaction)
G-->>P: Result and payout
Note over G,PP: Play never waits for PlayerPal
loop Every few seconds
W->>DB: Read queued rows, group into a batch with a stable batch_id
W->>PP: POST batch (same batch_id on every attempt)
alt 202 Accepted
PP-->>W: Receipt
W->>DB: Mark batch delivered
else Timeout, network error or 5xx
W->>W: Back off with jitter, retry the identical batch
end
end
```
Guidelines:
1. **Write the outbox row in the same database transaction as settlement.** Then a crash can never settle a round without recording its activity, or the reverse.
2. **Assign the `batch_id` once,** when you form the batch, and store it. Reuse it on every retry so PlayerPal can deduplicate.
3. **Retry until delivered.** Use exponential backoff with jitter and no fixed give-up point. Alert if a batch has been pending for longer than you expect.
4. **Survive restarts.** On startup, resume any batch that was pending or retrying.
5. **Do not let one stuck batch block newer ones.** Keep delivering fresh batches while an older one retries.
6. **Send many events per call.** One batch of hundreds or thousands of events is better than one call per round.
7. **Cache tokens per worker,** not per player, and refresh before expiry.
8. **Check a batch only after an ambiguous response,** using its status endpoint.
### What an outbox record holds
[Available]
Keep each event exactly as you will send it, plus two delivery fields: the `batch_id` it was assigned to (empty until a batch is formed) and its delivery state. The shape below is an example; use whatever storage you already run.
Outbox record (example):
```
{
"event_id": "round-8812-settled",
"type": "round_settled",
"player_id": "pp_plr_prv_XXXX",
"occurred_at": "2026-10-15T12:03:11Z",
"game_ref": "your-game-ref",
"round_ref": "round-8812",
"currency": "EUR",
"bet": "2.00",
"win": "0.00",
"batch_id": "rounds-2026-10-15-0001",
"state": "pending"
}
```
| `state` | Meaning |
|---|---|
| `queued` | Written with settlement, not yet in a batch |
| `pending` | Assigned to a batch that is being delivered or retried |
| `accepted` | PlayerPal returned a receipt for its batch |
The event fields match an [activity batch](./sending-activity.mdx). Store the batch itself too, with its `batch_id`, its event IDs and an attempt count, so a restarted worker can resume it unchanged.
## During an outage
[Available]
- **Play continues.** Nothing in login, launch, wagering, settlement or payout calls PlayerPal.
- **Points arrive late, not differently.** Eligibility is based on when the activity happened (`occurred_at`), not when it reached PlayerPal. Activity that happened before a player unlinked still counts when it arrives later.
- **Analytics catch up.** KPIs reflect delayed batches once they are delivered and processed.
- **Your data is safe while it stays in your outbox.** Delivery protection comes from your durable outbox and the reuse of the same `batch_id`.
:::note
PlayerPal does not yet publish availability or freshness commitments. Contact PlayerPal for current targets.
:::
## Ways to deliver data
| Endpoint | Use |
|---|---|
| `POST /v1/activity-batches` | Batches of settled-round activity that feed Points |
| `POST /v1/activity-uploads`, then `.../complete` | Large activity files through a signed upload URL |
| `POST /v1/ingest` | Daily pseudonymous facts that feed KPIs, up to 10,000 records per batch |
All three accept at-least-once delivery. See [Idempotency and errors](./idempotency-and-errors.mdx).
## Availability checklist
1. Login, launch, wagering, settlement and payouts complete with PlayerPal blocked at your network edge.
2. The outbox record is written in the same transaction as settlement.
3. Each batch gets its `batch_id` once, and every retry resends the identical batch.
4. Retries use exponential backoff with jitter, and you alert on batches pending longer than you expect.
5. A restarted worker resumes pending batches, and a stuck batch does not hold back newer ones.
6. Link and widget screens show a neutral or unavailable state instead of an error when PlayerPal does not answer.
Block PlayerPal, play a few rounds and restart your worker. Play is unaffected, the rows stay queued or pending, and once you unblock PlayerPal every batch is accepted exactly once.
## Related
- [Send activity](./sending-activity.mdx):
Batch format and upload flow for activity.
- [Idempotency and errors](./idempotency-and-errors.mdx):
What is deduplicated and how to retry.
- [How it works](../product/how-it-works.mdx):
Where PlayerPal sits next to your platform.
---
# Changelog
Source: https://docs-internal.playerpal.io/docs/developers/changelog
Changes to the partner-facing API, newest first. PlayerPal is in pre-release: endpoints marked In development can still change. Every route stays under `/v1`.
### Partner API reference
The [API reference](/api/partner) is now generated from the API's own code, with descriptions for every partner endpoint, request and response schemas, and error codes. The OpenAPI 3.1 document is published at [`/openapi/partner.json`](pathname:///openapi/partner.json).
### Insights
`GET /v1/kpis/intelligence/insights` returns ranked, explainable insights with an executive summary, and `GET /v1/kpis/intelligence/insights/{id}` returns one insight with its evidence. See [Insights](../analytics/insights.mdx).
### Explore endpoints for providers
Segments, journey funnel and per-game views were added under `/v1/kpis/explore`.
### Fact ingest
`POST /v1/ingest` accepts daily fact batches (gameplay, game catalogue, commercial and attribution) with per-record validation and idempotent receipts. See [Fact ingest](./fact-ingest.mdx).
### KPI API
- Own KPIs, breakdowns by market, game or partner, monthly retention cohorts, the link funnel and previous-period series under `/v1/kpis`.
- Anonymous network comparison and peer benchmarks, with peer and account thresholds.
- The partner overview at `GET /v1/kpis/overview` lists every KPI with its availability for your feeds.
### Partner authentication
Partner routes accept PP-HMAC-SHA256 request signing or OAuth 2.0 client-credentials tokens, with scopes. See [Authentication](./authentication.mdx).
### Bulk activity uploads
Large activity files can be sent through `POST /v1/activity-uploads` and completed with a checksum. See [Sending activity](./sending-activity.mdx).
### Studio analytics
Game providers can read studio analytics at `GET /v1/analytics/studio` and per alias.
### Widget releases
The PlayerPal widget is served as immutable, versioned releases with integrity hashes. See [Widget](./widget.mdx).
### Activity batches
`POST /v1/activity-batches` accepts settled rounds for points and analytics, idempotent on `batch_id`.
---
# Send daily facts
Source: https://docs-internal.playerpal.io/docs/developers/fact-ingest
Fact ingest is how you give PlayerPal the daily figures behind your analytics: gameplay per player and game from studios, commercial figures per player from operators, and affiliate attribution. You send facts; PlayerPal computes every KPI from them, so all partners see metrics defined the same way. Ingest is batch-first: one delivery per day, or per period, is enough.
## Schemas at a glance
| Schema | Who sends it | Natural key | What it feeds |
|---|---|---|---|
| `playerpal.provider.player_game_daily.v1` | Game provider | provider × player × game × day | Gameplay KPIs: active and new players, sessions, active time, retention, players at risk |
| `playerpal.provider.game_catalogue.v1` | Game provider | provider × game | Game names and attributes in game breakdowns and Explore game views |
| `playerpal.operator.player_commercial_daily.v1` | Operator | operator × player × day | Commercial KPIs: depositors, first-time deposits, deposits, GGR, NGR |
| `playerpal.affiliate.attribution.v1` | Operator | affiliate × player | Affiliate views, which count only `VERIFIED` attributions |
A KPI whose feed you have not delivered shows `requires_feed` in the [overview](./kpi-api.mdx), never a zero. Points, rewards, redemptions and missions are native to PlayerPal, so you never send them. Definitions are in [Metrics](../analytics/metrics.mdx).
## Build the envelope
`ingestSubmit` (see the API reference)
A batch is one envelope with records of one schema. PlayerPal checks the envelope first: if it is invalid, the request fails with `400` and nothing is stored.
- `source_party` is `GAME_PROVIDER` for provider schemas and `OPERATOR` for operator and attribution schemas.
- `source_id` must be your own party ID. Any other value returns `403 source_id_must_be_authenticated_party`.
- A schema sent by the wrong party type returns `403 schema_not_allowed_for_party`.
- `batch_id` is 1–128 characters of `A-Z a-z 0-9 _ . : -` and is your idempotency key.
- `period_start` must be before `period_end`. A batch holds at most 10,000 records.
(Parameter table: see the API reference.)
Envelope:
```
{
"schema": "playerpal.provider.player_game_daily.v1",
"schema_version": 1,
"batch_id": "daily-2026-10-12-0001",
"source_party": "GAME_PROVIDER",
"source_id": "pp_prv_XXXX",
"integration_id": null,
"period_start": "2026-10-12T00:00:00Z",
"period_end": "2026-10-13T00:00:00Z",
"generated_at": "2026-10-13T01:15:00Z",
"records": []
}
```
Invalid envelope:
```
{
"statusCode": 400,
"message": "period_invalid",
"error": "Bad Request"
}
```
## Add records for your schema
After the envelope, every record is validated on its own. Amounts are decimal strings (preferred) or numbers with at most 2 decimal places; counts are non-negative integers. References use only letters, digits and `_ . : -`, up to 128 characters.
**player_game_daily**
| Field | Required | Notes |
|---|---|---|
| `date` | Yes | UTC day, `YYYY-MM-DD`, inside the envelope period |
| `player_ref` | Yes | Your provider alias `pp_plr_prv_…` |
| `game_ref` | Yes | Your game ID, matching the game catalogue |
| `operator_ref` | No | Operator ID `pp_opr_…` you are integrated with. Defaults to the alias's integration operator |
| `market` | No | ISO 3166-1 alpha-2, upper case |
| `sessions`, `rounds`, `active_seconds` | At least one of these or `total_bet` | Counts |
| `total_bet`, `total_win`, `jackpot_win` | No | Non-negative amounts |
| `currency` | When any amount is sent | `EUR` |
**game_catalogue**
| Field | Required | Notes |
|---|---|---|
| `game_ref` | Yes | Your game ID |
| `name` | Yes | 1–200 characters |
| `category` | No | `SLOTS`, `LIVE`, `TABLE`, `CRASH`, `INSTANT` or `OTHER` (default) |
| `release_date` | No | `YYYY-MM-DD` |
| `rtp_percent` | No | Greater than 0, at most 100 |
| `volatility` | No | `LOW`, `MEDIUM`, `HIGH` or `VERY_HIGH` |
| `active` | No | Defaults to `true`; only `false` marks a game inactive |
**player_commercial_daily**
| Field | Required | Notes |
|---|---|---|
| `date` | Yes | UTC day, `YYYY-MM-DD`, inside the envelope period |
| `player_ref` | Yes | Your operator alias `pp_plr_opr_…` |
| `currency` | Yes | `EUR` |
| `market` | No | ISO 3166-1 alpha-2, upper case |
| `deposits_count`, `withdrawals_count` | No | Counts |
| `deposits_amount`, `withdrawals_amount`, `stake_amount`, `win_amount`, `bonus_cost` | No | Non-negative amounts |
| `ggr`, `ngr` | No | Amounts, may be negative |
| `first_deposit` | No | Boolean |
**attribution**
| Field | Required | Notes |
|---|---|---|
| `player_ref` | Yes | Your operator alias `pp_plr_opr_…` |
| `affiliate_ref` | Yes | Affiliate party ID `pp_aff_…` |
| `attribution_status` | Yes | `CLAIMED`, `OBSERVED` or `VERIFIED` |
| `attribution_source` | Yes | Short reference, same character rules as `batch_id` |
| `campaign_ref`, `click_ref` | No | Your references |
| `attributed_at` | No | ISO 8601 instant |
Only operators send attribution in this version, as the authoritative source.
player_game_daily record:
```
{
"date": "2026-10-12",
"player_ref": "pp_plr_prv_XXXX",
"game_ref": "your-game-ref",
"market": "MT",
"currency": "EUR",
"sessions": 3,
"rounds": 120,
"total_bet": "60.00",
"total_win": "52.40"
}
```
player_commercial_daily record:
```
{
"date": "2026-10-12",
"player_ref": "pp_plr_opr_XXXX",
"currency": "EUR",
"deposits_count": 1,
"deposits_amount": "50.00",
"ggr": "-12.50",
"first_deposit": false
}
```
## Sign and send the batch
Sign the request with PP-HMAC-SHA256, as in the [Quickstart](./quickstart.mdx), or send an OAuth bearer token. Sign the exact bytes you send: re-serialising the JSON after signing breaks the signature.
PlayerPal answers `202 Accepted` with a receipt. One bad record never rejects the batch: it is listed in `rejections` and the rest are stored.
You get `202` with `records_accepted` equal to the number of records you sent and `replayed: false`.
(Code sample: send-batch. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"batch_id": "daily-2026-10-12-0001",
"schema": "playerpal.provider.player_game_daily.v1",
"received_at": "2026-10-13T01:15:02.000Z",
"records_received": 1,
"records_accepted": 1,
"records_rejected": 0,
"rejections": [],
"replayed": false
}
```
## Read receipts
`ingestReceipt` (see the API reference)
The receipt is stored with the batch, so you can fetch it later by `batch_id` as often as you need. A fetched receipt always has `replayed: true`. An unknown `batch_id` returns `404 batch_not_found`.
`index` in each rejection points into your `records` array. The receipt lists at most 500 rejections; the counts are always complete. Common reasons:
| Reason | Fix |
|---|---|
| `player_ref_invalid` | The value is not a valid reference, for example an email address |
| `player_ref_unknown` | Use an active alias PlayerPal issued to you |
| `date_invalid`, `date_outside_period` | Use `YYYY-MM-DD` inside the envelope period |
| `currency_required`, `currency_unsupported` | Send `EUR` with every amount |
| `activity_indicator_required` | Send `sessions`, `rounds`, `active_seconds` or `total_bet` |
| `operator_ref_not_integrated` | Use an operator you are integrated with, or omit it |
| `affiliate_ref_unknown` | Use the affiliate's PlayerPal party ID |
| `duplicate_in_batch` | Send each natural key once per batch |
| `_invalid`, `record_not_object` | Fix the malformed field or record |
(Parameter table: see the API reference.)
(Code sample: ingest-receipt. See the page for curl, Node.js, Python, PHP, Java and C#.)
A receipt with rejections:
```
{
"batch_id": "pgd-2026-10-14",
"schema": "playerpal.provider.player_game_daily.v1",
"received_at": "2026-10-15T01:15:02.311Z",
"records_received": 1200,
"records_accepted": 1197,
"records_rejected": 3,
"rejections": [
{ "index": 14, "reason": "player_ref_unknown" },
{ "index": 220, "reason": "date_outside_period" },
{ "index": 301, "reason": "duplicate_in_batch" }
],
"replayed": true
}
```
## Resend and correct data
`batch_id` makes retries safe. PlayerPal compares the `schema` and `records` of a repeated `batch_id` with the stored batch.
- **Same records:** you get the original receipt back with `replayed: true`, and nothing is stored twice. Retry a timed-out call with the identical batch.
- **Different records:** `409 idempotency_conflict`. New data always needs a new `batch_id`.
- **Inside one batch:** the last record for each natural key wins; earlier ones are rejected as `duplicate_in_batch`.
To correct data, send the corrected facts in a **new** batch. Records replace the stored fact for the same natural key, and PlayerPal keeps a revision count and the time it received the correction. Resend the full record, not only the changed fields.
Sending the step 3 batch again returns `202` with `replayed: true`. Changing one record and keeping the `batch_id` returns `409`.
Same batch_id, different records:
```
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
```
Correction batch:
```
{
"schema": "playerpal.provider.player_game_daily.v1",
"schema_version": 1,
"batch_id": "daily-2026-10-12-0001-fix1",
"source_party": "GAME_PROVIDER",
"source_id": "pp_prv_XXXX",
"period_start": "2026-10-12T00:00:00Z",
"period_end": "2026-10-13T00:00:00Z",
"generated_at": "2026-10-14T09:00:00Z",
"records": [
{ "date": "2026-10-12", "player_ref": "pp_plr_prv_XXXX",
"game_ref": "your-game-ref", "currency": "EUR",
"rounds": 124, "total_bet": "62.00", "total_win": "52.40" }
]
}
```
## Data rules
| Rule | Detail |
|---|---|
| Players | `player_ref` must be an alias PlayerPal issued to you: `pp_plr_prv_…` through your integrations if you are a studio, `pp_plr_opr_…` if you are an operator. An alias for another audience or a revoked alias is rejected as `player_ref_unknown`. An email address or phone number fails as `player_ref_invalid` before it is ever matched |
| Currency | `EUR` only in this version, because the conversion policy is not yet decided. Other currencies are rejected as `currency_unsupported` |
| Dates | Daily records use UTC days. A record's `date` must fall from the UTC day of `period_start` up to, but not including, `period_end` |
| Markets | ISO 3166-1 alpha-2 in upper case, for example `MT` |
## Errors
| Status | Codes |
|---|---|
| `400` | `envelope_required`, `schema_unsupported`, `schema_version_unsupported`, `batch_id_invalid`, `source_party_not_allowed_for_schema`, `source_id_required`, `period_invalid`, `records_required`, `too_many_records` |
| `401` | Signature or token problems. See [Authentication](./authentication.mdx) |
| `403` | `source_id_must_be_authenticated_party`, `schema_not_allowed_for_party`, `partner_scope_missing` |
| `404` | `batch_not_found` (receipt lookup) |
| `409` | `idempotency_conflict` |
Every error code is listed in [Idempotency and errors](./idempotency-and-errors.mdx).
## Related
- [Read KPIs](./kpi-api.mdx):
Query the metrics PlayerPal computes from your facts.
- [Link players](./linking-players.mdx):
Get the aliases you send facts for.
- [API reference](/api/partner):
Envelope, record and receipt schemas.
---
# Idempotency, retries and errors
Source: https://docs-internal.playerpal.io/docs/developers/idempotency-and-errors
PlayerPal accepts **at-least-once** delivery and makes processing idempotent. You can resend any batch after a timeout, a dropped connection or a `5xx` without creating duplicates, as long as you resend it unchanged under the same ID. This page is the reference for what each endpoint deduplicates on, how to retry, and every error code the partner API returns.
Watch first. This walks through the retry rules below in six short scenes.
## Idempotency by endpoint family
[Available]
| Endpoint family | Deduplicated on | Same ID, same content | Same ID, different content |
|---|---|---|---|
| Fact ingest: `POST /v1/ingest` | Your party + `batch_id` | Original receipt, `replayed: true` | `409` [`idempotency_conflict`](#idempotency_conflict) |
| Activity batches: `POST /v1/activity-batches` | Integration + `batch_id`, and optionally integration + `idempotency-key` header | The existing batch, with its current `state` | `409` [`idempotency_conflict`](#idempotency_conflict) |
| Activity uploads: `POST /v1/activity-uploads/{uploadId}/complete` | The upload, its checksum and `batch_id` | The existing batch | `409` [`idempotency_conflict`](#idempotency_conflict) |
| Linking: `POST /v1/link-results/exchange` | The one-time result code | Not repeatable: a second exchange returns `404` [`result_not_found`](#result_not_found) | Not applicable |
| Reads: `GET` routes | Not needed | Safe to repeat | Not applicable |
How "same content" is decided:
- **`/v1/ingest`** compares a hash of the `records` array and the `schema`. Resend the identical array, in the same order.
- **`/v1/activity-batches`** compares a hash of the events, sorted by `event_id`, so event order does not matter.
- Events are also unique per integration and `event_id`, and each Points award is unique per campaign rule, event and action. A replayed event never awards Points twice.
## Accepted is not processed
A `202` means PlayerPal has durably accepted the batch. Points and analytics are computed afterwards, asynchronously. Do not wait on them before you mark the batch delivered in your outbox.
To correct fact data already sent to `/v1/ingest`, send the corrected facts in a **new** batch. Records upsert by their natural key and the stored revision is incremented.
## Retry safely
1. Give every batch a stable ID (`batch_id`) when you create it, and store it with the batch.
2. On a timeout, connection error or `5xx`, resend the **identical** batch with the same ID.
3. Back off exponentially with jitter between attempts, for example starting at a few seconds and capping at a minute or more. Keep retrying until the batch is accepted; do not drop it after a fixed number of attempts.
4. If you are unsure whether a batch arrived, check it: `GET /v1/ingest/batches/{batchId}` or `GET /v1/activity-batches/{batchId}`. Only poll after an ambiguous response.
5. Do not retry `400`, `401`, `403`, `404` or `409` unchanged. Fix the request first.
| Response | Retry? |
|---|---|
| `2xx` | No. Treat any `202` for your `batch_id` as delivered, including a replay |
| `400`, `409` | No. Fix the request, or use a new ID for new data |
| `404` | No, except `batch_not_found` on a status check after an ambiguous send: the batch did not arrive, so resend it |
| `401`, `403` | No. Fix the credential, clock, signer or scope |
| `500`, `502`, timeout, connection error | Yes, the identical request, with backoff |
| `503` | Not until PlayerPal confirms the feature is enabled for you |
A replayed activity batch reports its current `state`, which may already be past `accepted`.
## Error format
Errors share one JSON body. The machine-readable code is in `message`. Branch on `statusCode` and `message`; the `error` field is the HTTP reason phrase.
Error body:
```
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
```
Two responses do not carry a code. A body that is not valid JSON returns the JSON parser's message, and an unexpected server error returns the body below. Neither includes `error`. Retry a `500` with backoff.
Unexpected server error:
```
{
"statusCode": 500,
"message": "Internal server error"
}
```
## Error codes
Codes are grouped by the endpoint family that returns them. A code used by more than one family is described once, under the first family that uses it, and linked from the others.
## Authentication (all partner routes)
Returned before the route runs. See [Authentication](./authentication.mdx) for the signing rules.
### `partner_auth_required`
`401`. The request has no `Authorization: PP-HMAC-SHA256 …` or `Authorization: Bearer …` header on a partner-credential route. Sign the request, or send an OAuth access token.
### `invalid_signature`
`401`. The authorization header is malformed, the key ID is unknown, expired or revoked, or the signature does not match. Check your signer against the test vectors in [Authentication](./authentication.mdx), and sign the exact body bytes and query string you send.
### `stale_timestamp`
`401`. `X-PP-Timestamp` is missing, is not Unix seconds, or is more than 300 seconds from PlayerPal's clock. Synchronise your clock with NTP and generate the timestamp just before sending.
### `invalid_nonce`
`401`. `X-PP-Nonce` is missing or is not 16 to 128 characters of `A-Z a-z 0-9 _ -`. Generate a random nonce in that alphabet.
### `replayed_nonce`
`401`. The nonce was already used with this key. Generate a fresh nonce for every request, including retries.
### `invalid_token`
`401`. The bearer token failed signature, issuer, audience or expiry checks. Fetch a new token with the token endpoint and audience PlayerPal gave you.
### `unknown_client`
`401`. The token is valid but its client is not registered to an active partner. Contact PlayerPal to register the client.
### `hmac_not_configured`
`401`. Request signing is not enabled in this environment. Contact PlayerPal.
### `oauth_not_configured`
`401`. OAuth tokens are not accepted in this environment. Use request signing, or contact PlayerPal.
### `partner_scope_missing`
`403`. The credential is valid but lacks the route's scope: `ingest.write`, `kpis.read` or `studio.read`. Ask PlayerPal to add the scope.
### `machine_auth_required`
`401`. On the activity and linking routes, the integration key is missing or wrong. Send it as `x-api-key` or `Authorization: Bearer`.
## Fact ingest (`/v1/ingest`)
Envelope errors reject the whole call and store nothing. Record-level problems do not fail the call; see [record rejections](#record-rejections) below. Also returned here: [`partner_scope_missing`](#partner_scope_missing).
### `envelope_required`
`400`. The body is not a JSON object. Send the batch envelope as a JSON object.
### `schema_unsupported`
`400`. `schema` is missing or not a supported schema name. Use one of the schemas in [Send fact batches](./fact-ingest.mdx).
### `schema_version_unsupported`
`400`. `schema_version` is not `1`. Send `1`.
### `batch_id_invalid`
`400`. `batch_id` is missing or is not 1 to 128 characters of `A-Z a-z 0-9 _ . : -`. Use a stable ID in that alphabet.
### `source_party_not_allowed_for_schema`
`400`. `source_party` does not match the schema, for example `OPERATOR` on a game provider schema. Use the source party the schema requires.
### `source_id_required`
`400`. `source_id` is missing or empty. Send your own party ID.
### `period_invalid`
`400`. On ingest, `period_start`, `period_end` or `generated_at` is not a valid timestamp, or the period does not end after it starts. On KPI comparison routes, the `period` value does not match the `grain`. Fix the period.
### `records_required`
`400`. `records` is missing or not an array. Send an array, which may be empty.
### `too_many_records`
`400`. The batch has more than 10,000 records. Split it into several batches, each with its own `batch_id`.
### `source_id_must_be_authenticated_party`
`403`. `source_id` is not the party your credential belongs to. Use the party ID PlayerPal gave you.
### `schema_not_allowed_for_party`
`403`. The schema belongs to another party type, for example an operator sending a game provider schema. Send the schemas for your party type.
### `batch_not_found`
`404`. No batch with that ID exists for your party (`GET /v1/ingest/batches/{batchId}`) or your integration (`GET /v1/activity-batches/{batchId}`, upload completion). Check the ID and the credential or `x-integration-id` you used.
### `idempotency_conflict`
`409`. The ID was already used with different content: a `batch_id` with other records or another schema, an activity `batch_id` or `idempotency-key` with other events, or a completed upload with another checksum. Use a new ID for new data; resend the original unchanged for a retry.
### Record rejections
A rejected record is listed in the receipt as `{ index, reason }` and the rest of the batch is stored. Reasons include `record_not_object`, `date_invalid`, `date_outside_period`, `player_ref_invalid`, `player_ref_unknown`, `game_ref_invalid`, `operator_ref_invalid`, `operator_ref_not_integrated`, `affiliate_ref_invalid`, `affiliate_ref_unknown`, `market_invalid`, `currency_required`, `currency_unsupported`, `activity_indicator_required`, `duplicate_in_batch` and `_invalid` for a malformed field. Each is explained in [Send fact batches](./fact-ingest.mdx).
## Activity (`/v1/activity-batches`, `/v1/activity-uploads`)
Unlike fact ingest, one invalid event rejects the whole activity batch. See [Send activity](./sending-activity.mdx). Also returned here: [`machine_auth_required`](#machine_auth_required), [`batch_not_found`](#batch_not_found), [`idempotency_conflict`](#idempotency_conflict) and [`market_invalid`](#market_invalid).
### `integration_id_required`
`400`. The `x-integration-id` header is missing. Send your integration ID on every activity call.
### `integration_not_found`
`400` on activity routes, `404` on linking and alias routes. The integration ID is unknown or not active. Check the `x-integration-id` value PlayerPal gave you.
### `batch_id_required`
`400`. `batch_id` is missing or empty. Give every batch a stable ID.
### `events_required`
`400`. The batch, or the uploaded file, has no events. Send at least one event.
### `event_id_required`
`400`. An event has no `event_id`, or an uploaded line is not an object. Give every event a unique, stable ID.
### `player_id_required`
`400`. An event has no `player_id`. Send the player's alias.
### `player_id_must_be_provider_alias`
`400`. `player_id` is not a game provider alias (`pp_plr_prv_…`). Use the alias you received when the player linked.
### `event_type_required`
`400`. An event has no `type`. Set the event type.
### `content_type_unsupported`
`400`. The upload content type is not one of `application/x-ndjson`, `application/json`, `application/gzip`, `application/x-gzip`, `text/csv` or `text/plain`. Use a supported type.
### `content_length_required`
`400`. The upload request has no positive whole-number content length. Send the file size in bytes.
### `object_too_large`
`400`. The file is larger than the upload limit, or larger than the size you declared. Split it into several uploads.
### `object_storage_not_configured`
`400`. Bulk uploads are not enabled in this environment. Send batches to `/v1/activity-batches`, or contact PlayerPal.
### `upload_not_found`
`404`. No upload with that ID exists for your integration. Check the `upload_id` from the upload request.
### `upload_expired`
`400`. The upload window closed before you completed it. Request a new upload.
### `checksum_required`
`400`. The completion has no SHA-256 checksum of 64 hex characters. Send the checksum of the uploaded file.
### `checksum_mismatch`
`400`. The uploaded file does not match the checksum you sent. Hash the exact bytes you uploaded.
### `upload_incomplete`
`400`. PlayerPal found no file at the upload URL. Finish the upload before completing it.
### `object_empty`
`400`. The uploaded file is empty. Upload a file with events.
### `format_unsupported`
`400`. The file is in a format PlayerPal does not read, such as Parquet. Use JSON, NDJSON or CSV, optionally gzipped.
### `gzip_invalid`
`400`. The file is declared as gzip but cannot be decompressed. Check the compression.
### `object_not_json`
`400`. A line or document in the file is not valid JSON. Fix the file.
### `row_count_mismatch`
`400`. The number of events in the file differs from the row count you declared. Check the file or the count.
## Linking (`/v1/link-intents`, `/v1/link-results/exchange`, `/v1/aliases/{playerAlias}`)
See [Link players](./linking-players.mdx). Also returned here: [`machine_auth_required`](#machine_auth_required) and [`integration_not_found`](#integration_not_found).
### `return_origin_required`
`400`. `return_origin` is missing. Send the origin PlayerPal returns the player to.
### `return_origin_invalid`
`400`. `return_origin` is not a bare `http` or `https` origin: it has a path, query or fragment, or does not parse. Send only scheme, host and port.
### `operator_assertion_required`
`400`. An operator-led link intent has no `operator_assertion`. Sign a short-lived assertion with your registered key.
### `assertion_invalid`
`400`. The assertion's signature, audience or algorithm failed, or it lacks `iss`, `sub` or `jti`. Sign with your registered key using ES256 or RS256.
### `assertion_expired`
`400`. The assertion has expired, or lives longer than five minutes. Sign a new assertion for each link intent.
### `assertion_replay`
`401`. The assertion's `jti` was already used. Use a fresh `jti` for every assertion.
### `assertion_iss_mismatch`
`400`. The assertion's `iss` is not your operator ID. Set `iss` to your operator ID.
### `assertion_forbidden_claims`
`400`. The assertion contains personal data such as an email address, name or phone number. Remove those claims; PlayerPal never receives them.
### `jwks_unavailable`
`400`. PlayerPal could not fetch your registered signing keys, or none are registered. Check that your key set URL is reachable and lists keys.
### `integration_not_authorized_for_operator`
`400`. A requested game provider integration does not belong to your operator. Request only your own integrations.
### `scope_override_rejected`
`400`. A game provider link intent also sent an operator header, an assertion or an audience in the body. With `x-integration-id`, send only `return_origin`.
### `party_not_found`
`404`. The operator ID is unknown or is not an operator. Check the `x-operator-id` value.
### `result_code_required`
`400`. The exchange has no `result_code`. Send the code your page received.
### `result_not_found`
`404`. The result code is unknown, expired or already exchanged. Start a new link.
### `provider_alias_missing`
`400`. The link has no alias for the integration in `x-integration-id`. Check that the link intent was created for that integration.
### `player_alias_required`
`400`. The alias in the path is empty. Send the alias you hold.
### `audience_required`
`400`. An alias status request has neither `x-operator-id` nor `x-integration-id`. Send the header for your audience.
### `alias_not_found`
`404`. The alias was not issued to your audience. On alias status, treat the player as unlinked. On studio analytics, check the alias.
## KPIs (`/v1/kpis/*`)
See [KPI API](./kpi-api.mdx). Also returned here: [`partner_scope_missing`](#partner_scope_missing) and [`period_invalid`](#period_invalid).
### `kpi_unknown`
`400`. The `kpi` is not in the catalogue. Read `GET /v1/kpis/catalogue` for valid IDs.
### `kpi_not_available_for_party`
`400`. The KPI exists but not for your party type. Use a KPI from your catalogue.
### `date_range_invalid`
`400`. `from` or `to` is missing, not `YYYY-MM-DD`, or `from` is after `to`. Also returned by studio analytics. Fix the range.
### `date_range_too_long`
`400`. The range covers 400 days or more. Shorten it.
### `grain_invalid`
`400`. `grain` is not one of the values the route accepts (`day`, `week` or `month`; comparisons take `week` or `month`). Use a supported grain.
### `market_invalid`
`400`. `market` is not a two-letter uppercase code. Also returned by activity events. Use a code such as `MT`.
### `market_not_supported`
`400`. The KPI cannot be split by market. Leave out `market`.
### `breakdown_not_supported`
`400`. The KPI has no breakdown. Choose a KPI that supports one.
### `dimension_invalid`
`400`. The breakdown or explore dimension is not supported. Use one the route lists.
### `filter_invalid`
`400`. An explore filter (`game`, `market` or `partner`) is malformed or unknown, or a required filter is missing. Use values from `GET /v1/kpis/explore/options`.
### `game_not_found`
`404`. The game is not in your catalogue. Check the game reference.
### `insight_not_found`
`404`. The insight ID is unknown. List insights again for current IDs.
### `month_range_invalid`
`400`. A monthly range is not `YYYY-MM`, or starts after it ends. Fix the range.
### `month_range_too_long`
`400`. A monthly range spans more than 24 months. Shorten it.
### `overview_not_available_for_party`
`400`. Your party type has no overview. Contact PlayerPal.
### `period_not_closed`
`400`. The comparison period has not ended yet. Compare a completed week or month.
### `kpi_backend_error`
`502`. A temporary server-side problem. Retry with backoff.
### `kpi_backend_not_configured`
`503`. KPIs are not enabled in this environment. Contact PlayerPal.
## Studio analytics (`/v1/analytics/studio*`)
For game providers. Also returned here: [`partner_scope_missing`](#partner_scope_missing), [`date_range_invalid`](#date_range_invalid) and [`alias_not_found`](#alias_not_found).
### `provider_party_required`
`403` when your credential is not a game provider's, `400` when no game provider party is identified. Use a game provider credential.
### `date_range_required`
`400`. `from` or `to` is missing or not `YYYY-MM-DD`. Send both dates.
### `provider_not_found`
`404`. The game provider is unknown. Contact PlayerPal.
### `studio_cohort_suppressed`
`404`. Fewer accounts than the privacy threshold played in the range, so no figures are returned. Widen the range. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
## Rate limits
PlayerPal does not publish rate limits today. Send many records per batch rather than many small calls, and contact PlayerPal before a large backfill.
## Related
- [Design for availability](./availability.mdx):
The outbox pattern that makes retries safe.
- [Authentication](./authentication.mdx):
Signing, tokens, the integration key and scopes.
- [API reference](/api/partner):
Every endpoint and response schema.
---
# Developers
Source: https://docs-internal.playerpal.io/docs/developers
Connect your casino or games to PlayerPal: link players without exchanging personal data, send activity and daily facts, and read analytics about your own players. Every integration is asynchronous, so nothing you build sits in the path of login, game launch, wagering or payouts.
- [Quickstart](./quickstart.mdx):
Sign a request, send your first batch and read a KPI back.
- [Authentication](./authentication.mdx):
Request signing, partner tokens, the integration key and scopes.
- [API reference](/api/partner):
Every partner endpoint, generated from the API's own code.
## Choose your integration
## Guides
- [Link players](./linking-players.mdx):
Connect casino accounts and receive pairwise aliases.
- [Send activity](./sending-activity.mdx):
Settled rounds in JSON batches or bulk file uploads.
- [Fact ingest](./fact-ingest.mdx):
Daily gameplay, commercial, catalogue and attribution facts.
- [KPI API](./kpi-api.mdx):
Your KPIs, breakdowns and anonymous network comparisons.
- [Widget](./widget.mdx):
Show PlayerPal inside your game pages.
- [Webhooks](./webhooks.mdx):
Notices PlayerPal sends you, and how to reconcile.
## Reference
- [Errors and idempotency](./idempotency-and-errors.mdx):
Every error code, retries and safe resends.
- [Metric definitions](../analytics/metrics.mdx):
Grain, source, window and suppression for each KPI.
- [Availability](./availability.mdx):
How to integrate so play never depends on PlayerPal.
- [Changelog](./changelog.mdx):
Changes to the partner API.
## Tools
| | |
|---|---|
| **OpenAPI document** | [`/openapi/partner.json`](pathname:///openapi/partner.json): OpenAPI 3.1, for code generators and API clients |
| **Code samples** | curl, Node.js, Python, PHP, Java and C# on every guide. Your language choice is remembered across pages |
| **For AI assistants** | [`/llms.txt`](pathname:///llms.txt) indexes every page; [`/llms-full.txt`](pathname:///llms-full.txt) has all of it. Each page also has **Copy page** |
| **Sandbox** | Not available yet. Ask your PlayerPal contact about test credentials |
---
# Read KPIs
Source: https://docs-internal.playerpal.io/docs/developers/kpi-api
The KPI API returns metrics about the players linked to you, computed by PlayerPal from your [activity](./sending-activity.mdx), your [facts](./fact-ingest.mdx) and your Links. Every endpoint is a read-only `GET` under `/v1/kpis`. Your organisation is taken from your credential: there is no party parameter, and you can never query another partner's numbers. Metric definitions are in [Metrics](../analytics/metrics.mdx).
## Endpoint map
The catalogue and own-tier KPIs serve operators and game providers. The overview also serves affiliates.
| Group | Endpoint | Use it when |
|---|---|---|
| **Own KPIs** | `GET /v1/kpis/catalogue` | You need the KPI IDs your party type can read |
| | `GET /v1/kpis/own` | Charting one metric by day, week or month |
| | `GET /v1/kpis/breakdown` | Ranking markets, games or counterparts for one metric |
| | `GET /v1/kpis/overview` | Showing every overview KPI with availability and trend in one call |
| **Comparison** | `GET /v1/kpis/compare` | Comparing your players with the rest of the PlayerPal network |
| | `GET /v1/kpis/compare/summary` | Every comparison KPI for a period in one call |
| | `GET /v1/kpis/benchmarks` | Comparing your totals with anonymous peers of your type |
| | `GET /v1/kpis/cohorts/retention` | Monthly first-play cohorts and a pooled network curve |
| | `GET /v1/kpis/funnels/link` | Seeing how Links turn into play |
| **Insights** | `GET /v1/kpis/intelligence/insights`, `/insights/{id}` | Ranked, explained changes and opportunities |
| | `GET /v1/kpis/intelligence/ask`, `/suggestions` | Mapping a short question to a fixed analysis |
| **Explore** | `GET /v1/kpis/explore/*` | Building detailed analytics views with filters |
Every KPI call is recorded in PlayerPal's audit log with your organisation and client. Each endpoint's parameters are in the [API reference](/api/partner).
## List your catalogue
`kpisCatalogue` (see the API reference)
Start here. The catalogue lists the own-tier KPIs your party type can read, with each KPI's `unit`, the `grains` it supports, whether it takes a `market` filter (`markets`) and whether the account floor applies (`account_floor`).
| KPI | Unit | Who |
|---|---|---|
| `own.rounds`, `own.active_accounts` | count | Operator, provider |
| `own.bet_eur`, `own.win_eur`, `own.ggr` | EUR | Operator, provider |
| `own.linked_players`, `own.new_links`, `own.unlinks` | count | Operator, provider |
| `own.link_intents`, `own.link_conversion` | count, ratio | Operator |
| `own.points_issued` | points | Operator, provider |
| `own.ingest_acceptance` | ratio | Operator, provider |
| `own.batch_latency_p95` | seconds | Provider |
(Code sample: kpi-catalogue. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"kpis": [
{ "id": "own.rounds", "label": "Rounds", "unit": "count",
"markets": true, "grains": ["day", "week", "month"],
"account_floor": true },
{ "id": "own.new_links", "label": "New links", "unit": "count",
"markets": false, "grains": ["day", "week", "month"],
"account_floor": false }
]
}
```
## Read one KPI
`kpisOwn` (see the API reference)
`own` returns one KPI as a series, with the total, the previous period and the change between them.
- `from` and `to` are `YYYY-MM-DD`, UTC and inclusive, and the range must be under 400 days.
- `grain` is `day` (default), `week` (ISO weeks from Monday) or `month`.
- `market` is ISO 3166-1 alpha-2, for example `MT`, and works only for KPIs with `markets: true`.
Every period in the range is returned. A period with no activity is `0`, or `null` for ratios and latencies. `previous` is the period of equal length just before `from`, and `previous_series` aligns it with `series` for overlays. `delta_pct` is `null` when either total is missing or the previous total is `0`.
(Parameter table: see the API reference.)
You get `200` with one `series` entry for every week in the range. A week backed by too few accounts shows `"suppressed": true`.
(Code sample: read-kpi. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"kpi": "own.rounds", "label": "Rounds", "unit": "count",
"from": "2026-09-01", "to": "2026-09-30", "grain": "week",
"market": null,
"series": [
{ "period": "2026-08-31", "value": 1240 },
{ "period": "2026-09-07", "value": null, "suppressed": true },
{ "period": "2026-09-14", "value": 1310 },
{ "period": "2026-09-21", "value": 1290 },
{ "period": "2026-09-28", "value": 1280 }
],
"total": { "value": 5160 },
"previous": { "from": "2026-08-02", "to": "2026-08-31", "value": 4800 },
"previous_series": [
{ "value": 610 }, { "value": 1150 }, { "value": 1180 },
{ "value": 1220 }, { "value": 1240 }, { "value": 1200 }
],
"delta_pct": 0.075,
"generated_at": "2026-10-13T12:00:00.000Z"
}
```
## Break it down
`kpisBreakdown` (see the API reference)
`breakdown` takes the same parameters as `own`, except `grain`, plus `dimension`: `market`, `game` or `partner`. A partner is one of your own integration counterparts: a studio if you are an operator, an operator if you are a studio. Breakdowns support activity KPIs only; others return `400 breakdown_not_supported`.
The response holds the top 10 rows by value. For additive KPIs, each row has its `share` of the visible total and the remaining rows are folded into one `Other (n)` row. Rows under the account floor are never listed; they are counted in `suppressed_rows`.
(Parameter table: see the API reference.)
(Code sample: kpi-breakdown. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"kpi": "own.rounds", "label": "Rounds", "unit": "count",
"dimension": "market",
"from": "2026-09-01", "to": "2026-09-30", "market": null,
"rows": [
{ "key": "MT", "label": "MT", "value": 3100, "share": 0.6055 },
{ "key": "DE", "label": "DE", "value": 1400, "share": 0.2734 },
{ "key": "FI", "label": "FI", "value": 620, "share": 0.1211 }
],
"suppressed_rows": 2
}
```
## Compare with the network
`kpisCompare` (see the API reference)
`compare` takes a `cmp.*` KPI and a closed period: `grain=month` with `period=2026-09`, or `grain=week` with `period=2026-W38`. An open or future period returns `400 period_not_closed`.
- `you` is your players' median for per-player KPIs, or your ratio for party KPIs.
- `index` is you ÷ network median × 100, and `band` is the network quartile your value falls in.
- Network size and party counts are given only as bands, never exact numbers.
- With a `market`, a market too thin to publish falls back to the global figure with `fallback: "global"`.
The network side is published only when the rest of the network spans at least 5 parties of your type and 50 accounts, and no single party holds more than 50% of the measure (or two more than 75%).
`kpisBenchmarks` (see the API reference)
`benchmarks` compares your totals with anonymous peers of your type, using the same `period`, `grain` and `market`. It takes `own.rounds`, `own.bet_eur`, `own.ggr` or `own.active_accounts` (global or per market), or `own.linked_players`, `own.ingest_acceptance` or `own.link_conversion` (global only). A segment is published only with at least 6 parties in it, you plus 5 others, and no dominant peer.
GET /v1/kpis/compare?grain=month&kpi=cmp.bet_per_player&period=2026-09:
```
{
"kpi": "cmp.bet_per_player", "period": "2026-09", "grain": "month",
"from": "2026-09-01", "to": "2026-09-30",
"market": null, "fallback": null,
"you": 45,
"network": { "baseline": "rest_of_network",
"p25": 20, "p50": 36, "p75": 52, "mean": 38.4,
"size_band": "100-999", "parties_band": "5-9" },
"index": 125, "band": "p50-p75",
"suppressed": false, "reason": null,
"computed_at": "2026-10-01T06:00:00.000Z"
}
```
GET /v1/kpis/benchmarks?grain=month&kpi=own.bet_eur&period=2026-09:
```
{
"kpi": "own.bet_eur", "period": "2026-09", "grain": "month",
"from": "2026-09-01", "to": "2026-09-30",
"market": null, "fallback": null,
"peers": { "party_type": "OPERATOR", "count_band": "5-9",
"p25": 1200, "p50": 1800, "p75": 2600, "mean": 1950 },
"band": "p0-p25", "suppressed": false, "reason": null,
"computed_at": "2026-10-01T06:00:00.000Z"
}
```
## Handle suppression and errors
PlayerPal withholds any cell that describes too few people, and the rule is the same for every caller. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
- **Own KPIs and breakdowns:** for KPIs with `account_floor: true`, a cell backed by fewer than 50 distinct Accounts comes back as `"value": null, "suppressed": true`. Show it as withheld, never as zero.
- **Comparisons and benchmarks:** a withheld value has `suppressed: true` and a `reason`.
| `reason` | Meaning |
|---|---|
| `peer_floor` | The rest of the network spans fewer than 5 parties of your type |
| `account_floor` | The rest of the network has fewer than 50 accounts |
| `dominance` | One party has more than 50% of the measure, or two have more than 75% |
| `y_floor` | Your own population is under 50 accounts; the network is still shown |
| `elsewhere_floor` | Your players' activity elsewhere spans too few parties, or one dominates it |
| `not_computed` | No data for that period yet |
| Status | Codes |
|---|---|
| `400` | `kpi_unknown`, `kpi_not_available_for_party`, `date_range_invalid`, `date_range_too_long`, `grain_invalid`, `market_invalid`, `market_not_supported`, `dimension_invalid`, `breakdown_not_supported`, `month_range_invalid`, `month_range_too_long`, `period_not_closed`, `filter_invalid` |
| `403` | `partner_scope_missing`: the credential lacks `kpis.read` |
| `404` | Codes ending in `_not_found`, such as `insight_not_found` |
| `502`, `503` | `kpi_backend_error`, `kpi_backend_not_configured`. Retry later |
A comparison withheld:
```
{
"kpi": "cmp.bet_per_player", "period": "2026-09", "grain": "month",
"from": "2026-09-01", "to": "2026-09-30",
"market": null, "fallback": null,
"you": 45, "network": null,
"index": null, "band": null,
"suppressed": true, "reason": "peer_floor",
"computed_at": "2026-10-01T06:00:00.000Z"
}
```
Range too long:
```
{
"statusCode": 400,
"message": "date_range_too_long",
"error": "Bad Request"
}
```
## Overview, cohorts, insights and Explore
**Overview.** `GET /v1/kpis/overview?from=…&to=…` returns every overview KPI for your audience, each with `value`, `trend`, `source`, `lastUpdated` and a `dataAvailability` that is never faked as zero: `available`, `requires_feed` (with a `requirement` naming the missing feed), `not_applicable` (for example commercial KPIs for studios) or `suppressed`. It also returns chart `panels`, each with its own availability. Operators see their own players, studios see players of their games, and affiliates see only their verified-attributed players.
**Cohorts and funnels.** `/cohorts/retention` takes `from` and `to` as `YYYY-MM`, defaults to the last 12 closed months and allows at most 24. `/funnels/link` takes `YYYY-MM-DD` dates; the whole funnel is suppressed when its first step is under the account floor.
**Insights.** `/intelligence/insights` returns ranked insights and an executive summary from fixed rules and statistical tests. No language model computes or writes them. `/intelligence/ask` takes `q` (up to 300 characters are used) and routes it to one fixed analysis; it never runs free-form queries. See [Insights](../analytics/insights.mdx).
**Explore.** `/v1/kpis/explore/options`, `performance`, `summary`, `dimension` (with `dim=game`, `market` or `partner`), `players`, `retention`, `segments`, `funnel`, `game`, `comparison` and `benchmark-history` back detailed analytics views. Each takes `from` and `to`, plus optional `game`, `market` and `partner` filters. Call `explore/options` first: it lists only filter values with at least 50 players. A malformed filter, or a partner that is not one of your counterparts, returns `400 filter_invalid`.
## Related
- [Metrics](../analytics/metrics.mdx):
How each KPI is defined.
- [Fact ingest](./fact-ingest.mdx):
Deliver the feeds the overview needs.
- [API reference](/api/partner):
Every KPI endpoint and parameter.
---
# Link players
Source: https://docs-internal.playerpal.io/docs/developers/linking-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.
## 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: ` or `Authorization: Bearer `.
- 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://"
export PP_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": "",
"expires_at": "2026-10-15T12:05:00.000Z"
}
```
Assertion claims:
```
{
"iss": "pp_opr_XXXX",
"sub": "",
"aud": "",
"jti": "",
"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": "",
"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.
---
# Onboard as a partner
Source: https://docs-internal.playerpal.io/docs/developers/onboarding
This page is your integration path from a signed agreement to live traffic. PlayerPal provides your identity and credentials; you build a link flow, deliver data asynchronously and, if you are a game provider, optionally embed the widget. None of it sits in your login, launch, wagering or payout path.
:::info[Before you start]
There is no public sandbox yet and no self-service console. PlayerPal issues every credential and ID during onboarding, and arranges a test environment with test credentials for your integration. Contact PlayerPal to start.
:::
## Your path
Pick your role. Each track runs from contract to go-live; every step links to the guide that covers it.
**Operator**
### PlayerPal registers you
PlayerPal registers your organisation as an operator and issues your party ID (`pp_opr_…`), partner credentials and scopes.
### Sign your first request
Set up HMAC signing or OAuth and make one round trip. See [Authentication](./authentication.mdx) and the [Quickstart](./quickstart.mdx).
### Build the link flow
Let players connect their account to PlayerPal and receive the aliases you send data for. See [Link players](./linking-players.mdx).
### Send daily fact batches
Deliver pseudonymous daily facts that feed your KPIs. See [Send fact batches](./fact-ingest.mdx).
### Send activity, where agreed
If your agreement covers it, deliver settled-round activity from an outbox for Points. See [Send activity](./sending-activity.mdx).
### Reconcile link state
Handle player link notices and refresh alias status when you display link state. See [Player link notices](./webhooks.mdx).
### Read your KPIs (optional)
Pull your own KPIs for a date range. See [KPI API](./kpi-api.mdx).
### Go live
Work through the [go-live checklist](#go-live-checklist) with PlayerPal, then switch on production traffic.
**Game provider**
### PlayerPal registers you
PlayerPal registers your organisation as a game provider and issues your party ID (`pp_prv_…`), an integration ID (`pp_int_…`) for each operator integration, partner credentials, scopes and the integration key.
### Sign your first request
Set up HMAC signing or OAuth and make one round trip. See [Authentication](./authentication.mdx) and the [Quickstart](./quickstart.mdx).
### Build the link flow
Support the linking modes your operators use and receive provider aliases (`pp_plr_prv_…`). See [Link players](./linking-players.mdx).
### Send activity from an outbox
Write settled rounds to a local outbox and deliver them in batches for Points. See [Send activity](./sending-activity.mdx) and [Design for availability](./availability.mdx).
### Send daily fact batches
Deliver pseudonymous daily gameplay facts that feed KPIs. See [Send fact batches](./fact-ingest.mdx).
### Reconcile link state
Handle player link notices and refresh alias status when you display link state. See [Player link notices](./webhooks.mdx).
### Add the widget and KPIs (optional)
Embed the [widget](./widget.mdx) on your game page, and read KPIs and studio analytics through the [KPI API](./kpi-api.mdx).
### Go live
Work through the [go-live checklist](#go-live-checklist) with PlayerPal, then switch on production traffic.
## What PlayerPal provides vs what you build
[Available]
| PlayerPal provides | You build |
|---|---|
| **Party ID.** Your public ID: `pp_opr_…` for operators, `pp_prv_…` for game providers. Used as `source_id` in fact batches. | **Link flow.** Operators and game providers; required. [Link players](./linking-players.mdx) |
| **Integration ID.** For game providers, the ID of each operator integration (`pp_int_…`). Sent as `x-integration-id` on activity and linking calls. | **Activity delivery from an outbox.** Game providers, and operators where agreed; required for Points. [Send activity](./sending-activity.mdx) |
| **Partner credentials.** An HMAC key ID and secret, an OAuth client, or both. The HMAC secret is shown once. | **Daily fact batches.** Operators and game providers; required for KPIs. [Send fact batches](./fact-ingest.mdx) |
| **Scopes.** `ingest.write` to send facts, `kpis.read` to read KPIs, `studio.read` for game provider studio analytics. | **KPI reads.** Operators and game providers; optional. [KPI API](./kpi-api.mdx) |
| **Integration key.** The current key for activity and linking routes. Per-partner credentials for these routes are in development. | **Widget.** Game providers; optional. [Widget](./widget.mdx) |
| **Test environment.** Arranged with test credentials on request. | **Alias status reconciliation.** Operators and game providers; recommended. [Player link notices](./webhooks.mdx) |
You can hold up to two active HMAC keys. PlayerPal can rotate a key with a 24-hour overlap or revoke one immediately. Keep test and production credentials separate, and never reuse a key across environments.
Design every part so it fails open: if PlayerPal is unavailable, players keep playing. See [Design for availability](./availability.mdx).
## Go-live checklist
[Available]
1. **Credentials.** Production credentials issued with exactly the scopes you need, stored in a secret manager, never in client-side code.
2. **Signing.** Your HMAC signer reproduces the test vectors in [Authentication](./authentication.mdx) and your servers keep accurate time (within 300 seconds).
3. **Linking.** A test player can link and unlink, and your platform shows the correct state after each.
4. **No synchronous dependency.** Login, game launch, wagering, settlement and payouts work with PlayerPal blocked at your network edge.
5. **Outbox.** Activity is written in the same transaction as settlement, delivered in batches with a stable `batch_id`, and retried with backoff until accepted.
6. **Idempotency.** Resending a batch returns the original receipt (`replayed: true` on fact ingest).
7. **Receipts.** You monitor `records_rejected` and alert on rejections such as `player_ref_unknown` or `currency_unsupported`.
8. **Currency.** Amounts are sent in EUR. Other currencies are not accepted yet.
9. **Reconciliation.** You refresh alias status when displaying link state, not per wager.
With PlayerPal blocked at your network edge, a test player can still log in, launch a game, play and be paid; when you unblock it, the queued batches deliver and their receipts show no unexpected rejections.
## Related
- [Quickstart](./quickstart.mdx):
Sign a request, send a batch and read a KPI back.
- [Authentication](./authentication.mdx):
Signing, tokens, the integration key and scopes.
- [FAQ](../product/faq.mdx):
Common questions from partners.
---
# Quickstart
Source: https://docs-internal.playerpal.io/docs/developers/quickstart
Make one complete round trip with the PlayerPal Partner API: sign a request, send a batch of daily facts, confirm the receipt and read a KPI back. Everything here runs outside game launch, wagering and settlement, so nothing you build in this guide can block play.
:::tip[Using OAuth instead?]
If PlayerPal registered an OAuth client for you, send `Authorization: Bearer ` and skip step 2. Everything else is the same. See [Authentication](./authentication.mdx).
:::
## Set up your credentials
PlayerPal issues credentials during [onboarding](./onboarding.mdx). For request signing you receive a key ID (`pp_kid_…`) and a secret (`pp_sk_…`).
- The secret is shown **once**. Store it in your secret manager, never in source code.
- Ask for both scopes you need. A key issued without named scopes can only read KPIs (`kpis.read`); sending facts needs `ingest.write`.
- You can hold two active keys at a time. After a rotation, the old key keeps working for 24 hours.
The samples in this guide read three environment variables.
Environment:
```
export PP_API_BASE="https://"
export PP_KEY_ID="pp_kid_XXXX"
export PP_SECRET=""
```
## Sign every request
Each request carries three headers: `Authorization`, `X-PP-Timestamp` and `X-PP-Nonce`. The signature is an HMAC-SHA256 of a **canonical string**: six lines joined with `\n`, no trailing newline.
1. HTTP method, uppercase
2. Path, without the query string
3. Query pairs sorted by key then value, RFC 3986 encoded, joined with `&` (empty if none)
4. Lowercase hex SHA-256 of the exact body bytes (of the empty string for `GET`)
5. The timestamp, in Unix seconds
6. The nonce, 16 to 128 characters of `A-Z a-z 0-9 _ -`, never reused
The signature is the result, base64url-encoded without padding. The timestamp must be within 300 seconds of PlayerPal's clock.
With secret `pp_sk_test-vector-secret`, timestamp `1760000000` and nonce `nonce_0123456789abcdef`, signing `GET /v1/kpis/own?to=2026-09-30&kpi=own.rounds&from=2026-09-01` gives `NhQFk3mISFyIhDIMHBwZ-pWBoO5pKuFk_yWDVycaOyY`. Every sample on this page is tested against this value.
(Code sample: sign. See the page for curl, Node.js, Python, PHP, Java and C#.)
## Send your first batch
`ingestSubmit` (see the API reference)
A batch is one envelope with records of one schema. This example sends one player's daily gameplay as a game provider. Operators send operator schemas the same way; see [Fact ingest](./fact-ingest.mdx) for every schema.
- `source_id` must be your own party ID.
- `player_ref` must be an alias PlayerPal issued to you. Anything else, such as an email address, is rejected.
- Sign the exact bytes you send. Re-serialising the JSON after signing breaks the signature.
(Parameter table: see the API reference.)
(Code sample: send-batch. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"batch_id": "daily-2026-10-12-0001",
"schema": "playerpal.provider.player_game_daily.v1",
"received_at": "2026-10-13T01:15:02.000Z",
"records_received": 1,
"records_accepted": 1,
"records_rejected": 0,
"rejections": [],
"replayed": false
}
```
## Read the receipt
PlayerPal answers `202 Accepted` with a receipt. Records are validated one by one: a bad record is listed in `rejections` as `{ index, reason }` and never fails the rest of the batch.
The `batch_id` is your idempotency key. If a call times out, send the **identical** batch again: you get the original receipt back with `replayed: true`, and nothing is stored twice. Sending different records under the same `batch_id` returns `409 idempotency_conflict`.
(Parameter table: see the API reference.)
`records_accepted` is `1`. Send the same batch again and the receipt comes back with `replayed: true`.
`ingestReceipt` (see the API reference)
Same batch_id, different records:
```
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
```
A rejected record in the receipt:
```
{
"records_received": 2,
"records_accepted": 1,
"records_rejected": 1,
"rejections": [{ "index": 1, "reason": "player_ref_invalid" }],
"replayed": false
}
```
## Read a KPI
`kpisOwn` (see the API reference)
Every `/v1/kpis` route needs the `kpis.read` scope and is signed the same way; a `GET` signs the hash of the empty body. Send the query string exactly as you signed it.
`own` returns one KPI from your [catalogue](./kpi-api.mdx) as a series, with the total, the same-length previous period and the change between them. A period backed by fewer than 50 distinct accounts comes back `suppressed` instead of a value. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
You get `200` with a `series` covering every week in the range.
(Code sample: read-kpi. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"kpi": "own.rounds", "label": "Rounds", "unit": "count",
"from": "2026-09-01", "to": "2026-09-30", "grain": "week",
"series": [
{ "period": "2026-08-31", "value": 1240 },
{ "period": "2026-09-07", "value": null, "suppressed": true }
],
"total": { "value": 5120 },
"previous": { "from": "2026-08-02", "to": "2026-08-31", "value": 4800 },
"delta_pct": 0.0667,
"generated_at": "2026-10-13T12:00:00.000Z"
}
```
## If something goes wrong
| Response | Cause | Fix |
|---|---|---|
| `401 invalid_signature` | The canonical string or secret differs from PlayerPal's | Check your signer against the test value in step 2; sign the exact body bytes |
| `401 stale_timestamp` | Your clock is more than 300 seconds off | Sync the clock with NTP |
| `401 replayed_nonce` | A nonce was reused | Generate a fresh random nonce per request |
| `403 partner_scope_missing` | The key lacks the route's scope | Ask PlayerPal to add `ingest.write` or `kpis.read` |
| `403 source_id_must_be_authenticated_party` | `source_id` is not your party ID | Use the party ID PlayerPal gave you |
| `409 idempotency_conflict` | Same `batch_id`, different records | Use a new `batch_id` for new data |
Every error code is listed in [Idempotency and errors](./idempotency-and-errors.mdx).
## Next steps
- [Link players](./linking-players.mdx):
Connect casino accounts to PlayerPal and receive the aliases you send facts for.
- [Send activity](./sending-activity.mdx):
Stream game rounds in batches or bulk uploads for points and analytics.
- [API reference](/api/partner):
Every partner endpoint, with parameters and responses, generated from the API.
---
# Send player activity
Source: https://docs-internal.playerpal.io/docs/developers/sending-activity
Game providers send PlayerPal the rounds that linked players settle. PlayerPal turns eligible rounds into Points and into the activity KPIs you can read from the [KPI API](./kpi-api.mdx). Activity is sent after settlement, from your backend, asynchronously. It never sits in the wager or settlement path, so a PlayerPal outage cannot block play.
## Batches or uploads?
| | Activity batch | Activity upload |
|---|---|---|
| Endpoint | `POST /v1/activity-batches` | `POST /v1/activity-uploads`, then `PUT` the file, then complete |
| Format | JSON body | NDJSON, JSON, CSV, or any of those gzip-compressed |
| Best for | Frequent small batches sent close to settlement | Large periodic exports, backfills |
| Size | One request body | Up to the upload limit (64 MiB by default) |
Both paths end in the same batch, with the same validation, states and idempotency.
## What PlayerPal does with your events today
[Available]
- **The whole batch is rejected (`400`) if any event fails basic checks**: a missing `event_id`, `player_id` or `type`, a `player_id` that is not a provider alias, or a malformed `market`. Nothing is stored. Fix the event and send the batch again.
- **Only `round_settled` is processed.** Other types are accepted and then dropped.
- **Only `EUR` is processed.** `currency` defaults to `EUR`; events in any other currency are dropped.
- **Events for unlinked aliases are dropped**: unknown or revoked aliases, and rounds whose `occurred_at` is missing, before the Link's `effective_from` or at or after an Unlink.
- Events without a positive `bet`, or with a `market` not configured for your integration, are also dropped.
Dropped events never fail the rest of the batch, and they earn nothing.
## Set up the integration key
Activity routes use the integration key PlayerPal issues during [onboarding](./onboarding.mdx), sent as `x-api-key: ` or `Authorization: Bearer `.
Every request also sends `x-integration-id: pp_int_XXXX`. The batch belongs to that integration only; `operator_id` in a body is accepted and ignored. Keep the key server-side.
(Code sample: integration-key. See the page for curl, Node.js, Python, PHP, Java and C#.)
Environment:
```
export PP_API_BASE="https://"
export PP_INTEGRATION_KEY=""
export PP_INTEGRATION_ID="pp_int_XXXX"
```
## Send a batch
`activitySubmitBatch` (see the API reference)
A batch is a `batch_id` and a list of events. Open `events` below to see every event field.
(Parameter table: see the API reference.)
- `event_id` is your stable ID for the event, unique per integration. Do not reuse it in another batch.
- `player_id` is the provider alias from [linking](./linking-players.mdx), starting `pp_plr_prv_`.
- `occurred_at` is the settlement time in ISO 8601 UTC. Without it the event cannot qualify.
- `bet` and `win` are decimal strings with up to two decimal places, such as `"2.50"`.
- `market` is ISO 3166-1 alpha-2 in upper case, such as `MT`. If you omit it and your integration has exactly one market, that market is used.
(Parameter table: see the API reference.)
(Code sample: activity-batch. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"batch_id": "rounds-2026-10-15-0001",
"state": "accepted",
"received_events": 1,
"received_at": "2026-10-15T12:04:00.120Z"
}
```
One event fails basic checks:
```
{
"statusCode": 400,
"message": "player_id_must_be_provider_alias",
"error": "Bad Request"
}
```
## Check processing
`activityGetBatch` (see the API reference)
`202` means PlayerPal has stored the batch. Processing happens afterwards. Poll the batch with backoff to follow it.
| `state` | Meaning |
|---|---|
| `accepted` | Stored, waiting to be processed |
| `processing` | Events are being evaluated |
| `processed` | Every event was evaluated |
| `processed_with_errors` | Processing finished but some events are still pending |
`completed_at` stays `null` until processing finishes. Per-event outcomes, including dropped events, are not returned by the API today. Your acceptance rate is available as the `own.ingest_acceptance` KPI.
You get `200` with `state` `processed` and `received_events` matching the number of events you sent.
(Code sample: activity-status. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{
"batch_id": "rounds-2026-10-15-0001",
"state": "processed",
"received_events": 1,
"received_at": "2026-10-15T12:04:00.120Z",
"completed_at": "2026-10-15T12:04:00.480Z"
}
```
## Bulk upload for backfills
`activityRequestUpload` (see the API reference)
For large exports, upload a file instead of a JSON body.
1. **Request an upload** with the file's `content_type` and exact `content_length` in bytes. You get an `upload_id`, a presigned `upload_url`, `required_headers` and an `expires_at` 15 minutes ahead by default.
2. **`PUT` the exact bytes** to `upload_url` with every header in `required_headers`, without PlayerPal credentials. The file may not be larger than the size you declared.
3. **Complete the upload** with a `batch_id` and the SHA-256 `checksum` of the file as 64 hex characters (a `sha256:` prefix is accepted). If you send `row_count`, it must equal the number of parsed events.
`activityCompleteUpload` (see the API reference)
`POST /v1/activity-uploads/{uploadId}:complete` is the same call. The response is the same `202` as a batch, and the same all-or-nothing checks apply to the file's events.
**File formats.** NDJSON has one event object per line. JSON can be an array of events, an object with an `events` array, or a single event. CSV uses a header row with the field names; without a header, columns are read in this order: `event_id, player_id, type, bet, win, currency, occurred_at, game_ref, round_ref, market`. CSV values cannot contain commas. Gzip is detected from the content type or the file itself. Parquet is not supported.
(Code sample: activity-upload. See the page for curl, Node.js, Python, PHP, Java and C#.)
Upload ticket:
```
{
"upload_id": "upl_XXXX",
"upload_url": "",
"expires_at": "2026-10-15T12:19:00.000Z",
"required_headers": {
"Content-Type": "application/x-ndjson",
"Content-Length": "1843302"
}
}
```
Complete:
```
{
"batch_id": "backfill-2026-10-0001",
"state": "accepted",
"received_events": 18210,
"received_at": "2026-10-15T12:10:42.000Z"
}
```
## Retry safely
Network errors happen. Retry on timeouts and `5xx` with the **same** `batch_id`, and nothing is counted twice.
- `batch_id` is idempotent per integration. Re-sending the same `batch_id` with the same events returns the existing batch, with its current `state`. Event order does not matter.
- `Idempotency-Key` is optional and works the same way.
- The same `batch_id` or key with different events returns `409 idempotency_conflict`. Use a new `batch_id` for new data.
- Completing an upload twice with the same checksum returns the original batch; a different checksum is `409 idempotency_conflict`.
- If a batch was rejected with `400`, nothing was stored: fix it and send it again under the same `batch_id`.
See [Idempotency and errors](./idempotency-and-errors.mdx).
Same batch_id, different events:
```
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
```
## Errors
| Response | Cause | Fix |
|---|---|---|
| `400 integration_id_required` | No `x-integration-id` header | Send your integration ID |
| `400 batch_id_required`, `events_required` | Empty `batch_id` or no events | Send at least one event under a `batch_id` |
| `400 event_id_required`, `player_id_required`, `event_type_required` | An event lacks a required field | Fix that event; the whole batch was rejected |
| `400 player_id_must_be_provider_alias` | A `player_id` does not start `pp_plr_prv_` | Send the provider alias, never your own player ID |
| `400 market_invalid` | `market` is not two upper-case letters | Use ISO 3166-1 alpha-2, such as `MT` |
| `400 integration_not_found` | Unknown integration ID | Use the ID PlayerPal gave you |
| `400 content_type_unsupported`, `content_length_required`, `object_too_large` | Upload request not accepted | Use an allowed type and the exact size, within the limit |
| `400 checksum_required`, `checksum_mismatch` | Checksum missing or not the uploaded bytes | Hash the exact file you uploaded |
| `400 upload_expired`, `upload_incomplete` | The slot expired or no file arrived | Request a new upload and `PUT` the file before `expires_at` |
| `400 row_count_mismatch` | `row_count` differs from the parsed events | Fix the count or leave it out |
| `400 object_not_json`, `gzip_invalid`, `format_unsupported` | The file could not be parsed | Send NDJSON, JSON or CSV, optionally gzipped |
| `401 machine_auth_required` | Integration key missing or wrong | Send it as `x-api-key` or `Authorization: Bearer` |
| `404 upload_not_found`, `batch_not_found` | Unknown ID for your integration | Check the ID and `x-integration-id` |
| `409 idempotency_conflict` | Same `batch_id`, key or upload, different content | Use a new `batch_id` for new data |
The error code is in the `message` field.
## Related
- [Link players](./linking-players.mdx):
Get the provider aliases your events carry.
- [Fact ingest](./fact-ingest.mdx):
Send daily aggregated facts that power the KPI overview.
- [API reference](/api/partner):
Activity batch and upload schemas.
---
# Player link notices
Source: https://docs-internal.playerpal.io/docs/developers/webhooks
When a player unlinks an account in PlayerPal, PlayerPal can tell the affected operator or game provider straight away, so your platform stops showing the player as connected. The notice is a convenience, not a guarantee: your integration must also reconcile alias status on its own, because a notice can be missed.
:::info[In development: delivery is limited]
Today notices are delivered only to a demonstration operator integration. Your platform will not receive them yet. Per-partner notice URLs and per-partner notice credentials are planned. Until then, reconcile alias status (step 4 below), which is available to every partner.
:::
## Events
| Event | Sent when | Request | Status |
|---|---|---|---|
| Player link notice (`status: revoked`) | A player unlinks your operator or game provider integration in PlayerPal | `POST /v1/player-link-notices` on your endpoint | [In development] |
There are no other events today. Activity, facts and Points have no callbacks: check a batch with its status route instead.
## How it works
```mermaid
sequenceDiagram
participant P as Player
participant PP as PlayerPal
participant O as Your platform
P->>PP: Unlink account
PP->>PP: Revoke link and aliases
PP-->>P: Unlink confirmed (does not wait for you)
PP-)O: POST player link notice (one attempt)
O-->>PP: 2xx
Note over O: Later, on next status refresh
O->>PP: GET /v1/aliases/{playerAlias}
PP-->>O: status active or revoked, or 404
```
- The Unlink completes in PlayerPal first. The notice is sent afterwards, and the player's Unlink never waits for, or fails because of, your endpoint.
- Unlinking an operator also revokes the game provider aliases issued through that operator's integrations. Each revoked alias produces its own notice.
- A repeated Unlink request does not send the notice again.
## Receive the notice
PlayerPal sends `POST /v1/player-link-notices` to your endpoint with a JSON body and `content-type: application/json`.
| Field | Type | Meaning |
|---|---|---|
| `audience_id` | string, required | The public ID of the audience the alias was issued to: your operator ID, or the game provider integration's ID |
| `player_alias` | string, required | The alias you hold for this player: `pp_plr_opr_…` for operators, `pp_plr_prv_…` for game providers |
| `alias_type` | string, required | `operator` or `provider_operator` |
| `status` | string, required | Always `revoked` |
The notice never contains PlayerPal's internal player ID or any personal data. Look the player up by `player_alias`.
POST /v1/player-link-notices:
```
{
"audience_id": "pp_opr_XXXX",
"player_alias": "pp_plr_opr_XXXX",
"alias_type": "operator",
"status": "revoked"
}
```
## Authenticate it
Today a notice carries one credential: an `x-api-key` header with the integration key. Notices are not signed.
1. Compare `x-api-key` with your integration key in constant time.
2. Reject the request with `401` if it does not match.
3. Accept notices only over TLS, and keep the integration key server-side.
:::warning[Planned]
Signed notices with per-partner secrets are planned. Until then, the integration key is the only check.
:::
Notice headers:
```
POST /v1/player-link-notices
content-type: application/json
x-api-key:
```
## Respond quickly
Your handler should:
1. Find the alias by `player_alias` and `audience_id`, and mark it unlinked. Treat an unknown alias as success.
2. Return `204` (or any `2xx`) within 2 seconds. Do any slower work afterwards.
3. Be idempotent: receiving the same notice twice must leave the same result.
| Aspect | Current behaviour |
|---|---|
| Timeout | PlayerPal waits 2 seconds for your response |
| Success | Any `2xx` status |
| Retries | None. A failed or timed-out notice is logged and not resent |
| Ordering | Not guaranteed across aliases |
| Delivery | Demonstration integration only (see above) |
Your response:
```
HTTP/1.1 204 No Content
```
## Reconcile alias status
[Available]
`getAliasStatus` (see the API reference)
Because notices are limited and not retried, reconcile when you next display the player's link state, for example when they open their account page or launch a game from your lobby. Never call this per wager.
Send the integration key with `x-operator-id` (operators) or `x-integration-id` (game providers). See [Authentication](./authentication.mdx).
| Response | What to do |
|---|---|
| `200` `{ "status": "active" }` | Keep the link |
| `200` `{ "status": "revoked" }` | Mark the link as unlinked, as you would for a notice |
| `404 alias_not_found` | Treat as unlinked |
| Network error or `5xx` | Keep your last known state and try again later |
PlayerPal being unreachable must never block play: show the last known state and move on.
For an alias you hold, you get `200` with `status` `active`. After the player unlinks, the same call returns `revoked`.
(Code sample: alias-status. See the page for curl, Node.js, Python, PHP, Java and C#.)
Response:
```
{ "status": "revoked" }
```
Alias not issued to you:
```
{
"statusCode": 404,
"message": "alias_not_found",
"error": "Not Found"
}
```
## Related
- [Link players](./linking-players.mdx):
How players connect accounts and how aliases are issued.
- [Idempotency and errors](./idempotency-and-errors.mdx):
Every error code, including the alias status errors.
- [Design for availability](./availability.mdx):
Why link state never sits in the play path.
---
# Embed the PlayerPal widget
Source: https://docs-internal.playerpal.io/docs/developers/widget
The PlayerPal widget is a small capsule that sits on a game provider's page. It tells the player whether PlayerPal is connected ("Earning points") or not ("Connect to earn"), and, where the provider runs the Link, lets the player connect without leaving the game. It never shows Points balances, ledger details or other partners' data; those stay on PlayerPal's own pages. If the widget fails to load, the game plays normally.
[In development]
:::info[In development]
The widget runs today in PlayerPal's reference provider integration. The release format and the loading rules below are built; the launch-context contract may still change. Contact PlayerPal before building against it.
:::
## How it works
```mermaid
sequenceDiagram
participant O as Operator backend
participant PB as Provider backend
participant G as Provider game page
participant W as PlayerPal widget
participant PP as PlayerPal
O->>PB: Create a provider launch session (no PlayerPal call)
O-->>G: Single-use bootstrap in a POST body
G->>PB: Consume bootstrap
PB-->>G: Verified session context
G->>W: playerpal:session-context event
W->>PB: Link status, via your own origin
W-->>G: playerpal:alias-ready (provider alias)
```
The operator stays in charge of the wallet, the provider owns the game and its rounds, and PlayerPal receives only [activity](./sending-activity.mdx) after settlement. Launch tokens, signed assertions and aliases never appear in the game URL.
## Read the release manifest
Each widget release is published at a fixed version path and never changes. Its manifest names the exact file and its integrity digest.
- The file name carries a fingerprint of its contents, and `integrity` is a SHA-384 digest.
- PlayerPal verifies the digest again before serving a release.
- Release files are served with a one-year immutable cache, `Access-Control-Allow-Origin: *`, `Cross-Origin-Resource-Policy: cross-origin` and `X-Content-Type-Options: nosniff`.
Request:
```
GET /assets/playerpal-widget/0.1.0/manifest.json
```
manifest.json:
```
{
"name": "@playerpal/widget",
"version": "0.1.0",
"file": "playerpal-widget..iife.js",
"url": "https:///assets/playerpal-widget/0.1.0/playerpal-widget..iife.js",
"integrity": "sha384-"
}
```
## Pin the script with SRI
Copy `url` into `src` and `integrity` into `integrity`. The browser refuses the script if a single byte differs.
- `crossorigin="anonymous"` is required for the integrity check on a cross-origin script.
- Load it `async`: the game never waits for the widget.
- Load the release from PlayerPal. Do not copy, rebuild or bundle the widget into your own build.
- To upgrade, read the new version's manifest and update both `src` and `integrity`.
The script loads with no integrity error in the console. Change one character of `integrity` and the browser blocks it while the game still plays.
Your game page:
```
```
## Place and configure the element
Add one `` element to the page.
| Attribute | Required | Meaning |
|---|---|---|
| `game-id` | Yes | The game this page runs. Session context for another game is ignored |
| `allowed-integration` | Yes | Your integration ID. Context for any other integration is ignored |
| `portal-origin` | Yes | The PlayerPal player-portal origin you were given. Link pages from any other origin are refused |
| `hidden` | No | Hides the widget while present |
| Method on the element | Use |
|---|---|
| `handleGameplayStarted()` | Call when a round starts. The widget collapses, then tucks itself to the edge of the screen |
| `revealManual()` | Call from your own menu or info button to bring the hidden widget back |
Only one widget is active per page. It renders in a shadow root, respects reduced-motion settings, and supports keyboard focus and Escape to close its panels.
Element:
```
```
Gameplay hooks:
```
const widget = document.querySelector('playerpal-widget')
// When a round starts
widget?.handleGameplayStarted?.()
// From your own menu or info button
widget?.revealManual?.()
```
## Hand over the launch session
Once your backend has consumed the launch bootstrap, your page dispatches the verified context as a same-document event. If the widget loads later, it asks for the context again with `playerpal:request-session-context`, so answer that event too.
The context names the launch, the game, your integration, the linking mode, your own origin as the Link broker, the player-portal origin and your play-session token. The widget accepts it only if every value matches the attributes above and the page's own origin. The play-session token is sent only to your backend, never to PlayerPal. Parent-frame `postMessage` is not used for launch context.
When the player is connected, the widget dispatches `playerpal:alias-ready` with the provider alias. Your page can dispatch the same event to hand the widget an alias it already holds.
| Linking mode | What the widget does |
|---|---|
| `pre-linked` | The provider alias already exists; the widget shows "Earning points" |
| `operator-led` | The operator linked the player before launch and passed only the provider alias. Without one, the game continues without PlayerPal |
| `provider-led` | "Connect to earn" opens the PlayerPal link page in a popup. Your backend creates and exchanges the provider-led Link with your integration credentials |
In provider-led mode the widget calls only your own origin. Your backend then calls `POST /v1/link-intents` and `POST /v1/link-results/exchange` as described in [Link players](./linking-players.mdx). The popup times out after five minutes.
After you dispatch the context, the capsule leaves its loading state and shows "Earning points" or "Connect to earn".
Publish context and receive the alias:
```
const publish = () =>
window.dispatchEvent(
new CustomEvent('playerpal:session-context', { detail: context }),
)
publish()
window.addEventListener('playerpal:request-session-context', publish)
window.addEventListener('playerpal:alias-ready', (event) => {
const { launch_id, integration_id, player_id } = event.detail
// player_id is the provider alias, pp_plr_prv_…
})
```
Session context (detail):
```
{
"type": "operator-game:launch-context",
"version": 1,
"launch_id": "",
"demo_session_id": "",
"game_id": "your-game-id",
"integration_id": "pp_int_XXXX",
"linking_mode": "provider-led",
"api_base": "",
"player_portal_origin": "https://",
"session_token": "ps_XXXX"
}
```
:::note
`demo_session_id` carries your game session's ID. The launch contract is in development, so field names may change.
:::
## Set your Content Security Policy
| Directive | Allow |
|---|---|
| `script-src` | The PlayerPal asset origin. Integrity checking also needs `crossorigin="anonymous"` on the tag |
| `style-src` | `'unsafe-inline'` today, because the widget injects its styles into its shadow root |
| `connect-src` | Your own origin (`'self'`). The widget never calls PlayerPal directly |
| Popups | Do not sandbox away popups. If you send `Cross-Origin-Opener-Policy`, use `same-origin-allow-popups` so the link page can message back |
The widget keeps a short-lived flag and the provider alias in `sessionStorage` for the current tab; storage being unavailable does not break it.
The browser console shows no CSP violations while the widget loads, checks link status and, in provider-led mode, opens and closes the link popup.
Response headers for your game page:
```
Content-Security-Policy: script-src 'self' https://; style-src 'self' 'unsafe-inline'; connect-src 'self'
Cross-Origin-Opener-Policy: same-origin-allow-popups
```
## Related
- [Link players](./linking-players.mdx):
The link intent and exchange your backend runs.
- [Player experience](../product/player-experience.mdx):
What players see across PlayerPal.
- [Send activity](./sending-activity.mdx):
Report settled rounds after play.
---
# Identity
Source: https://docs-internal.playerpal.io/docs/identity
PlayerPal keeps one private player and issues unlinkable aliases per audience. Partners must not be able to correlate the same person by comparing PlayerPal identifiers.
Long-form: spec §3-§4, §15, §30, §36.6. Encoding background was first written in [historical/id-architecture.md](historical/id-architecture.md); the rules below are current. Buildable DDL: TypeORM migrations under `services/api/src/db/migrations/` (ADR 0023). Analytics subject and partner views: [ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md).
## Identifiers
| Identifier | Represents | Who may see it |
|---|---|---|
| Internal UUIDv7 | Database primary key | PlayerPal only. Never exposed |
| `pp_plr_...` | Player-visible PlayerPal identity | Player and PlayerPal only |
| `pp_plr_opr_...` | Player alias for one operator | That operator and PlayerPal |
| `pp_plr_prv_...` | Player alias for one provider-operator audience | That integration and PlayerPal |
| `pp_opr_...` / `pp_prv_...` / `pp_aff_...` | Party (organisation) | Authorized parties |
| `pp_int_...` | Configured integration / audience | Authorized parties |
| `pp_ses_...` | Session | Relevant client and PlayerPal |
| `pp_anl_...` | Analytics subject for retained Activity | PlayerPal only. Never a player or partner API |
Never use `pp_opr_...` or `pp_prv_...` as a player alias. They identify organisations. See ADR 0013.
A provider-global **cohort** (Account counts, numbers only) is allowed; a provider-global **alias** is not (ADR 0014, ADR 0026).
Example:
```text
private player: pp_plr_7D4P6...
analytics subject: pp_anl_K4N2W... (PlayerPal only)
Operator A: pp_plr_opr_A91K7...
Operator B: pp_plr_opr_B38M2...
Provider A × Operator A: pp_plr_prv_X72Q4...
Provider A × Operator B: pp_plr_prv_M93R8...
```
## Internal IDs: UUIDv7
Database keys use UUIDv7 (128-bit, time-ordered, native database support). The formatted external IDs are separate from those keys.
```text
internal player UUIDv7: 0198f2a4-8d7e-7c31-a8b2-4d6f91c7e201
player-visible PlayerPal ID: pp_plr_7D4P6...
```
Do not expose `Base32(internal player UUIDv7)`. That would be reversible and globally linkable.
ULID is viable as an encoding of 128 bits, but PlayerPal keeps UUIDv7 internally and a random pairwise value externally.
## External encoding: Crockford Base32
Pairwise aliases are cryptographically random 128-bit values, encoded with Crockford Base32, then prefixed.
Crockford alphabet:
```text
0123456789ABCDEFGHJKMNPQRSTVWXYZ
```
It omits `I`, `L`, `O`, `U`. URL-safe, case-insensitive, 26 characters for 128 bits (`ceil(128 / 5) = 26`).
The alias must not contain the internal UUID, partner IDs, PII, external account IDs, or business timestamps.
## Random pairwise, not derived
Rejected: `pp_plr_` (reversible, same value for every partner) and `HMAC(secret, player + audience)` (key rotation and coupling; a mapping table is needed anyway).
Accepted (ADR 0002): a fresh random 128-bit value per `(player, audience)`, stored, encoded, prefixed. Generated when the one-time link/consent completes (ADR 0009), for audiences listed on that intent ([ADR 0027](decisions/0027-alias-provisioning-at-link.md)), not at passive first exposure and not on Activity ingest. Later-enabled integrations mint through spec §37.5 resolve.
Recommended uniqueness:
```text
UNIQUE(audience_id, external_alias)
UNIQUE(player_id, audience_id) WHERE revoked_at IS NULL
```
## Parties and mappings
Organisations use a generic `party` model internally (ADR 0001): player, operator, provider, affiliate, merchant, partner, and integration. Externally, keep typed prefixes. Never `pp_party_...`.
Player identity tables are specialised (spec §15): `players`, `authentication_identities` (including opaque `iam_subject` for password reverse lookup), `player_aliases`, `identity_links`, `integrations`, `player_sessions`, `webauthn_credentials`. Live loyalty ledger may reference the internal player UUID. Partner analytics and studio reads do not: they are Audience-scoped numbers keyed by a Pairwise alias the caller was issued, or a provider-Party cohort of distinct Accounts ([ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md)). Canonical DDL: TypeORM ([ADR 0023](decisions/0023-typeorm-data-layer.md)).
External IDs issued by operators or providers (`user_123`, `token_xyz`) are stored separately, scoped to `(owner, external_id, type)`. They are not PlayerPal aliases.
Do not encode relationships into identifiers (`pp_plr_casino123_provider456_...`). Relationships change; identifiers should not.
## Unlink and Delete Account
Player-facing verbs are only **Unlink** and **Delete Account**. Anonymisation is how Delete Account retains analytics, not a third control. Mutating calls live next to `GET /v1/me`, require recent reauthentication (same idea as recovery-code rotate), and take `Idempotency-Key`.
**Unlink** follows spec §36.6:
1. Player reauthenticates to PlayerPal.
2. Player chooses the exact operator/provider relationship (`DELETE /v1/me/connections/:audienceId`).
3. PlayerPal records `revoked_at` / `unlinked_at` (spec §36.6 `revoked_at/effective_at`) and stops qualifying later events (`occurred_at` at or after that cutoff).
4. Aliases remain reserved and auditable; they are not reassigned. Relink of that Audience mints a new Pairwise alias.
5. A status delta may notify the partner asynchronously. Failure of that notice does not fail Unlink. Demo Bucks has no `DELETE /v1/player-links/:playerRef`; PlayerPal POSTs `player-link-notices` and Bucks may reconcile `GET /v1/aliases/:playerAlias` ([ADR 0032](decisions/0032-unlink-partner-notice.md)). Operator link status stays `linked` only while the local alias row remains (stale-linked if notice failed and PlayerPal is down).
6. Delayed activity before the revocation timestamp may still qualify; activity at or after it does not. Play at the partner continues; Points already on the ledger stay.
The Unlink target is the Audience the Player consented to. Operator-led → operator Party (cascade-revoke provider aliases for that operator’s integrations). Provider-led → that provider-operator integration only. `GET /v1/me` `provider_operator` rows include `operator_audience_id` (that operator Party public id, from integration config, never a partner payload). The Account groups those rows under the operator Party when that Party is also connected; confirm of operator Unlink names every Audience in that cascade.
**Delete Account** (`DELETE /v1/me`) is the erasure verb spec §36.6 does not name: revoke all aliases and links; delete Authentication identities, passkeys, sessions, and external ids; delete the mapped IAM subject in ZITADEL (`DELETE /v2/users/:id`) **before** anonymising the PlayerPal graph; destroy the UUID↔analytics-subject mapping; null `player_id` and `player_alias` on retained Activity; keep `round_ref`, amounts, `occurred_at`, integration, and analytics subject. Local demo grants the bootstrap `login-client` machine user `ORG_USER_MANAGER` via `bash infra/scripts/ensure-login-client-user-manager.sh` (also run from `task up`) so DeleteUser is allowed — do not promote login-client to `IAM_OWNER` ([ADR 0022](decisions/0022-official-login-app-fork.md)). If IAM delete fails, Delete Account returns 503 and leaves the Account active. PlayerPal does not command partner erasure. v1 does not crypto-shred backups ([ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md)).
## Rules from day one
1. Never expose internal player UUIDs.
2. Never expose `pp_plr_...` to an operator or provider.
3. Generate aliases from cryptographically random values.
4. Never derive IDs from PII or partner identifiers.
5. Never recycle or reassign IDs.
6. Scope every partner-issued identifier to its issuer.
7. Design for alias revocation and rotation even if unused at launch.
8. Never put the analytics subject on a player or partner API.
See ADR 0013 (typed prefixes), ADR 0014 (provider-operator audience), ADR 0026 (analytics subject and cohort), ADR 0031 (Account connection groups), and ADR 0032 (demo Bucks unlink notice).
---
# Ingestion and availability
Source: https://docs-internal.playerpal.io/docs/ingestion-and-availability
PlayerPal is an asynchronous loyalty sidecar. Gameplay must not depend on it.
Long-form: spec §12, §34-§41. Decisions: ADR 0004, 0005, 0006, 0008, 0015.
## Failure rule
If every PlayerPal service is unavailable, the casino and provider must still authenticate the player, launch the game, accept bets, settle rounds and pay winnings.
```text
Player opens game → Operator launch → Provider game → Bet and settle
↓ (async, retry)
Provider outbox / batch file
↓
PlayerPal ingestion → loyalty / analytics
```
No per-player session creation, per-launch resolution, per-round token request or synchronous loyalty calculation.
## Call budget (minimum provider integration)
1. Receive an already-provisioned `pp_plr_prv_...` in the operator's launch metadata.
2. Write eligible activity to the provider's existing outbox in the same local transaction as settlement where possible.
3. Cache a machine token (refresh typically every 5-15 minutes per worker, not per player).
4. Send one compressed batch of hundreds or thousands of events.
5. Poll batch status only after an ambiguous response.
## Control plane vs data plane
Ingestion authenticates, validates a minimal envelope, appends durably, and responds `202`. Normalization, campaign evaluation, ledger updates and analytics run asynchronously off that durable log (ADR 0004). Real-time and batch paths share one event model and one reward engine.
`202` means durably accepted, not loyalty-complete. Ingest is integration-scoped (`x-integration-id`). Provider studio reads are a different plane: provider-Party scoped (`x-party-id`), numbers only ([ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md), [loyalty.md](loyalty.md)).
## Idempotency
At-least-once delivery, idempotent processing (ADR 0008). Uniqueness (spec §38.1):
```text
batch uniqueness: (integration_id, batch_id)
event uniqueness: (integration_id, event_id)
ledger uniqueness: (campaign_rule_version_id, activity_event_id, ledger_action)
```
An identical retry returns the original receipt. A reused ID with a different checksum returns `409 idempotency_conflict`.
Scope for events comes from the authenticated integration, not from the payload's operator/provider fields (ADR 0014).
## Batch delivery
Small/medium batches: `POST /v1/activity-batches`. Large exports: `POST /v1/activity-uploads` returns a short-lived signed PUT URL; the partner writes NDJSON, NDJSON.gz, JSON, or CSV to object storage, then `POST /v1/activity-uploads/{uploadId}/complete` with checksum, schema version, and client `batch_id` (spec §37.7, ADR 0006). Processing starts only after the stored object matches the checksum. The complete path then uses the same Activity → Points pipeline as inline batches.
Local MinIO is the S3-compatible store (`quay.io/minio/minio`, pinned by digest in compose). Preconditions: compose `minio` + `minio-init` (bucket `playerpal-activity`), `ACTIVITY_S3_*` in `infra/.env` (see `.env.example`). Host API uses `http://localhost:9000`; the compose API service uses `http://minio:9000` internally and signs PUTs for `http://localhost:9000`. Verify: `.cursor/skills/verify-playerpal/features/activity-uploads.md`. Parquet is recognised and rejected (`format_unsupported`) until a dedicated decoder is added.
## Event log phasing
Phase 1: PostgreSQL inbox (or object storage) and workers. Phase 2: Redpanda/Kafka-compatible log when measured throughput, extra consumers, or replay needs justify it (ADR 0005, spec §35.4). Do not guess a date.
## Suggested SLOs (not contractual until load-tested)
| Capability | Objective |
|---|---|
| Game launch/wager dependence on PlayerPal | None |
| Link and account APIs | 99.9% monthly |
| Batch acceptance API | 99.95% monthly |
| `202` latency for a normal batch | p95 under 500 ms after body received |
| Activity-to-ledger freshness | p95 under 5 minutes |
| Duplicate ledger effects | Zero by design |
Interactive/core and ingestion/processing scale independently (spec §41). Token validation uses cached JWKS. Ingestion does not synchronously call IAM, campaign admin or analytics.
Integration profiles (provider batch, operator-submitted activity, object-file, optional overlay): spec §40.
---
# KPIs
Source: https://docs-internal.playerpal.io/docs/kpis
This is the reference for partner and staff KPIs: who sees what, how each KPI is defined, and how comparisons stay anonymous. The decision record is [ADR 0033](decisions/0033-partner-kpi-platform.md). Definitions are implemented as Cube measures in `services/cube/model/` and must match this page.
Status key: ✅ data exists · 🟡 needs the domain event log · 🌍 needs the activity `market` · ⏳ future (no source table yet).
## Visibility tiers
| Tier | Audience | Population | Protection |
|---|---|---|---|
| **Own** | The party itself | Players linked to the party. Operator: active `identity_links` with `audience_party_id` = the party. Provider: Accounts reached through its integrations (same scope as studio analytics). Affiliate: attributed players (⏳). | Account floor ≥ 50 per cell; drill-down only by the party's **own** aliases |
| **Network comparison** | The party | Y = your players; R = all active PlayerPal Accounts minus Y; N = all active Accounts | Rest-of-network floor, dominance rule, per-player figures only |
| **Benchmark** | The party | Peers of the same party type | Peer floor, dominance rule, statistics only |
| **Network** | PlayerPal staff | Everyone | Staff permission `reports.aggregate.read`; every access is audited |
Scope always comes from the authenticated principal. A `party_id` in the query string or an `x-party-id` header is ignored on partner routes.
## Anonymity rules
They apply to the Network comparison and Benchmark tiers. They are enforced when segments are written and checked again when they are read. Thresholds are configuration; production must not go below these values.
| Rule | Value | Config |
|---|---|---|
| Account floor | ≥ 50 distinct Accounts | `BENCHMARK_MIN_ACCOUNTS` |
| Peer floor | ≥ 5 other parties of the requester's type | `BENCHMARK_MIN_PEERS` |
| Dominance | suppress if one other party > 50 % of the measure | `BENCHMARK_DOMINANCE_P` |
| Dominance, top two | suppress if the top two together > 75 % | `BENCHMARK_DOMINANCE_P2` |
- **Published:** p25, p50, p75, mean and the requester's percentile band (for example `p50–p75`).
- **Never published:**
- min, max or an exact rank
- per-peer values, names or IDs
- absolute network totals (network size is shown only as a band, for example "100k+ players")
- **Fixed segments only:** closed ISO weeks or calendar months, and markets from the catalogue. Arbitrary date ranges are not allowed, so two segments can't be differenced to isolate a peer.
- **Fallback:** a market segment that fails a rule falls back to global. If global also fails, the response is `{ "suppressed": true, "reason": "" }`.
- **Cross-party KPIs** (`cmp.share_of_wallet`, `cmp.overlap_rate`, `cmp.multi_homing`, `cmp.market_mix`): your players' activity with *other* parties must come from at least 5 parties and pass the dominance rule.
## Market
`market` is the ISO 3166-1 alpha-2 licensed jurisdiction (uppercase, e.g. `MT`) that the partner reports on each activity event, in the JSON body or as a `market` column in uploads.
- A malformed value rejects the whole batch with `market_invalid` (400).
- If the integration declares `markets` and the event's market isn't one of them, the event is rejected with `market_not_licensed`.
- If the event has no market and the integration declares exactly one, that market is used. Otherwise the market stays empty, and the event only counts in global figures.
- The batch idempotency checksum covers `market` only when it is sent, so replays of batches sent before markets existed still match.
Nothing is stored on the player. A player's market for a day is derived from that day's activity, and a player active in two markets counts in both.
## Own-tier KPIs
| ID | KPI | Definition | Who | Source | Status |
|---|---|---|---|---|---|
| `own.linked_players` | Linked players | Accounts with an active link at the end of the period | op | `identity_links` | ✅ |
| `own.new_links` | New links | Links completed in the period | op | `identity_links.linked_at` | ✅ |
| `own.unlink_rate` | Unlink rate | Links unlinked or revoked in the period ÷ active links at the start of the period | op | `identity_links` | ✅ |
| `own.link_funnel` | Link funnel | `link_intents` created → completed; conversion % | op | `link_intents` | ✅ |
| `own.active_accounts` | Active accounts | Distinct Accounts with ≥ 1 round (DAU / WAU / MAU) | op, prv | `analytics_account_*_day` | ✅ |
| `own.stickiness` | Stickiness | Mean DAU ÷ MAU | op, prv | as above | ✅ |
| `own.rounds` | Rounds | Σ rounds | op, prv | as above | ✅ |
| `own.bet_eur` / `own.win_eur` | Turnover / wins | Σ `bet_eur`, Σ wins (EUR) | op, prv | as above | ✅ (🌍 split) |
| `own.ggr` | GGR proxy | Σ (bet − win) | op, prv | as above | ✅ |
| `own.share_of_turnover` | Share of linked players' turnover | Provider studio "share of slot turnover" (ADR 0026) | prv | `analytics_studio_account_day` | ✅ |
| `own.retention_d7` / `_d30` | Retention | Of Accounts linked in the cohort week, the share active again on day 7 / 30 | op, prv | `player_active_day` | 🟡 |
| `own.churn_rate` | Monthly churn | Active in P−1 and not in P ÷ active in P−1 | op, prv | `player_active_day` | 🟡 |
| `own.points_issued` | Points issued / funded | Σ ledger credits by campaign | op | `points_ledger` | ✅ |
| `own.points_per_player` | Points per active account | points issued ÷ active accounts | op | `points_ledger` | ✅ |
| `own.qualification_rate` | Rewarded-round qualification | Rounds credited ÷ rounds | prv | `points_ledger`, activity | ✅ |
| `own.events_accepted` | Ingest acceptance | Accepted ÷ received events, by `reject_reason` | op, prv (own integrations) | `activity_events` | ✅ |
| `own.batch_latency` | Batch latency | p50 / p95 of `completed_at − received_at` | op, prv | `activity_batches` | ✅ |
| `own.attributed_players` | Attributed / activated players | Players attributed to the affiliate; activation % | aff | attribution model | ⏳ |
| `own.rewards_claimed` | Rewards claimed | | partner | rewards model | ⏳ |
## Network comparison KPIs (`cmp.*`)
**Live in v1** (`GET /v1/kpis/compare`, see [api/kpis.md](api/kpis.md)):
- `active_days`, `rounds_per_player`, `bet_per_player`, `ggr_per_player`, `points_per_player`
- `avg_stake`, `payout_ratio`, `churn_rate`, `link_conversion`
- `multi_homing`, `overlap_rate`, `share_of_wallet`
The other rows below are planned.
How "the network" is measured:
- **Per-player KPIs** compare the median of your players with the distribution over the players of other parties of your type, one unit per (player, party) relationship.
- **Ratio KPIs** compare your ratio with the distribution of other parties' ratios.
**Populations** for a period P (closed week or month) and an optional market M:
- **Y:** Accounts in the party's reach on at least one day in P.
- **R:** all active PlayerPal Accounts (`players.status = 'active'`) that were active in P, minus Y. R is the default baseline.
- **N:** all active Accounts, including Y. Shown as a secondary figure.
**Response fields:**
- `you`: the value over Y
- `network`: the value over R, plus `network_all` over N
- `index`: `you ÷ network × 100`
- `band`: where your players' median sits in the network's per-player distribution
- `network_dist`: `{p25, p50, p75}`
- `suppressed`, plus `reason` when it is
Per-player measures are computed per Account first and then aggregated, so the figures report medians as well as means.
| ID | KPI | Definition (for Y and for R) | Who | Source | Status |
|---|---|---|---|---|---|
| `cmp.active_rate` | Active rate | Active Accounts ÷ Accounts in the population at the start of P | op, prv, aff | `player_active_day`, `party_reach_day` | 🟡 |
| `cmp.active_days` | Active days per active player | Median / mean of distinct active days per Account | op, prv | `player_active_day` | ✅ |
| `cmp.stickiness` | Stickiness | Mean DAU ÷ MAU | op, prv | `player_active_day` | ✅ |
| `cmp.rounds_per_player` | Rounds per active player | Median / mean of rounds per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.bet_per_player` | Turnover per active player | Median / mean of `bet_eur` per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.ggr_per_player` | GGR proxy per active player | Median / mean of (bet − win) per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.avg_stake` | Average stake | Σ `bet_eur` ÷ Σ rounds | op, prv | `analytics_account_day` | ✅ |
| `cmp.payout_ratio` | Observed payout ratio | Σ win ÷ Σ bet | op, prv | `analytics_account_day` | ✅ |
| `cmp.retention_d7` / `_d30` | New-player retention | Of Accounts that joined in the cohort week (Y: linked to you; R: activated on PlayerPal), the share active again 7 / 30 days later | op, prv | `player_active_day`, `identity_links`, `players.activated_at` | 🟡 |
| `cmp.churn_rate` | Monthly churn | Active in P−1 and not in P ÷ active in P−1 | op, prv | `player_active_day` | 🟡 |
| `cmp.reactivation_rate` | Reactivation | Active in P after ≥ 30 inactive days ÷ Accounts that were inactive | op, prv | `player_active_day` | 🟡 |
| `cmp.tenure` | Player tenure | Median days from `players.activated_at` to the end of P, over active Accounts | op, prv, aff | `players` | ✅ |
| `cmp.points_per_player` | Points earned per active player | Median / mean of ledger credits per Account | op | `points_ledger` | ✅ |
| `cmp.redemption_rate` | Redemption rate | Points redeemed ÷ points earned | op | rewards model | ⏳ |
| `cmp.multi_homing` | Operators per player | Median / mean of active operator links per Account | op, prv | `identity_links` | ✅ |
| `cmp.overlap_rate` | Overlap | Share of Y also active with ≥ 1 other party of your type (count only, never which) | op, prv | `party_reach_day`, activity | ✅ |
| `cmp.share_of_wallet` | Share of your players' turnover | Y's `bet_eur` with you ÷ Y's total `bet_eur` across PlayerPal. The network figure is the median share across parties of your type. | op, prv | `analytics_account_*_day` | ✅ (op 🌍) |
| `cmp.market_mix` | Market mix | Share of `bet_eur` per market, Y vs R | op, prv | activity `market` | 🌍 |
| `cmp.link_conversion` | Link conversion | Completed ÷ created `link_intents` in P | op | `link_intents` | ✅ |
| `cmp.time_to_first_play` | Time to first play | Median hours from link completed to the first round with that party | op, prv | `identity_links`, activity | ✅ |
| `cmp.game_mix` | Game-category mix | Share of `bet_eur` by game category, Y vs R | op, prv | game category metadata | ⏳ |
## Benchmark KPIs
Any KPI marked benchmarkable in the catalogue: the per-party value of an Own-tier or `cmp.*` KPI, compared across peers of the same type. Response: peer `{p25, p50, p75, mean}`, the requester's `band`, `peers` shown as a band (e.g. "5–10"), or `suppressed`.
## Staff-only KPIs (Network tier)
| KPI | Source | Status |
|---|---|---|
| Signups and signup funnel by method | `player_registrations`, `players` | ✅ |
| Network DAU / WAU / MAU, stickiness | `player_active_day` | 🟡 |
| Network turnover, GGR, points liability | activity rollups, `points_ledger` | ✅ |
| Login success / failure by method | `domain_events` | 🟡 |
| Recovery-code use, account merges, deletions | `recovery_attempt_log`, `player_account_merges`, `domain_events` | ✅ / 🟡 |
| Connected parties by type, integrations by status | `parties`, `integrations` | ✅ |
| Ingest health across all integrations | `activity_events`, `activity_batches` | ✅ |
| KPI API usage per party and client | `domain_events` (`kpi.api.accessed`) | 🟡 |
## Domain events
Append-only `domain_events`, written in the same transaction as the change. No PII. Delete Account nulls `player_id` and keeps the event.
| Group | Event types |
|---|---|
| Player | `player.registered`, `player.activated`, `player.deleted` |
| Auth | `auth.login.succeeded` (a session was issued; `scope`), `auth.login.failed` (`method`, `reason`), `auth.session.revoked` (actor `player` or `staff`) |
| Linking | `link.intent_created` (`led`), `link.completed` (`proof_type`), `link.unlinked` (`cascaded`), `alias.issued` / `alias.revoked` (`alias_type`); `party_id` is the audience |
| Planned | `account.merged`, `auth.method.added` / `auth.method.revoked`, `auth.recovery.used` |
| Ingest | `batch.received` (`events`), `batch.completed` (`state`, `events`, `rejected`, `latency_ms`); `party_id` is the provider |
| Data access | `kpi.api.accessed` |
| Partner credentials | `partner.credential.created`, `partner.credential.rotated`, `partner.credential.revoked` |
---
# Linking and bootstrap
Source: https://docs-internal.playerpal.io/docs/linking-and-bootstrap
A bootstrap method is the first proof used to create and secure a PlayerPal account. An operator or provider assertion proves local partner context; it does not give that partner permanent ownership of the portable account.
Long-form: spec §10-§11, §33, §36.6, §45-§46. Decisions: ADR 0009, 0010, 0014, 0017, 0019, 0020, 0024, 0026, 0027.
Diagrams: [spec/playerpal-bootstrap-diagrams/](../spec/playerpal-bootstrap-diagrams/). Mockups: [spec/playerpal-device-mockups/](../spec/playerpal-device-mockups/).
## When an account exists
No activity data for a `(player, audience)` relationship is transmitted to PlayerPal until the player has registered or linked at least once (ADR 0009). After that, earning is automatic. A browser cookie expiring does not break the server-side link. Reauthentication is for viewing, spending or managing the account, not for continued asynchronous earning (spec §33).
The first link creates the account. A later audience either signs into that account (unification) or creates a separate independent account. Fragmented accounts are expected, not a bug (ADR 0010). There is no separate later "claim" step.
Earning starts only after explicit registration/activation. PlayerPal does not create pre-registration shadow profiles or award historical points by default (spec §33). Events are eligible only when `occurred_at >= connection.effective_from` and before Unlink revocation (`unlinked_at`; spec §36.6 `effective_at`).
## Bootstrap methods
| Method | Creates first account? | Recommendation |
|---|---|---|
| Passkey | Yes | Default |
| Approved social OIDC (`iss + sub` only) | Yes | After provider POC |
| Partner assertion + passkey | Yes | Best embedded flow |
| Partner assertion + recovery secret | Yes | Controlled fallback, then upgrade |
| Generated PlayerPal ID + password | Provisional only | Restricted fallback (A0) |
| Player-chosen username + password | Technically yes | Do not launch initially |
| Native device key | Yes | Mobile complement; do not replace WebAuthn |
| Existing-device QR | No; adds a device | Recommended device-add |
| Partner activation code | Only after a durable credential | Campaign / invitation context |
`POST /v1/registrations` creates short-lived registration state only. It does not create a player until a permitted bootstrap ceremony completes. All mutating calls require an `Idempotency-Key`. HTTP examples: spec §45.3 and §37.
## Operator / provider linking
Do not put the assertion or an access token in a URL.
1. Operator (or provider) authenticates as a machine client and `POST /v1/link-intents` with a short-lived signed assertion in the body (about five minutes; replay-protected `jti` / nonce).
2. PlayerPal returns a `link_url` containing an opaque one-time code.
3. The player completes a first-party PlayerPal ceremony (passkey, social, or existing-account discovery).
4. The player consents to that exact operator/provider connection.
5. Exchange returns aliases for the completing Audience only:
- **Operator-led:** `pp_plr_opr_...` plus requested `pp_plr_prv_...` aliases already authorized for that operator (spec §37.4).
- **Provider-led:** only that integration’s `pp_plr_prv_...`. It does not mint `pp_plr_opr_...` or an operator identity link.
`requested_provider_integrations` must already be authorized for that operator; it does not grant scope. Normal game launch then carries the provider alias in the operator's existing launch payload. No synchronous PlayerPal call (ADR 0015).
## Unlink and Delete Account
Unlink and Delete Account are player session APIs next to `GET /v1/me` (`DELETE /v1/me/connections/:audienceId`, `DELETE /v1/me`). Both require recent reauthentication and `Idempotency-Key`.
**Unlink** is spec §36.6: revoke one consented Audience; stop qualifying later events; keep aliases reserved; optional async partner notice; delayed pre-revocation Activity may still qualify. Operator-led Unlink is Unlink of that operator (cascade-revoke provider aliases for that operator’s integrations). Provider-led Unlink is Unlink of that provider-operator integration only. Relink mints a new Pairwise alias. Play at the partner continues. Demo Bucks has no player-links revoke; PlayerPal pushes a notice and Bucks may reconcile alias status so casino chrome can show Connect ([ADR 0032](decisions/0032-unlink-partner-notice.md)).
**Delete Account** erases the identity graph and retains gameplay facts without identity keys. It does not command partner erasure. Details: [identity.md](identity.md) and [ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md).
## Account discovery
Before creating an account, the UI asks "Already have PlayerPal?" and offers passkey, approved social, PlayerPal ID + password, existing-device approval, or recovery code. Never email or telephone lookup. Never reveal whether an arbitrary PlayerPal ID exists. Uniform authentication errors.
A repeatable external proof maps through a non-reversible lookup key scoped by issuer (spec §46.2). The same active social, operator, or integration-scoped provider subject cannot create a second player.
## Merge
Merge is a high-risk correction, not automatic deduplication. The player must independently authenticate both accounts with recent step-up. Preview consequences without exposing private partner identifiers. Ledger history stays immutable; adjustments use compensating entries.
Automatic merge is prohibited for matches based on email, name, phone, IP, device fingerprint, timing, gameplay similarity or profile similarity (ADR 0019).
## Related ADRs
- [0009](decisions/0009-mandatory-one-time-link-before-data-flows.md) no data before link
- [0010](decisions/0010-linking-unifies-no-separate-claim.md) unification at link time
- [0014](decisions/0014-provider-operator-alias-scope.md) provider-operator audience
- [0017](decisions/0017-operator-assertion-popup-linking.md) assertion + popup
- [0019](decisions/0019-scoped-bootstrap-proofs-controlled-merge.md) duplicates and merge
- [0020](decisions/0020-assurance-levels-provisional-password.md) A0-A3
- [0024](decisions/0024-nest-owned-portal-auth.md) Nest-owned portal password/passkey
- [0026](decisions/0026-analytics-subject-and-provider-cohort.md) analytics subject and cohort
- [0027](decisions/0027-alias-provisioning-at-link.md) mint listed aliases at link complete
---
# Loyalty
Source: https://docs-internal.playerpal.io/docs/loyalty
The private player owns loyalty and analytical facts. External aliases are access handles, not separate wallets.
Long-form: spec §18, §33, §38. Decisions: ADR 0007, 0008, 0009, [0026](decisions/0026-analytics-subject-and-provider-cohort.md).
## Authority
PlayerPal calculates points. Providers report factual activity; they do not calculate the PlayerPal reward. The append-only points ledger is authoritative. Cached balances and projections are derived views.
The live ledger may reference the internal player UUID (`players.id`) in memory. Partner-facing analytics do not: they are Audience-scoped numbers, never UUID, `pp_plr_...`, another Audience’s alias, or the analytics subject.
## Phase 1 demo earn path
- Partners (or `POST /demo/activity-batches` when `DEMO_PARTNER_HELPER=1`) submit Activity facts; Nest awards Points.
- Fixed campaign version `pp_cmp_BET_EUR_V1`: **1 Point per €1 bet**, EUR only, `round_settled` events for linked `pp_plr_prv_…` aliases.
- Machine ingest requires `x-api-key` and `x-integration-id` (no default audience). ACK is `202`.
- StarBucks is owned by the NetEnt provider iframe (`:5175`), which receives
only the NetEnt×Bucks (`pp_int_NETENT_BUCKS`) alias and batches settled rounds
through a retrying demo outbox. Big Bass → Pragmatic×Bucks remains distinct.
## Eligibility
- Earning starts only after explicit registration/activation. No pre-registration shadow profiles. No historical points by default (spec §33). Spec §18's "pre-enrolment accrual" option is not current product policy.
- An activity event is eligible only when `occurred_at >= connection.effective_from` and before Unlink revocation (`unlinked_at`; spec §36.6 `effective_at`).
- Unlink stops qualifying later Activity for that Audience; Points already on the ledger stay. Delete Account erases the identity graph and does not confiscate retained gameplay facts (no identity keys).
- A cookie expiring does not stop asynchronous earning once the server-side link exists.
## Campaigns
Campaign configuration is versioned and snapshotted (ADR 0007). `reward_event` references `campaign_version_id` (or rule version), never a mutable campaign row. Past decisions stay explainable after edits.
The domain model is generic (campaign → eligibility → trigger → reward → limits → schedule → audience), not casino-only.
## Ledger
Raw activity is immutable. Corrections are adjustments, reversals or compensating events, not mutations. Merge of two player accounts does not rewrite history; it applies programme-specific reconciliation and compensating entries (ADR 0019).
Idempotency on the reward side: `UNIQUE (campaign_rule_version_id, activity_event_id, ledger_action)` (spec §38.1).
## Analytics
Processing Activity upserts daily serving rows. Partner studio HTTP (`GET /v1/analytics/studio`, `GET /v1/analytics/studio/aliases/:alias`) is provider-Party scoped. The provider comes from the partner principal: an OAuth client or a PP-HMAC-SHA256 key with scope `studio.read` ([api/hmac-signing.md](api/hmac-signing.md)). The legacy shared machine key with `x-party-id` (`pp_prv_...`) is still accepted while `STUDIO_LEGACY_PARTY_HEADER` is not `false`, and must be turned off in production. Ingest stays integration-scoped (`x-integration-id`). Scope never comes from a payload `operator_id`.
Per-alias lookup uses only a `pp_plr_prv_...` that provider was issued. Two aliases for the same Account at that provider return the same Account-level numbers. A foreign alias is the same unknown as a missing alias. After Delete Account, live per-alias GET is unknown.
“On you” is all of that provider’s integrations for the Account that period, not one casino. Response fields (numbers only): `from`, `to`, `bets`, `wins`, `bet_eur`, `share_of_their_slot_turnover`, `rounds_on_you`, `rounds_elsewhere`. Studio rollup adds `accounts` (distinct Accounts, not a sum of per-alias rows) and suppresses windows below a configurable Account floor (production default 50).
A provider-global **cohort** is allowed; a provider-global **alias** is not (ADR 0014, ADR 0026).
Each Activity event may carry a licensed `market` (ISO 3166-1 alpha-2). It is checked against the integration's declared `markets`, and events outside them are rejected with `market_not_licensed`. Market feeds partner KPIs and anonymous comparisons; see [kpis.md](kpis.md) and ADR 0033.
---
# Neon Nexus integration
Source: https://docs-internal.playerpal.io/docs/neon-nexus-integration
## Ownership
| Concern | Authority |
|---------|-----------|
| Visuals, symbols, audio, cabinet, intro, win tiers, 5×4 / 5×5 layout, payline geometry | Neon Nexus artifact |
| Session bootstrap, wallet debit/credit, round IDs, Activity outbox, widget, localization shell | PlayerPal / Orbit Games host |
| Immutable outcome (stops, grid, wins, feature steps, total win) | Server (`POST /v1/rounds`) |
Do not replace production wallet or round APIs with the artifact’s demo balance or client-only RNG.
## Round contract
Server returns an immutable outcome containing at least:
- `round_id`, `bet`, `status`, `balance`
- `outcome.grid`, `outcome.win`, `outcome.tier`, `outcome.model_version`
- `outcome.base` (5×4 step with stops and wins)
- `outcome.feature` (null, or `{ steps }` of 5×5 nexus spins with rising `path_multiplier`)
The client animates that result and must not regenerate it.
## Lifecycle (client)
`READY → REQUESTING_RESULT → SPINNING → STOPPING → EVALUATING → PRESENTING_WIN → READY`
Feature presentation may insert explicit feature states; transitions stay named and ordered.
## Embedding
1. Launch via Bucks / Orbit provider launch session (play session token in memory only).
2. Host `demos/apps/provider-game-demo` (Vite) or its production build under the provider origin.
3. Assets resolve from `/neon-nexus/...` (no CDN Pixi runtime).
4. Optional PlayerPal widget uses `game-id=orbit-breaker` and `allowed-integration=pp_int_ORBIT_BUCKS` (platform ids unchanged).
## Commands
```bash
# Dev
task demos:provider-game
# or
cd demos/apps/provider-game-demo && npm run dev
# Test / build / export
cd demos/apps/provider-game-demo && npm test && npm run build
# Output: demos/apps/provider-game-demo/dist
# Math simulation (≥1e6 recommended)
cd services/orbit-games && SPINS=1000000 SEED=42 npm run simulate:rtp
cd demos/apps/provider-game-demo && npm run simulate
```
## Adapter surface
Initialize with session token (Authorization bearer), fixed bet ladder, and currency EUR. Spin requests `{ client_round_id, bet, rtp_profile? }` and presents `outcome`. `rtp_profile` is a development-only prototype control; production forces the reference profile unless the host explicitly enables demo profiles. Interrupt recovery uses `GET /v1/rounds/current` plus idempotent `POST /v1/rounds` with the same `client_round_id`.
---
# Neon Nexus play-money mathematics
Source: https://docs-internal.playerpal.io/docs/neon-nexus-math
Play-money prototype. **Not certified** for real-money gambling. The legacy
platform identifier remains `orbit-breaker`; it is not player-visible.
## Model
- Base: 5 reels × 4 rows, 20 fixed paylines
- Nexus Paths: 5 reels × 5 rows, adjacent-row connected paths
- Ordered reel strips with unbiased stop selection
- Four scatters trigger three feature spins at ×1, ×2, ×3
- Wild substitution; feature exposure capped at 10,000× bet
- Configuration: `services/orbit-games/math-config/math-config.json`
The provider service determines and persists the complete outcome before the
client animates. Speed and quick-stop affect presentation only.
Development builds expose three server-authoritative prototype payout profiles:
conservative (~92%), reference (~96.06%), and promotional (~98%). They scale
payout multipliers without changing reel stops, hit selection, feature
frequency, or presentation timing. Production ignores client-selected profiles
unless `ALLOW_DEMO_RTP_PROFILES=true` is explicitly configured. Each
non-reference outcome includes its profile and target in `model_version`.
## Prototype evidence
`SPINS=1000000 SEED=42 npm run simulate:rtp` reported approximately 96.09%
total RTP, 94.04% base RTP, 2.05% feature contribution, 31.8% hit frequency,
one feature per ~433 rounds, and 327.1× maximum observed win.
These are prototype simulation figures, not certification claims.
Run a selectable profile with:
```bash
RTP_PROFILE=conservative SPINS=1000000 SEED=42 npm run simulate:rtp
RTP_PROFILE=standard SPINS=1000000 SEED=42 npm run simulate:rtp
RTP_PROFILE=promotional SPINS=1000000 SEED=42 npm run simulate:rtp
```
---
# Neon Nexus presentation (provider-game-demo)
Source: https://docs-internal.playerpal.io/docs/neon-nexus-presentation
Play-money presentation for Neon Nexus. The legacy internal
`game_id=orbit-breaker` remains unchanged for launch and wallet compatibility.
## Authority split
- Server (`services/orbit-games`) owns immutable outcomes
- Client (`demos/apps/provider-game-demo`) only presents `POST /v1/rounds` results
- Spin speed / fast-stop / autoplay change timing only
## Client structure
```text
src/game/NeonNexusGame.tsx
src/game/neon/
SlotRenderer.ts # Pixi reel strip presentation
layout.ts # centralized 5×4 / 5×5 geometry
roundState.ts # READY → … → PRESENTING_WIN → READY
audio.ts # intro crossfade, mute persistence
viewport.ts # fullscreen, incl. iOS fallback + operator frame request
winPresentation.ts # return / standard / big / epic thresholds
public/neon-nexus/ # symbols, music, CSS, math-config, licenses
```
## Win tiers
| Tier | Condition | Full-screen celebration |
|------|-----------|-------------------------|
| Return | `total <= bet` | No |
| Standard | `> 1×` and `< 10×` | Yes |
| Big | `>= 10×` and `< 50×` | Yes |
| Epic | `>= 50×` | Yes |
## Motion
Phones report `prefers-reduced-motion: reduce` for Reduce Motion and Low Power
Mode, which is why the panel's **Motion** setting (`auto` / `full` / `minimal`)
overrides it. Reduced motion removes movement, not information: reel travel
shortens and anticipation drops, and win screens still open — held static and
longer, since there is no count-up to watch. Only `minimal` (or spin speed
`instant`) lands the grid with no travel at all.
The effective choice is mirrored onto `` so
CSS keyframes follow the player's override rather than the media query.
## Commands
```bash
cd demos/apps/provider-game-demo
npm run dev
npm test
npm run build # → dist/
npm run simulate
```
See also `docs/neon-nexus-math.md`, `docs/neon-nexus-integration.md`, and
`demos/apps/provider-game-demo/INTEGRATION.md`.
---
# Neon Nexus crash recovery
Source: https://docs-internal.playerpal.io/docs/neon-nexus-recovery
Provider rounds retain the existing idempotent recovery contract:
1. Repeating a `client_round_id` resumes the stored round.
2. Any open round is completed before another wager is accepted.
3. `GET /v1/rounds/current` exposes an interrupted round after relaunch.
4. Wallet debit and credit reuse durable transaction references.
The play-session token remains memory-only. PlayerPal or widget outages never
block launch, wagering, settlement, or recovery. Persisted Activity outbox rows
resume independently after provider-service restart.
---
# Neon Nexus three-mode verification
Source: https://docs-internal.playerpal.io/docs/neon-nexus-three-mode
```bash
task provider:stack
PROVIDER_E2E=1 task provider:verify
```
The verification covers pre-linked, operator-led, and provider-led alias
delivery; wallet settlement; round recovery; Activity outbox behavior; Neon
Nexus math; and the provider-game browser.
Provider traffic must never expose global Points, ledger rows, private player
identifiers, machine credentials, assertions, result codes, or bootstrap
secrets in URLs.
Related:
- [neon-nexus-recovery.md](./neon-nexus-recovery.md)
- [neon-nexus-presentation.md](./neon-nexus-presentation.md)
- [neon-nexus-math.md](./neon-nexus-math.md)
---
# Open questions
Source: https://docs-internal.playerpal.io/docs/open-questions
Genuinely undecided items. If you are about to assume an answer, stop and
write an ADR instead of guessing.
The long-form list in spec §29 is filtered here: items closed by spec §33
or ADRs 0011-0032 are in the resolved section at the bottom.
## Production hardening (not a product pick)
1. Production IAM lock-in still waits on the remaining spec §23.2 proofs.
Local/dev is ZITADEL ([ADR 0021](decisions/0021-nestjs-zitadel-local-poc.md)).
Keycloak remains the fallback if those proofs fail. Do not pick a third
vendor without a new ADR.
Already demonstrated in this POC:
- Passkey-only Account create without player email (ADR 0024).
- Social start requests `openid` only; Nest keeps `iss` + `sub` and drops
IdP `rawInformation` (ADR 0012).
- Linking is an explicit intent, not email match (ADR 0017, 0019).
- Player and staff portals are URL-isolated (ADR 0018). Staff still uses
`MACHINE_API_KEY`, not workforce SSO.
- Players can revoke portal sessions.
Still required before production IAM lock-in:
- Live capture that Google + ZITADEL do not leave email/name/phone in
PlayerPal cookies, DB, API logs, or traces (code filtering is in place;
the live hop is not).
- Enterprise admin SAML/OIDC.
- Machine clients using `private_key_jwt` (POC ingest still uses
`MACHINE_API_KEY`).
- Export/migration of authentication mappings.
- IAM key rotation.
## Resolved. Do not re-open without an ADR
- ~~Keycloak vs ZITADEL for local POC~~ → [ADR 0021](decisions/0021-nestjs-zitadel-local-poc.md) (ZITADEL + NestJS control plane)
- ~~Partner aliases are `pp_plr_...`~~ → [ADR 0013](decisions/0013-typed-pairwise-aliases.md)
- ~~No extra operator×provider scope~~ → [ADR 0014](decisions/0014-provider-operator-alias-scope.md)
- ~~JWT at operator login for the widget~~ → [ADR 0015](decisions/0015-async-loyalty-sidecar.md), [ADR 0016](decisions/0016-bff-cookies-and-machine-oauth.md)
- ~~Token lifetime unknown~~ → machine tokens 5-15 minutes; player BFF cookies; no widget refresh tokens ([ADR 0016](decisions/0016-bff-cookies-and-machine-oauth.md))
- ~~Richer profile may use email~~ → [ADR 0012](decisions/0012-never-receive-player-pii.md)
- ~~Link UX / consent surface~~ → [ADR 0017](decisions/0017-operator-assertion-popup-linking.md)
- ~~Sign in as existing account~~ → [ADR 0019](decisions/0019-scoped-bootstrap-proofs-controlled-merge.md)
- ~~Points before registration / retrospective claim~~ → spec §33: no shadow profiles, no historical points by default ([ADR 0009](decisions/0009-mandatory-one-time-link-before-data-flows.md))
- ~~Never store vs never receive email~~ → never receive or store ([ADR 0012](decisions/0012-never-receive-player-pii.md))
- ~~PostgreSQL-first then Redpanda~~ → [ADR 0005](decisions/0005-phased-event-log-infrastructure.md)
- ~~Batch orchestrator~~ → Dagster initially (spec §35.1)
- ~~Random pairwise vs HMAC / Base32(UUID)~~ → [ADR 0002](decisions/0002-random-pairwise-external-ids.md)
- ~~Portal role picker~~ → [ADR 0018](decisions/0018-url-isolated-portals.md)
- ~~Custom / branded portal login UI path~~ → Login V2 fork available ([ADR 0022](decisions/0022-official-login-app-fork.md)); **Nest owns** player password/passkey + social for the local portal ([ADR 0024](decisions/0024-nest-owned-portal-auth.md))
- ~~Whether PlayerPal ID + password is offered~~ → yes on player signup, provisional A0, pre-minted `pp_plr_...` as IAM loginname ([ADR 0020](decisions/0020-assurance-levels-provisional-password.md), [ADR 0024](decisions/0024-nest-owned-portal-auth.md))
- ~~Nest data access / DDL ownership~~ → TypeORM ([ADR 0023](decisions/0023-typeorm-data-layer.md))
- ~~Permitted cross-operator analytics (item 6)~~ → partners get cohort and
per-alias **numeric** enrichment; they never get another Audience’s
identifier. Legal joint-controller contracts remain a product-permission
assumption, not an identifier rule ([ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md))
- ~~Retention after Unlink or Delete Account (item 7)~~ → Unlink revokes one
Audience (spec §36.6); Delete Account erases the identity graph and retains
gameplay facts without identity keys ([ADR 0026](decisions/0026-analytics-subject-and-provider-cohort.md))
- ~~Google claim behaviour in PlayerPal-controlled systems (item 2)~~ →
`openid` only; Nest persists `iss` + `sub`; IdP `rawInformation` is dropped
before return ([ADR 0012](decisions/0012-never-receive-player-pii.md)). Live
hop capture remains under production hardening above.
- ~~Recovery beyond recovery codes (item 3)~~ → codes, extra passkeys, linked
social, remaining-device session revoke. Operator-assisted recovery is out
of v1 ([ADR 0028](decisions/0028-v1-recovery-without-operator-assist.md)).
- ~~Eager vs on-demand provider aliases (item 4)~~ → mint listed integrations
at link complete; later integrations via resolve; never on ingest
([ADR 0027](decisions/0027-alias-provisioning-at-link.md)).
- ~~Which providers get a provider-global alias exception (item 5)~~ → none.
Exceptions need a new ADR plus a named contract
([ADR 0014](decisions/0014-provider-operator-alias-scope.md)).
- ~~Authorization implementation (item 8)~~ → application-native Nest checks
until a second service shares the same permissions
([ADR 0029](decisions/0029-application-native-authorization.md)).
- ~~Redpanda date (item 9)~~ → no date. Phase 2 starts when a spec §35.4
condition is measured ([ADR 0005](decisions/0005-phased-event-log-infrastructure.md)).
- ~~How Bucks learns PlayerPal Unlink~~ → async notice plus rare alias
reconcile; Unlink does not wait ([ADR 0032](decisions/0032-unlink-partner-notice.md)).
---
# Portals
Source: https://docs-internal.playerpal.io/docs/portals
Each audience has its own URL, OAuth client, redirect URIs, session cookie and permission policy. The user never picks a role inside the product.
Source: conversation export §14 and spec §7 / §14. Decision: ADR 0018.
## Audiences
| Portal | Intended host (example) | Audience | Authentication |
|---|---|---|---|
| Player | `my.playerpal.io` | Players | Passkey, approved social, provisional PlayerPal ID/password (A0), recovery |
| Staff | `admin.playerpal.io` | PlayerPal workforce | Enterprise SSO, MFA, privileged-access controls |
| Operator | `partners.playerpal.io/operator` | Operator workforce | Enterprise OIDC/SAML |
| Provider | `partners.playerpal.io/provider` | Provider workforce | Enterprise OIDC/SAML |
| Affiliate | `partners.playerpal.io/affiliate` | Affiliate workforce | Enterprise SSO or approved passkey |
Opening the wrong portal returns "you don't have access to this workspace", not a role switcher. One person may hold several roles; they enter each through its URL and authorization context.
UI navigation is not an authorization boundary. APIs enforce tenant, audience, role and resource policies (see [authorization.md](authorization.md)).
## What each audience may see
| Actor | Player identifier visible | Player email stored? |
|---|---|---|
| Player | Private `pp_plr_...` | No |
| Operator | `pp_plr_opr_...` only | No |
| Provider | `pp_plr_prv_...` only | No |
| Affiliate | Attributed or aggregate context | No |
| PlayerPal staff | Internal subject only where authorised | No player email |
## Demo mapping
Local demos use **host** routing (ADR 0018), not path roles:
| Host | Audience |
|---|---|
| `player.localhost:8080` | Player (real Nest + ZITADEL auth) |
| `staff.localhost:8080` | Staff (simulated sign-in) |
| `operator.localhost:8080` | Operator |
| `provider.localhost:8080` | Provider |
| `affiliate.localhost:8080` | Affiliate |
| `portals.localhost:8080` / `localhost:5173` | Launcher |
Path bypass (`/player`, …) remains for Vite direct mode. Presentation apps in [demos/](../demos/README.md). HTML prototypes under [spec/prototype/](../spec/prototype/README.md). Simulated sign-in outside the player portal is not production authentication.
## Analytics (partner KPIs)
The operator, provider and staff portals have an **Analytics** screen, and the operator and provider Overviews show live tiles. The screen has three tabs:
- **Your performance** (Own tier)
- **You vs PlayerPal network** (anonymous comparison)
- **Peer benchmarks**
The rules are in [kpis.md](kpis.md) and ADR 0033. The partner portals still use simulated sign-in, so locally they read a seeded demo party through the API's `/demo/kpis/*` helper (`DEMO_PARTNER_HELPER=1`, demo parties only). They never use a partner credential in the browser. Running it locally needs Cube (`task cube:db-role && task cube:up`), `CUBE_API_URL` and `CUBEJS_API_SECRET` on the API. With only a handful of local parties, comparisons stay suppressed unless the API also sets `BENCHMARK_ALLOW_LOW_THRESHOLDS=1` with lower `BENCHMARK_*` values.
Charts use Apache ECharts 5.6.0 (modular build, SVG renderer). The library is lazy-loaded, so the player portal never downloads it. One shared theme (`components/kpi/echarts.ts`) encodes the dataviz specs and the series colour `#1b9bc8`, which passes the validator on the portal surface. Every chart has a crosshair or item tooltip, arrow-key reading and a table view. Stat tiles and layout stay plain HTML.
## Partner overview (Spec v0.1 §13)
The operator, studio and affiliate portals open on a registry-driven overview (`components/kpi/overview/`):
- two KPI rows
- activity over time
- partner mix
- campaigns (affiliates)
- geo mix and player segments
- rewards redeemed, with redemption and mission performance
- retention by cohort and missions
Every value comes from `GET /v1/kpis/overview`, and components never compute KPIs. A KPI whose feed hasn't delivered shows the API's requirement text (e.g. "Requires operator commercial feed"), never a zero. KPIs that don't apply to an audience aren't rendered. The affiliate portal shows PrimeWins (`pp_aff_SIMPRIMEWINS`) from the simulated network.
Multi-series charts use the validated 4-slot categorical palette in `echarts.ts` (dataviz reference dark slots, checked on the portal surface). Anything beyond four slices goes into a neutral "Other".
---
# Business value
Source: https://docs-internal.playerpal.io/docs/product/business-value
This page is for investors and advisors. It explains the problem PlayerPal is
built for, why its approach works for every party in the network, and what
exists today. It contains no pricing, market sizing or traction figures.
## The problem
In online gaming, loyalty stops at the edge of each casino. A player who
plays at several operators, and plays one studio's games at all of them, has
a separate, short-lived relationship with each. Points do not travel, and no
party sees the player's wider behaviour.
Sharing data across companies is the obvious fix, and the reason it does not
happen. Operators will not hand competitors or a new intermediary a copy of
their player database. Studios serve many casinos and must not be able to
track players across them. Any network that pools personal data takes on that
risk for everyone in it.
## The approach
PlayerPal is a loyalty and analytics layer that sits beside casinos and
studios rather than in front of them.
```text
ONE PRIVATE PLAYER IDENTITY
MANY AUDIENCE-SPECIFIC ALIASES
NO PLAYER EMAIL, NAME OR PHONE
```
- **One Account, many Aliases.** The player holds one PlayerPal Account.
Each Operator, and each provider-casino pair, receives its own random
Alias for that player. No two parties can match players by comparing
identifiers.
- **No personal data.** PlayerPal never receives a player's email, name or
telephone number. Sign-in uses passkeys and similar methods.
- **Off the game path.** Casinos and studios keep running if PlayerPal is
unavailable. Activity arrives in batches after the fact.
- **Partners report facts, PlayerPal calculates.** Points and KPIs are
computed centrally from reported Activity, so they are consistent across
the network.
## Value for each party
| Party | What they get | Status |
|---|---|---|
| Player | One Account that earns Points across casinos and games, with control over each connection | [Available] |
| Operator | Analytics on linked players and an anonymous view of how they compare with the network | [Available] |
| Game provider | Studio analytics across all its casinos, counted per Account, without cross-casino tracking | [Available] |
| Affiliate | Overview built on operator-reported attribution | [In development] |
| Reward partner | A place in the shop and redemption flow | [Planned] |
Spending Points on rewards is Planned; earning is Available. See
[Capabilities](./capabilities.mdx) for the full list.
## Network effects
Each new party adds value for the others:
- **Players** get more places to earn as more operators and studios join.
- **Operators** get a more meaningful comparison. Network figures become
available in more markets once there are enough peers to keep them
anonymous.
- **Game providers** see the share of a player's turnover that their games
capture across the casinos they serve.
- **Reward partners**, once live, reach players from every casino in the
network through one integration.
Comparisons are suppressed in thin markets by design. Early on, partners will
mostly see global figures, and market-level detail grows with the network.
## Why privacy-first identity matters
Without personal data, the main reason a partner would refuse to join
disappears: PlayerPal cannot become a leak of their player base, and cannot
be used by a competitor to identify their players. It also limits what a
breach of PlayerPal could expose. Players can Unlink a casino or delete
their Account themselves, without asking each partner.
## Defensibility from the design
| Design choice | Why it matters |
|---|---|
| Random pairwise Aliases per Audience | Unlinkable identifiers have to be designed in from the start; adding them later means re-issuing every partner's identifiers |
| Provider Aliases scoped to each casino | Gives studios analytics that operators can accept, because cross-casino tracking is impossible |
| Anonymous comparison with floors and dominance rules | Partners see how they compare without learning anything about a named competitor |
| Asynchronous sidecar | Integration does not touch the wallet, launch or settlement path, so partners can adopt it with low risk |
The value of the comparisons grows with the number of participating parties,
which a new entrant would have to rebuild.
## Current stage
"Available" means built and tested in the platform; it is not a statement
about live partner traffic.
| Area | Status |
|---|---|
| Player Account, sign-in without personal data, passkeys | [Available] |
| Linking, pairwise Aliases, Unlink and Delete Account | [Available] |
| Activity ingest and Points earning | [Available] |
| Partner KPIs, network comparison, peer benchmarks | [Available] |
| Embeddable widget | [Available] |
| Per-partner credentials on every partner route | [In development] |
| Partner and staff portals with production sign-in | [In development] |
| Reward shop | [In development] |
| Points redemption in the platform API | [Planned] |
| Reward partner integration | [Planned] |
| Workforce single sign-on | [Planned] |
## Related
- [Overview](./overview.mdx):
What PlayerPal is, in one page.
- [Capabilities](./capabilities.mdx):
Status of every capability.
- [Privacy](../trust/privacy.mdx):
The commitments behind the design.
---
# Capabilities
Source: https://docs-internal.playerpal.io/docs/product/capabilities
This page lists PlayerPal's capabilities and the status of each. **Available** means built and tested in the platform. **In development** means being built, and behaviour may change. **Planned** means designed or decided, but not built yet. A demo or mockup never counts as Available.
## Identity and linking
| Capability | What it gives you | Status |
|---|---|---|
| PlayerPal Account | One portable loyalty Account per player, across every connected partner | [Available] |
| Passkey sign-in | Phishing-resistant sign-in without a password or email | [Available] |
| PlayerPal ID and password | Sign-in with a PlayerPal-generated ID, no email needed | [Available] |
| Social sign-in | Sign-in with a supported social account, keeping only an opaque reference | [Available] |
| Recovery codes | Account recovery without email or SMS | [Available] |
| No player email, name or phone | PlayerPal never collects direct personal identifiers | [Available] |
| Casino-led linking | A one-time link started from the casino, completed in a PlayerPal window | [Available] |
| Pairwise aliases | A separate random identifier for each partner, and per casino for game providers | [Available] |
| Unlink and Delete Account | Player-controlled disconnection and erasure from the PlayerPal Account | [Available] |
| Partner notice on Unlink | An asynchronous notice to the partner when a player disconnects | [In development] |
| Affiliate and reward-partner aliases | Player aliases for affiliates and reward partners | [Planned] |
## Activity and Points
| Capability | What it gives you | Status |
|---|---|---|
| Asynchronous activity batches | Send settled rounds in batches after the fact, never in the game path | [Available] |
| Large file uploads | Deliver large exports as a file upload instead of an API body | [Available] |
| Safe retries | A batch or event sent twice is counted once | [Available] |
| Per-partner credentials for activity | Each partner authenticates activity delivery with its own credentials | [In development] |
| Points earning | PlayerPal calculates and credits Points from eligible activity | [Available] |
| Versioned campaigns | Every Point credit traces to a fixed campaign version | [Available] |
| Points redemption and reversals | Spending Points and correcting credits | [Planned] |
## Analytics
| Capability | What it gives you | Status |
|---|---|---|
| Daily fact ingest | Send pseudonymous daily gameplay, commercial, catalogue and attribution facts | [Available] |
| Own-player KPIs | Metrics about the players linked to you | [Available] |
| Network comparison | Your players against the rest of the network, as rates, an index and a percentile band | [Available] |
| Peer benchmarks | Anonymous statistics for partners of your type | [Available] |
| Anonymity thresholds | Comparisons withheld when too few players or peers contribute, or one peer dominates | [Available] |
| Game provider distinct-player analytics | Figures counted per player, not per casino integration | [Available] |
| Insights | Automatic, rule-based observations about your figures | [Available] |
| Affiliate analytics | Performance figures for referred players | [In development] |
## Rewards
| Capability | What it gives you | Status |
|---|---|---|
| Points balance and history for players | Players see what they earned and where | [Available] |
| Rewards shop | Players spend Points on rewards | [In development] |
| Reward partner role | Partners who fund or fulfil rewards | [Planned] |
## Portals
| Capability | What it gives you | Status |
|---|---|---|
| Player portal | Account, sign-in methods, connected partners, Points | [Available] |
| Operator portal | Analytics and overview for your casino | [In development] |
| Game provider portal | Analytics and overview for your studio | [In development] |
| Affiliate portal | Performance overview for your referrals | [In development] |
| Separate portal per audience | Each audience signs in at its own address; no role switching | [Available] |
| Enterprise sign-in for partner staff | Single sign-on with your own identity provider | [Planned] |
| Reward partner portal | Self-service for reward partners | [Planned] |
## Integration
| Capability | What it gives you | Status |
|---|---|---|
| Partner API with OAuth 2.0 client credentials | Standard machine-to-machine authentication | [Available] |
| HMAC request signing | An alternative for partners who cannot run OAuth | [Available] |
| Interactive API reference | Browse every partner endpoint (read-only) | [Available] |
| Embeddable PlayerPal widget | A versioned link and status widget for game pages that never blocks play | [In development] |
## Related
- [How it works](./how-it-works.mdx):
The end-to-end journey and data flow.
- [Developer quickstart](../developers/quickstart.mdx):
Start integrating with the partner API.
- [FAQ](./faq.mdx):
Common questions from partners and investors.
---
# Frequently asked questions
Source: https://docs-internal.playerpal.io/docs/product/faq
Short answers to common questions, grouped by who usually asks them. Where something is not yet decided, the answer says so.
## For everyone
### What is PlayerPal?
One player identity and loyalty layer that works across casinos, game providers and affiliates. A player keeps a single PlayerPal Account and earns Points wherever they connect it. Partners get analytics about their own players and an anonymous comparison with the wider network. See [What is PlayerPal](./overview.mdx).
### Does PlayerPal store player emails, names or phone numbers?
No. PlayerPal does not ask for them, and its systems are designed never to receive them. Players sign in with a passkey, a PlayerPal-generated ID and password, or a social account from which PlayerPal keeps only an opaque reference. Partner data that looks like an email address is rejected. PlayerPal still treats pseudonymous identifiers and gameplay data as personal data and protects them accordingly. See [Privacy](../trust/privacy.mdx).
### What happens if PlayerPal is down?
Players keep playing. PlayerPal is never called during game launch, betting, settlement or payout. Partners hold activity locally and send it when PlayerPal is reachable again, so Points arrive late rather than being lost. Linking a new player needs PlayerPal, so that step waits; play does not. See [Availability](../developers/availability.mdx).
### What is available today?
Player sign-in, linking, activity ingestion, Points earning and partner analytics through the API are available. Partner portals, the embeddable widget and the rewards shop are in development. The reward-partner role and enterprise sign-in for partner staff are planned. See [Capabilities](./capabilities.mdx).
### How is PlayerPal priced?
Commercial terms are not published. Contact PlayerPal.
## For players
### Can a player delete their data?
Yes. From the PlayerPal Account a player can Unlink any single partner, or Delete the Account entirely. Delete Account erases the player's PlayerPal identity; gameplay facts may be kept without anything tying them back to the player. It does not delete the player's accounts at casinos. See [Player experience](./player-experience.mdx).
### Can players spend their Points yet?
Not yet. Players earn and view Points today. A rewards shop is in development. See [Points and rewards](../rewards/points-and-rewards.mdx).
### How does a player recover an Account without email?
With one-time recovery codes, a second passkey, or a connected social sign-in. PlayerPal staff cannot reset credentials or take over an Account.
## For operators
### Can I see other operators' data?
No. You see figures about the players linked to you. Comparisons with the network are anonymous statistics only: no names, ranks or individual competitor values, and figures are withheld when too few operators or players contribute or when one competitor would dominate. Your scope comes from your credentials, so you cannot request another party's data. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
### Do I need to change my game launch or wallet?
Your launch and wallet flows stay the same. After a player links, you receive aliases from PlayerPal and pass the game provider's alias to the game in the launch information you already send. PlayerPal plays no part in wallet authorisation or settlement. See [Linking players](../developers/linking-players.mdx).
### Who calculates Points?
PlayerPal. Partners report what happened; they never submit Point totals. This keeps Points consistent across every casino and game. See [Points and rewards](../rewards/points-and-rewards.mdx).
## For game providers
### Can a provider track a player across casinos?
No. A game provider receives a separate alias for each casino it is integrated with, so it cannot join a player from one casino to another by identifier. For analytics, PlayerPal can still tell a provider how many distinct players it reaches across its integrations, as numbers only, and withholds those figures when the group is too small. See [For game providers](./for-game-providers.mdx).
### How does activity reach PlayerPal?
After rounds settle, you write them to your own outbox and send them in batches, either in the request body or as an uploaded file for large exports. PlayerPal confirms receipt straight away and processes the batch afterwards. See [Sending activity](../developers/sending-activity.mdx).
## For affiliates
### How does affiliate attribution work?
In the current version, the operator reports which affiliate referred each player, as the authoritative source. Affiliate analytics and the affiliate portal are in development. See [For affiliates](./for-affiliates.mdx).
## For developers
### How do I start integrating?
PlayerPal issues partner credentials during onboarding; there is no self-service sign-up. You authenticate with OAuth 2.0 client credentials, or with HMAC request signing if you cannot run OAuth. Then follow the [Developer quickstart](../developers/quickstart.mdx) and browse the [API reference](/api/partner).
### Is there a sandbox?
Not yet. The API reference is read-only, and there is no public test environment. Contact PlayerPal to arrange integration testing.
### What currencies are supported?
Euro only in the current version. Support for other currencies depends on a currency policy that is not yet decided.
### What if I send the same batch twice?
It is counted once. Re-sending a batch with the same ID and the same content returns the original receipt. Re-using a batch ID with different content is rejected as a conflict. See [Idempotency and errors](../developers/idempotency-and-errors.mdx).
## Related
- [Capabilities](./capabilities.mdx):
What is available, in development and planned.
- [How it works](./how-it-works.mdx):
The journey from sign-up to Points.
- [Glossary](./glossary.mdx):
The terms used across these docs.
---
# For affiliates
Source: https://docs-internal.playerpal.io/docs/product/for-affiliates
Affiliates are part of the PlayerPal network design: an Affiliate is a
recognised party, operators can report which players an Affiliate brought in,
and an affiliate overview is being built on those facts. Less is built for
affiliates than for operators and game providers, and this page says plainly
what exists.
The labels mean: **Available** (built and tested in the platform), **In
development** (being built; behaviour may change) and **Planned** (designed,
not built yet).
## At a glance
| Capability | Status |
|---|---|
| Affiliate as a party in the network | [Available] |
| Attribution facts, sent by operators | [Available] |
| Affiliate overview built on verified attribution | [In development] |
| Affiliate portal | [In development] |
| Affiliate player Aliases | [Planned] |
| Attributed and activated player KPIs | [Planned] |
## Attribution comes from the operator
[Available]
In the first version, the Operator is the authoritative source for
attribution. The operator sends attribution facts through fact ingest: which
Affiliate a linked player is attributed to, the campaign and click reference,
when it happened, and whether the attribution is claimed, observed or
verified.
The facts use the operator's own player Alias and your Affiliate party ID.
They never carry a player's email, name or phone number. Affiliates do not
send attribution to PlayerPal directly yet.
## Affiliate overview
[In development]
An affiliate overview is being built on verified attribution. It covers the
players attributed to you, commercial mix and campaign performance. Where the
underlying facts have not arrived, it shows what it needs, for example
verified attribution, rather than a zero.
The affiliate portal shows this overview, but its sign-in is not yet
production authentication.
## Not decided yet
[Planned]
- **Affiliate Aliases.** A player Alias format for affiliates is reserved,
but PlayerPal does not issue affiliate Aliases yet. How an affiliate
Audience is scoped has not been decided, so today an Affiliate sees
attributed or aggregate figures, not player-level handles.
- **Attributed and activated player KPIs.** Defined in the catalogue, waiting
on the attribution model.
- **Network comparison for affiliates.** The anonymity rules are designed to
cover affiliates; affiliate comparisons are not live.
If affiliates are important to your plans, contact PlayerPal to discuss the
roadmap.
## Privacy for affiliate data
The same commitments apply as for every party: no player personal data, scope
taken from credentials, and anonymity thresholds on anything compared across
parties. See [Privacy](../trust/privacy.mdx).
## Related
- [For operators](./for-operators.mdx):
Operators link players and send attribution facts.
- [Fact ingest](../developers/fact-ingest.mdx):
The attribution schema operators send.
- [Capabilities](./capabilities.mdx):
Status of every PlayerPal capability.
---
# For game providers
Source: https://docs-internal.playerpal.io/docs/product/for-game-providers
PlayerPal lets the rounds your games already settle earn Points for linked
players, and gives your studio analytics on how those players play, without
handling personal data or sitting in the game loop. You report facts about
settled rounds; PlayerPal does the loyalty calculation.
The labels mean: **Available** (built and tested in the platform), **In
development** (being built; behaviour may change) and **Planned** (designed,
not built yet).
## What you get
| Capability | What it means for you | Status |
|---|---|---|
| Activity from game rounds | Settled rounds sent in batches earn Points for linked players | [Available] |
| Studio analytics | Rounds, bets, wins and share of turnover for the players reached through your integrations | [Available] |
| Cohort analytics | Distinct-Account counts across all your integrations, with small groups suppressed | [Available] |
| KPI API and comparisons | Own KPIs, anonymous comparison with the network, and peer benchmarks among providers | [Available] |
| PlayerPal widget | A PlayerPal panel you can show inside your game experience | [In development] |
| Provider launch sessions | A pattern for receiving launch context from the operator safely | [In development] |
| Provider portal | Browser workspace for your studio analytics | [In development] |
## Aliases scoped to one casino
[Available]
A provider works with many operators, so PlayerPal gives you a separate Alias
(`pp_plr_prv_…`) for each provider-operator integration. The same player at
two casinos reaches you as two unrelated Aliases. You cannot join a player
across casinos through PlayerPal, and no other party can join your Aliases
with theirs.
- Aliases are random and contain no personal data, account IDs or timestamps.
- Your integration, not a field in the payload, decides which casino an event
belongs to.
- An Alias issued to someone else gets the same response as one that does not
exist.
## Off the game path
[Available]
PlayerPal is never called at launch, bet or settlement. The operator places
your provider Alias in its existing launch payload. Your backend writes
eligible rounds to its own outbox and sends them to PlayerPal in batches
after settlement, retrying until they are accepted. If PlayerPal is
unavailable, games keep running and winnings are paid as normal.
## Launch sessions and the widget
The operator creates a short-lived launch session through your own backend;
PlayerPal is not involved. Launch context is passed in a single-use request
body, never in the game URL. Your page receives only your integration's
Alias and a coarse connection status. Points balances, the ledger and the
player's private PlayerPal ID stay on PlayerPal surfaces.
You load the PlayerPal widget as an exact, versioned build from PlayerPal,
checked with Subresource Integrity. You do not rebuild or redistribute it,
and a widget failure never blocks play.
| Part | Status |
|---|---|
| Versioned widget builds served with integrity hashes | [Available] |
| Launch session pattern | [In development] |
| Provider-led linking from inside your experience | [Available] |
## Studio and cohort analytics
[Available]
Studio analytics count distinct Accounts across all of your integrations, so
a player who plays your games at two casinos is counted once. Windows with
fewer Accounts than the minimum are suppressed. You can also look up figures
for a single Alias that was issued to you; the response contains numbers
only, such as rounds and turnover with you and elsewhere.
The KPI API adds your own KPIs, a comparison with the rest of the network and
benchmarks against other providers, all behind the same anonymity rules. See
[Privacy thresholds](../analytics/privacy-thresholds.mdx).
## Integration status
| Part | Status |
|---|---|
| Activity batches and large uploads, idempotent | [Available] |
| Per-partner credentials for activity batches | [In development] |
| Daily game and catalogue facts through fact ingest | [Available] |
| Studio analytics and KPI API with OAuth or signed requests | [Available] |
## Related
- [Send activity](../developers/sending-activity.mdx):
Batch settled rounds and handle retries.
- [Widget](../developers/widget.mdx):
Load the PlayerPal widget in your game experience.
- [Availability](../developers/availability.mdx):
Why play never waits for PlayerPal.
- [KPI API](../developers/kpi-api.mdx):
Studio KPIs, comparisons and benchmarks.
- [Fact ingest](../developers/fact-ingest.mdx):
Daily game and catalogue facts.
- [Privacy](../trust/privacy.mdx):
What PlayerPal holds and who can see it.
---
# For operators
Source: https://docs-internal.playerpal.io/docs/product/for-operators
PlayerPal gives your players a loyalty Account that earns Points across the
network, and gives you analytics on those players, without becoming a second
copy of your player database. You never send PlayerPal a player's email, name
or phone number, and PlayerPal is never in the path of login, wagering or
payouts.
This page summarises what an Operator gets, with the status of each part. The
labels mean: **Available** (built and tested in the platform), **In
development** (being built; behaviour may change) and **Planned** (designed,
not built yet).
## What you get
| Capability | What it means for you | Status |
|---|---|---|
| Linked players | Players connect their casino account to PlayerPal once, in a PlayerPal-hosted consent step | [Available] |
| Points across the network | Linked players earn Points from settled game rounds; PlayerPal calculates them | [Available] |
| Analytics on your players | KPIs about the players linked to you, through the KPI API | [Available] |
| Anonymous network comparison | How your players compare with the rest of the network, and with peer operators, without revealing any competitor | [Available] |
| Insights | Rule-based observations on your KPIs | [Available] |
| Operator portal | Browser workspace for your analytics | [In development] |
| Spending Points on rewards | A player shop and redemption | [In development] |
## Your players stay yours
[Available]
- **No personal data.** PlayerPal never receives or stores a player's email,
name or telephone number. The link step asks you for a signed assertion
about your own player, not their details.
- **Your own Alias.** Each linked player gets an Alias that only you receive
(`pp_plr_opr_…`). It is random, so it cannot be matched with the Alias
another operator holds for the same person.
- **Only your players.** Every KPI and lookup is scoped to the players linked
to you. Your scope comes from your credentials, never from a field in the
request.
- **The player is in control.** A player can Unlink your casino at any time
from their PlayerPal Account. Play at your casino continues; later Activity
simply stops earning.
## No impact on your platform
[Available]
PlayerPal is a sidecar, not a dependency. If PlayerPal is unreachable, your
players still sign in, launch games, place bets, settle rounds and get paid.
There is no PlayerPal call at login, game launch or settlement. Activity is
sent afterwards in batches and retried until it is accepted.
PlayerPal does not handle your wallet, your casino login or your bonus
engine. The player's casino account and the PlayerPal Account are separate.
## Analytics and comparison
[Available]
The KPI API gives you three views:
| View | What you see |
|---|---|
| Your performance | Linked players, new links, link funnel, active accounts, rounds, turnover, Points issued, ingest health |
| You vs the network | Your players' per-player figures against the rest of the network, as an index and a percentile band |
| Peer benchmarks | Where you sit among other operators: quartiles and your band, never names or ranks |
Comparisons are protected by anonymity thresholds. Thin or dominated segments
are suppressed rather than shown, so in early markets you will mostly see
global figures. Some KPIs, such as retention, depend on data feeds that are
still being added and show what they require instead of a zero. See
[Privacy thresholds](../analytics/privacy-thresholds.mdx) and
[Metrics](../analytics/metrics.mdx).
:::info[In development]
The operator portal shows these analytics in a browser workspace, but its
sign-in is not yet production authentication. Use the KPI API with your
partner credentials for live access.
:::
## How the integration works
### Link a player
Your backend creates a link intent with a short-lived signed assertion.
The player completes a PlayerPal-hosted consent step, and you exchange the
result for your Alias and any provider Aliases you requested.
### Pass the provider Alias at launch
Put the provider Alias in your existing game launch payload. PlayerPal is
not called.
### Activity flows asynchronously
The game provider sends settled rounds in batches. You can also send daily
commercial facts through fact ingest.
### Read your KPIs
Call the KPI API with OAuth client credentials or signed requests.
### Integration status
| Part | Status |
|---|---|
| Link flow with signed operator assertion | [Available] |
| Per-partner credentials for linking and activity batches | [In development] |
| Fact ingest (commercial and attribution facts) with OAuth or signed requests | [Available] |
| KPI API with OAuth or signed requests | [Available] |
## Related
- [Quickstart](../developers/quickstart.mdx):
The shortest path from credentials to a first linked player.
- [Link players](../developers/linking-players.mdx):
The link intent, consent step and Alias exchange.
- [Send activity](../developers/sending-activity.mdx):
Batches, idempotency and retries.
- [KPI API](../developers/kpi-api.mdx):
Read your KPIs, comparisons and benchmarks.
- [Fact ingest](../developers/fact-ingest.mdx):
Send daily commercial and attribution facts.
- [Privacy](../trust/privacy.mdx):
What PlayerPal holds and who can see it.
---
# For reward partners
Source: https://docs-internal.playerpal.io/docs/product/for-reward-partners
A Reward partner is an organisation that funds or fulfils the rewards players
spend their Points on. Reward partners are part of the PlayerPal network
design, but nothing for them is built yet. This page describes the intended
role so you can see where it fits. Everything here is **Planned** and may
change.
## Where things stand
| Part | Status |
|---|---|
| Reward partner as a party type in the network model | [Available] |
| Players earning Points | [Available] |
| Spending Points: shop, redemption and fulfilment | [Planned] |
| Reward partner portal or API | [Planned] |
| Reward partner player Aliases | [Planned] |
Today, players can earn Points but cannot spend them. A shop and redemption
flow have been designed and prototyped, but are not part of the platform yet.
## The intended role
In the shop design, every reward sits behind a fulfilment step. When a player
redeems, PlayerPal debits the Points and hands the order to the reward's
fulfilment partner, which delivers a code or a claim link. If fulfilment
fails, the Points are returned.
```mermaid
flowchart LR
P[Player] -->|redeems Points| PP[PlayerPal]
PP -->|order, scoped Alias| RP[Reward partner]
RP -->|code or claim link| PP
PP -->|reveal| P
RP -.->|fulfilment fails| PP
PP -.->|Points returned| P
```
## Privacy by design
The same rules that protect players elsewhere are designed to apply to reward
partners:
- **No personal data from PlayerPal.** Fulfilment requests carry no email,
name or phone number. Where a reward needs delivery details, the partner
collects them directly from the player.
- **A scoped handle, not the player's ID.** A partner would receive its own
scoped Alias, never the player's private PlayerPal ID. A reward-partner
Alias format is reserved, but how that Audience is scoped has not been
decided.
- **One order, once.** Each redemption carries a unique reference, so a
resent request cannot create a second order.
## Not decided
Commercial terms, how rewards are funded, which kinds of reward partner will
take part and how they will integrate have not been decided. Contact
PlayerPal if you would like to be part of those conversations.
## Related
- [Points and rewards](../rewards/points-and-rewards.mdx):
How Points are earned today and what is planned.
- [Capabilities](./capabilities.mdx):
Status of every PlayerPal capability.
- [Privacy](../trust/privacy.mdx):
What PlayerPal holds and who can see it.
---
# Glossary
Source: https://docs-internal.playerpal.io/docs/product/glossary
The terms PlayerPal uses in its product and developer documentation, in alphabetical order.
**Account**: A player's portable PlayerPal loyalty account, created the first time they sign up or link. It is not a casino account or a sign-in method. Also called the PlayerPal Account. See [Player experience](./player-experience.mdx).
**Activity**: A factual gameplay event a partner reports for a player alias after it happens, such as a settled round with its bet, win, game and time. PlayerPal calculates Points from eligible Activity. See [Sending activity](../developers/sending-activity.mdx).
**Affiliate**: A partner that refers players to operators. In the current version, operators report affiliate attribution as the authoritative source. See [For affiliates](./for-affiliates.mdx).
**Alias**: A random, PlayerPal-issued identifier for a player that is meaningful only to the one partner it was issued to. Aliases are never derived from personal data. Also called a pairwise alias. See [Linking players](../developers/linking-players.mdx).
**Anonymity thresholds**: The rules that withhold comparison figures when too few players or peers contribute, or when one peer would dominate the result. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
**Audience**: Who may see and use a given alias: a particular operator, or a particular game provider at a particular operator.
**Batch**: A group of Activity events or facts sent together. Batches are accepted first and processed afterwards, and a batch sent twice is counted once. See [Idempotency and errors](../developers/idempotency-and-errors.mdx).
**Benchmark**: Anonymous statistics (quartiles and mean) for partners of the same type, with your own position shown as a percentile band. Never names, ranks or individual peer values. See [Metrics](../analytics/metrics.mdx).
**Campaign**: A versioned set of rules that decides which Activity earns Points and how many. Past credits always refer to the campaign version that produced them. See [Points and rewards](../rewards/points-and-rewards.mdx).
**Delete Account**: The player's action to erase their PlayerPal identity. Gameplay facts may remain without anything tying them to the player. It does not delete accounts the player holds at partners. See [Player experience](./player-experience.mdx).
**Fact**: A pseudonymous daily figure a partner sends for analytics, such as a player's daily gameplay at a studio or daily commercial figures at a casino. See [Fact ingest](../developers/fact-ingest.mdx).
**Game provider**: A studio whose games run at operators. A game provider receives a separate alias for each operator it is integrated with. Also called a provider or studio. See [For game providers](./for-game-providers.mdx).
**Insights**: Automatic observations about a partner's figures, produced by fixed rules. See [Insights](../analytics/insights.mdx).
**Link**: The one-time step in which a player connects a partner to their PlayerPal Account. No Activity is sent for a player until a link is complete. See [Linking players](../developers/linking-players.mdx).
**Link broker**: The partner's own backend that the PlayerPal widget calls to check link status and, when that partner starts linking, to run the link flow. It keeps partner credentials out of the browser. See [Widget](../developers/widget.mdx).
**Linking mode**: How a provider alias reaches a game: already linked, linked by the operator before launch, or linked by the provider after launch. The mode never changes what a partner may see.
**Market**: The licensed jurisdiction an activity took place in, as a two-letter country code. It comes from the partner's report, never from the player. See [Metrics](../analytics/metrics.mdx).
**Network comparison**: Your players' figures against the rest of the PlayerPal network, shown as per-player rates, an index and a percentile band. See [Analytics overview](../analytics/overview.mdx).
**Operator**: A casino or other gaming business where players hold accounts and play. See [For operators](./for-operators.mdx).
**Passkey**: A sign-in credential stored on the player's device and unlocked with fingerprint, face or device PIN. PlayerPal's recommended sign-in method.
**Player**: A person with a PlayerPal Account. One person may hold more than one Account if they choose not to combine them.
**PlayerPal ID**: The identifier a player sees for their own Account. It is never sent to any partner.
**PlayerPal widget**: An optional, versioned PlayerPal component that partners embed in game pages to show link status and start linking. Its failure never blocks play. See [Widget](../developers/widget.mdx).
**Points**: Loyalty units PlayerPal calculates from eligible Activity and records on the player's ledger. Partners never submit Point totals. See [Points and rewards](../rewards/points-and-rewards.mdx).
**Recovery codes**: One-time codes a player keeps to regain access to their Account without email or SMS.
**Reward partner**: A partner that funds or fulfils rewards players can redeem with Points. This role is planned. See [For reward partners](./for-reward-partners.mdx).
**Round**: One game cycle at a game provider, from accepted bet to settled outcome. It is complete before PlayerPal ever hears about it.
**Unlink**: The player's action to disconnect one partner from their Account. Play continues and earned Points stay, but later play with that partner stops earning. See [Player experience](./player-experience.mdx).
**Visibility tier**: The level a figure belongs to: your own players, network comparison, or anonymous peer benchmark. See [Analytics overview](../analytics/overview.mdx).
## Related
- [What is PlayerPal](./overview.mdx)
- [How PlayerPal works](./how-it-works.mdx)
- [Frequently asked questions](./faq.mdx)
---
# How PlayerPal works
Source: https://docs-internal.playerpal.io/docs/product/how-it-works
PlayerPal sits beside the casino and the game, never in front of them. A Player connects a casino account to PlayerPal once. From then on, partners send what happened in batches after the fact, PlayerPal turns eligible activity into Points, and each partner reads analytics about its own players. This page walks through the journey step by step and shows what data moves at each point.
Watch first. This follows the journey below from sign-up to analytics in six short scenes.
## The journey at a glance
```mermaid
flowchart LR
P[Player] -->|1. Signs up| PP[PlayerPal Account]
P -->|2. Links from casino| PP
P -->|3. Plays| C[Casino and game provider]
C -->|4. Activity in batches, later| PP
PP -->|5. Credits Points| P
PP -->|6. Analytics, own players only| C
```
| Step | What happens | Status |
|---|---|---|
| 1 | Player creates a PlayerPal Account | [Available] |
| 2 | Player links a casino account; each partner gets its own alias | [Available] |
| 3 | Player plays as normal; PlayerPal is not involved | [Available] |
| 4 | Partners send activity asynchronously in batches | [Available] |
| 5 | PlayerPal calculates and credits Points | [Available] |
| 6 | Partners read analytics through the API | [Available] |
| 6 | Partners read analytics in their own portal | [In development] |
## 1. The player signs up
[Available]
The Player creates a PlayerPal Account with a passkey, a password with a PlayerPal-generated ID, or a supported social sign-in. PlayerPal never asks for an email address or phone number. From a social sign-in it keeps only an opaque reference, not the player's profile. See [Player experience](./player-experience.mdx).
## 2. The player links a casino account
[Available]
Linking is a one-time step, started by the casino. No activity flows for a player until it is complete.
```mermaid
sequenceDiagram
participant Casino as Casino backend
participant PP as PlayerPal
participant Player
Casino->>PP: Create link request (signed, short-lived)
PP-->>Casino: One-time link address
Casino->>Player: Open PlayerPal window
Player->>PP: Sign in or create Account, approve this casino
PP-->>Casino: Result code
Casino->>PP: Exchange result code
PP-->>Casino: Aliases for this casino and its game integrations
```
When the link completes, PlayerPal issues **pairwise aliases**: random identifiers that mean something only to the partner they were issued to.
- The casino receives one alias for the player.
- Each game provider receives a separate alias per casino it is integrated with, so a provider cannot follow a player from one casino to another.
- Aliases are random. They are not derived from any personal data or from the player's own PlayerPal ID, which is never shared with partners.
The casino passes the provider's alias to the game in its existing launch information. PlayerPal is not called at launch.
## 3. The player plays
[Available]
Login, game launch, bets, settlement and payouts happen exactly as they do today, between the casino and the game provider. PlayerPal takes no part. If PlayerPal is unavailable, play continues. See [Availability](../developers/availability.mdx).
## 4. Activity flows in batches
[Available]
After a round settles, the partner records it locally and sends it to PlayerPal later, in a batch. PlayerPal confirms durable receipt straight away and processes the batch afterwards. Retries are safe: a batch or event sent twice is counted once.
```mermaid
flowchart LR
G[Round settles at the partner] --> O[Partner's local outbox]
O -->|Batch, with retries| I[PlayerPal ingestion]
I -->|Accepted for processing| Q[Processing]
Q --> L[Points ledger]
Q --> A[Analytics]
```
There are two kinds of data:
| Data | Sent by | Used for | Guide |
|---|---|---|---|
| Per-round activity (bet, win, game, time) | Game provider | Points | [Sending activity](../developers/sending-activity.mdx) |
| Daily facts (gameplay, commercial, game catalogue, affiliate attribution) | Game provider or operator | Analytics | [Fact ingest](../developers/fact-ingest.mdx) |
Per-partner credentials for the per-round activity endpoints are [In development]. Daily fact ingest already uses per-partner credentials.
## 5. Points are credited
[Available]
PlayerPal calculates Points from eligible activity. Partners report facts; they never submit Point totals. Activity counts only if it happened after the player linked and before any Unlink. Campaign rules are versioned, so every credit can be explained later.
Spending Points in a rewards shop is [In development]. See [Points and rewards](../rewards/points-and-rewards.mdx).
## 6. Partners read analytics
[Available]
Each partner sees figures about the players linked to it, a comparison with the rest of the network, and anonymous peer benchmarks. Comparison figures are withheld when too few players or peers contribute. Partner portals that show these figures are [In development]; the API is available today. See [Analytics overview](../analytics/overview.mdx).
## What moves where
| Data | Player | Operator | Game provider | PlayerPal |
|---|---|---|---|---|
| Player's own PlayerPal ID | Sees it | Never | Never | Holds it |
| Operator alias | No | Its own only | Never | Holds it |
| Provider alias | No | Passes it to the game at launch | Its own, per casino | Holds it |
| Gameplay and commercial facts | Own summary | Sends its own | Sends its own | Processes them |
| Analytics | Own Points and activity | Own players plus anonymous comparison | Own players plus anonymous comparison | Network-wide |
## What never moves
- A player's email address, name or telephone number. PlayerPal does not collect them.
- A partner's login credentials or session cookies for its own players.
- One partner's alias to another partner.
- Any call from the game path to PlayerPal during launch, betting or payout.
## Related
- [Linking players](../developers/linking-players.mdx):
Run the link flow from your casino.
- [Privacy](../trust/privacy.mdx):
What PlayerPal holds and what it never receives.
- [Capabilities](./capabilities.mdx):
What is available, in development and planned.
---
# What is PlayerPal
Source: https://docs-internal.playerpal.io/docs/product/overview
PlayerPal is one player identity and loyalty layer for the online gaming network. A Player keeps a single PlayerPal Account and earns Points across every casino and game they connect. Operators, game providers and affiliates get loyalty and analytics about their own players. PlayerPal does this without holding a player's email address, name or telephone number.
A single PlayerPal Account that follows the player across every casino and game they connect.
Each partner gets its own random identifier for a player, so nobody can join players across partners.
PlayerPal never holds a player's email address, name or telephone number.
## The problem
Loyalty in online gaming is locked inside each casino. A player who plays the same studio's games at three casinos has three unrelated loyalty balances, and none of them follow the player. Each partner sees a slice of the picture and has no fair way to compare itself with the rest of the market.
The obvious fix, a shared player database, does not work. Operators will not hand their players' personal data to a third party, and a central dependency in the game path would turn any outage into a casino outage.
PlayerPal is designed around both constraints: a portable Account with no personal data, and a service that sits beside the game, never inside it.
## Who takes part
| Party | Role in the network | Status |
|---|---|---|
| Player | Holds the PlayerPal Account, connects casinos and games, earns Points | [Available] |
| Operator (casino) | Links its players to PlayerPal, sends commercial facts, reads analytics | [Available] |
| Game provider (studio) | Sends gameplay activity, reads analytics about players reached through its integrations | [Available] |
| Affiliate | Receives attribution and performance figures for the players it referred | [In development] |
| Reward partner | Funds or fulfils rewards that players redeem with Points | [Planned] |
## What each party gets
**Players** get one Account that works across casinos and games, Points that accumulate wherever they play, and full control: they can disconnect any partner or delete the Account at any time. See [Player experience](./player-experience.mdx).
**Operators** get a loyalty programme that players already carry with them, analytics about their own linked players, and an anonymous comparison with the rest of the network. See [For operators](./for-operators.mdx).
**Game providers** get loyalty for their players across every casino that runs their games, and analytics counted in distinct players rather than per-casino fragments. See [For game providers](./for-game-providers.mdx).
**Affiliates** get attribution and performance figures for the players they referred, reported by the operator as the authoritative source. See [For affiliates](./for-affiliates.mdx).
**Reward partners** will be able to fund or fulfil rewards for an engaged, pseudonymous audience. This role is planned. See [For reward partners](./for-reward-partners.mdx).
## Core principles
### No player personal data
[Available]
PlayerPal never asks for, and does not store, a player's email address, name or telephone number. Sign-in keeps only an opaque reference from the sign-in method. Partners send activity against PlayerPal-issued aliases, and anything shaped like an email address is rejected. Read more in [Privacy](../trust/privacy.mdx).
### Casino play never depends on PlayerPal
[Available]
PlayerPal is not called during game launch, betting, settlement or payout. Partners record activity locally and send it in batches afterwards. If PlayerPal is unavailable, players keep playing and activity is delivered once it is back. Read more in [Availability](../developers/availability.mdx).
### Partners see only their own players
[Available]
Each partner receives its own random alias for a player, so two partners cannot match the same person by comparing identifiers. A game provider receives a separate alias for each casino it is integrated with, so it cannot follow a player from one casino to another. Partner scope always comes from the partner's credentials, never from the request.
### Anonymous comparison
[Available]
Partners can compare their players with the rest of the network without learning anything about a named competitor. Figures are only shown when enough players and enough peers contribute, and are withheld when one competitor would dominate the result. Read more in [Privacy thresholds](../analytics/privacy-thresholds.mdx).
## Where things stand
The core platform is built and tested: player sign-in, linking, activity ingestion, Points earning and partner analytics. Partner portals, the embeddable widget and the rewards shop are in development, and Points redemption in the platform API is planned. The reward-partner role and enterprise sign-in for partner staff are planned. The full list is in [Capabilities](./capabilities.mdx).
## Explore
- [How it works](./how-it-works.mdx):
The journey from sign-up to Points, and what data moves where.
- [For operators](./for-operators.mdx):
Portable loyalty and analytics for your casino.
- [For game providers](./for-game-providers.mdx):
Loyalty and distinct-player analytics across every casino you run on.
- [For affiliates](./for-affiliates.mdx):
Attribution and performance for the players you refer.
- [Business value](./business-value.mdx):
Why a shared, privacy-first loyalty layer matters.
- [Developer quickstart](../developers/quickstart.mdx):
Authenticate, send a batch and read analytics back.
## Related
- [Capabilities](./capabilities.mdx)
- [Frequently asked questions](./faq.mdx)
- [Privacy](../trust/privacy.mdx)
- [Glossary](./glossary.mdx)
---
# Player experience
Source: https://docs-internal.playerpal.io/docs/product/player-experience
A Player has one PlayerPal Account that follows them across every casino and game they connect. They sign in without an email address, choose which partners are connected, see the Points they have earned, and can disconnect a partner or delete the Account at any time. Casino play never waits on PlayerPal.
## The PlayerPal Account
[Available]
The Account is the player's portable loyalty identity. It holds their sign-in methods, recovery codes, connected partners, signed-in devices and Points. The player sees their own PlayerPal ID; partners never do. Each partner only ever receives its own alias for the player.
PlayerPal does not ask for or store the player's email address, name or telephone number.
## Signing in
[Available]
| Method | How it works |
|---|---|
| Passkey | The recommended method. Uses the device's built-in sign-in (fingerprint, face or device PIN). Nothing to remember, nothing to phish. |
| PlayerPal ID and password | PlayerPal generates the ID; the player chooses a password. No email address is involved. Players are encouraged to add a passkey. |
| Social sign-in | Sign in with a supported social account. PlayerPal keeps only an opaque reference from the provider, never the profile, email or name. |
Email links and SMS codes are not offered, because they would require PlayerPal to hold an email address or phone number.
### Recovery
[Available]
Because there is no email or SMS, recovery relies on factors the player holds:
- one-time recovery codes, generated after sign-up
- an additional passkey on another device
- an already-connected social sign-in
Players can see their signed-in devices and sign out the ones they no longer use. PlayerPal staff cannot reset a player's credentials or take over an Account.
## Linking from a casino
[Available]
### Start at the casino
The player chooses to connect PlayerPal from the casino's site, or from the PlayerPal widget in a game.
### Sign in or create an Account
A PlayerPal window opens. The player signs in, or creates an Account if they do not have one. If they already have PlayerPal, they are asked to sign in rather than create a second Account.
### Approve the connection
The player approves that specific casino, and the games it offers, as a connected partner.
### Start earning
From then on, eligible play at that casino earns Points automatically. The player does not need to stay signed in to PlayerPal.
Play that happened before the link does not earn Points. Linking is optional: a player who cancels keeps playing as normal.
## Connected partners
[Available]
The Account screen lists each connected partner by name. Disconnecting is done here, on the PlayerPal Account, not in casino settings. A casino may link to this screen from its own account pages.
## Unlink
[Available]
Unlink disconnects one partner. Before confirming, the player is asked to re-authenticate and is shown what happens:
- play at that casino continues as normal
- Points already earned stay on the Account
- later play with that partner no longer earns Points
- reconnecting later creates a new link with a new alias
Unlinking a casino also disconnects the game integrations at that casino. Unlinking a single game integration leaves the casino connected.
## Delete Account
[Available]
Delete Account erases the player's PlayerPal identity: sign-in methods, connected partners and the links between the player and their aliases. It requires re-authentication and a confirmation that the player's casino accounts are not affected. After deletion the player is signed out.
Gameplay facts may be kept without anything that ties them back to the player. Delete Account does not delete the player's accounts at casinos or other partners; those remain the partner's responsibility.
## Points balance and activity
[Available]
The player portal shows the current Points balance, recent ledger entries, which partners the Points came from and a day-by-day chart. Points are calculated by PlayerPal from the activity partners report, so every partner's contribution is counted the same way.
## The widget in games
[Available]
Game pages can show the PlayerPal widget. It tells the player whether that game is connected ("Earning points") or offers to connect ("Connect to earn"). The widget is optional and loads separately from the game: if it fails to load, the game is unaffected.
## Rewards shop
[In development]
A rewards shop where players spend Points is in development. Today, players earn and view Points; spending them is not yet available. See [Points and rewards](../rewards/points-and-rewards.mdx).
## Related
- [How it works](./how-it-works.mdx):
The journey behind the player's experience.
- [Privacy](../trust/privacy.mdx):
What PlayerPal holds about players, and what it never receives.
- [Widget](../developers/widget.mdx):
Add the PlayerPal widget to a game page.
---
# PlayerPal documentation
Source: https://docs-internal.playerpal.io/docs
Day-to-day source of truth for architecture and decisions. The long-form identity and authentication specification remains [spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md](../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md) and wins on conflict.
## Precedence
```text
spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md wins on conflict
docs/ + docs/decisions/ day-to-day source of truth
docs/historical/ superseded briefs and old ID diagrams
```
Do not weaken an invariant in [spec §30](../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md) without an ADR.
## Guides
The documentation site (`task docs:dev`) has a public part for partners, prospects and investors, and this internal handbook. Public pages live in `product/`, `developers/`, `analytics/`, `rewards/` and `trust/`; everything else is internal. See [status.mdx](status.mdx) for what is built and [contributing/docs.md](contributing/docs.md) and [contributing/style.md](contributing/style.md) for how pages are written.
| Start with | For |
|---|---|
| [product/overview.mdx](product/overview.mdx) | What PlayerPal is and who takes part |
| [developers/quickstart.mdx](developers/quickstart.mdx) | Partner engineers integrating for the first time |
| [analytics/metrics.mdx](analytics/metrics.mdx) | KPI definitions |
| [trust/privacy.mdx](trust/privacy.mdx) | Public privacy commitments |
| [security-privacy/personal-data.md](security-privacy/personal-data.md) | Internal deep dive on player data, with code evidence and gaps |
| [shop/redemption-lifecycle.md](shop/redemption-lifecycle.md) | Internal deep dive on the demo shop |
## Start here
| Document | What it covers |
|---|---|
| [architecture.md](architecture.md) | Product, components, stack, delivery phases |
| [identity.md](identity.md) | Identifiers, pairwise aliases, encoding |
| [authentication.md](authentication.md) | IAM vs PlayerPal, methods, sessions, tokens |
| [linking-and-bootstrap.md](linking-and-bootstrap.md) | Registration, operator/provider linking, Unlink, Delete Account, discovery, merge |
| [portals.md](portals.md) | URL-isolated audiences |
| [authorization.md](authorization.md) | IAM personas vs PlayerPal permissions |
| [ingestion-and-availability.md](ingestion-and-availability.md) | Sidecar, batches, outbox, SLOs |
| [loyalty.md](loyalty.md) | Ledger, eligibility, versioned campaigns, studio analytics |
| [kpis.md](kpis.md) | Partner and staff KPIs, visibility tiers, anonymous comparison rules |
| [data-model.md](data-model.md) | Identity and party tables |
| [../schema/](../schema/README.md) | Seeds + historical SQL (DDL = TypeORM) |
| [../infra/](../infra/README.md) | Local ZITADEL + NestJS + Postgres |
| [open-questions.md](open-questions.md) | Items still undecided |
| [decisions/](decisions/README.md) | Architecture Decision Records |
Flows, HTTP examples, and diagrams live in the spec. Topic pages summarise decisions and cite spec sections.
## Diagrams
- Bootstrap and linking: [spec/playerpal-bootstrap-diagrams/](../spec/playerpal-bootstrap-diagrams/)
- Device mockups: [spec/playerpal-device-mockups/](../spec/playerpal-device-mockups/)
## How to add an ADR
1. Copy [adr-template.md](adr-template.md) to `docs/decisions/NNNN-short-title.md` using the next number.
2. One decision per ADR. Keep Status / Context / Decision / Consequences.
3. Do not rewrite a decided ADR in place. Mark it `Superseded by NNNN` and write a new record.
4. Update [open-questions.md](open-questions.md) if the ADR closes or opens a question.
5. If the decision changes identity or privacy behaviour, update the relevant topic page and cite the spec section that still applies.
Historical briefs: [historical/architecture-brief.md](historical/architecture-brief.md) and [historical/id-architecture.md](historical/id-architecture.md).
## Research notes
Primary-source write-ups for open design questions (not day-to-day truth; ADRs and topic pages win).
| Note | Topic |
|---|---|
| [2026-09-05-zitadel-custom-login-ui.md](research/2026-09-05-zitadel-custom-login-ui.md) | ZITADEL branding vs fork Login V2 vs build-your-own vs community UI |
---
# Points and rewards
Source: https://docs-internal.playerpal.io/docs/rewards/points-and-rewards
Players earn PlayerPal points when they play with linked operators and game
providers. PlayerPal calculates the points itself from the activity partners
report, and records every credit in an append-only ledger. A rewards shop,
where players spend points on rewards, is in development.
| Part | Status |
|---|---|
| Earning points from qualifying activity | [Available] |
| Points ledger and balance | [Available] |
| Rewards shop and redemption | [In development] |
## Earning points
[Available]
Partners report facts about play; they never calculate points. PlayerPal
applies a versioned campaign rule to each qualifying event and credits the
result to the player's ledger.
```mermaid
flowchart LR
P[Partner sends
settled rounds] --> Q{Qualifies?}
Q -- yes --> C[Campaign version
calculates points]
C --> L[(Points ledger
credit)]
Q -- no --> R[Event rejected
with a reason]
```
An activity event qualifies only when all of these hold:
- It is a **settled round** for a player alias PlayerPal issued to that
partner.
- The player's link to that partner was **in force when the round occurred**:
on or after the link became effective, and before any unlink.
- The stake is in **EUR**.
- The round's market, if given, is one the integration is licensed for.
How many Points a qualifying round earns is set by the active campaign's
rules. Campaign rules are versioned: every credit records the
exact campaign version that produced it, so past awards stay explainable after
a rule changes.
Earning starts only after a player registers with PlayerPal and links an
account. There is no backdated earning for play before the link. A player who
unlinks stops earning from that partner from then on; points already earned
stay on the ledger.
## The points ledger
[Available]
- **Append-only.** Entries are never edited or deleted. By design, any
correction is a new, compensating entry.
- **The balance is derived.** A player's balance is the sum of their ledger
entries.
- **No double credits.** Each credit is unique per campaign version, activity
event and action. A partner can safely resend a batch: a round already
credited is not credited again.
- **Players can see it.** In PlayerPal, players see their balance and recent
ledger entries, with the game each credit came from.
- **Partners see aggregates only.** Operators and game providers can read the
points issued for their integrations as a KPI. See
[`own.points_issued`](../analytics/metrics.mdx#points-kpi).
Today the ledger records credits. Spending points arrives with the rewards
shop below.
## The rewards shop
[In development]
:::info[In development]
The rewards shop is being built. The design below describes how it is intended
to work; behaviour, states and limits may change before it is available.
:::
### From points to a reward
### Browse the catalogue
The player sees rewards for their market, each with its price in points
and whether they can redeem it now. If they are short, the shop shows how
many more points they need.
### Confirm
The player confirms one reward. The shop checks eligibility on the server
and debits the points.
### Fulfilment
PlayerPal hands the order to the reward's source and tracks the outcome
asynchronously.
### Access the reward
Once fulfilled, the player reveals their code or claim link through a
separate, protected step.
Prices are set in points, optionally per market. The player is always taken
from their signed-in session; no request names a player.
### Eligibility
Each reward shows one state, decided on the server. The checks run in this
order, so a reward that is not on sale says so before the player's balance is
mentioned.
| Order | State | Meaning | Redeemable |
|---|---|---|---|
| 1 | Expired | The reward's sale window has ended | No |
| 2 | Coming soon | Not yet on sale | No |
| 3 | Not in your market | Not offered in the player's market | No |
| 4 | Out of stock | Limited stock, none left | No |
| 5 | Not enough points | Balance below the price | No |
| 6 | Limited stock | Only a few left | Yes |
| 7 | Available | Everything else | Yes |
A refused redemption never touches the player's points.
### Redemption lifecycle
```mermaid
stateDiagram-v2
[*] --> RESERVED
RESERVED --> POINTS_DEBITED
RESERVED --> CANCELLED
POINTS_DEBITED --> FULFILLMENT_PENDING
POINTS_DEBITED --> FULFILLMENT_FAILED
FULFILLMENT_PENDING --> FULFILLED
FULFILLMENT_PENDING --> FULFILLMENT_FAILED
FULFILLED --> USED
FULFILLED --> EXPIRED
FULFILLMENT_FAILED --> POINTS_REVERSED
POINTS_REVERSED --> [*]
USED --> [*]
EXPIRED --> [*]
CANCELLED --> [*]
```
| State | Meaning |
|---|---|
| `RESERVED` | The redemption is recorded and limited stock is held |
| `POINTS_DEBITED` | The price has been taken from the balance |
| `FULFILLMENT_PENDING` | The reward source is processing the order |
| `FULFILLED` | The reward is ready for the player |
| `USED` | The player marked the reward as used |
| `EXPIRED` | The reward passed its expiry date unused |
| `FULFILLMENT_FAILED` | The reward source definitively refused the order |
| `POINTS_REVERSED` | The points were returned to the player |
| `CANCELLED` | The redemption was cancelled before any points moved |
Any transition not shown is refused.
### Idempotency
Each confirmation carries a unique idempotency key, and a retry of the same
confirmation reuses it.
- **Same key, same reward:** returns the original redemption and its current
state. Points are debited once, even for simultaneous retries.
- **Same key, different reward:** refused as a conflict.
- **Toward the reward source:** the redemption's own ID is passed on as the
idempotency key, so a resend cannot create a second order.
### Fulfilment and failures
Each reward source sits behind one common interface, whether it is a gift
card, an operator reward, a game provider entitlement, a claim link or a
reward completed by hand.
| Outcome | What happens |
|---|---|
| Fulfilled | The reward is stored securely and the redemption becomes `FULFILLED` |
| Definitive failure | The points are returned to the player, held stock is released, and the redemption ends `POINTS_REVERSED` |
| Pending or unknown | The redemption stays pending and PlayerPal checks the status again. Points are never returned while the outcome is uncertain. |
| Order never received | PlayerPal resends it with the same idempotency key |
If an uncertain outcome is still unresolved after repeated checks, the
redemption is flagged for review by PlayerPal staff rather than guessed. A late
or duplicate answer from a reward source is ignored once the redemption has
moved on.
Fulfilment requests carry no player email, name or phone number. A partner's
reward receives only that partner's own alias for the player, never a global
PlayerPal identifier.
### Secure reward access
- Codes and claim links are encrypted before storage, bound to their
redemption so they cannot be moved to another.
- Lists and status views show only a masked hint, such as the last group of
characters.
- The full code is revealed only through a separate access step, only to the
player who redeemed it, and only once it is fulfilled.
- Reveals are rate-limited per player and each one is audited without
recording the secret.
### Availability
Redemption depends on PlayerPal. If PlayerPal is unavailable, redemption is
unavailable too. This never blocks deposits, withdrawals, wagering or game
settlement at the operator or game provider.
## Related
- [Player experience](../product/player-experience.mdx):
What players see when they link, play and earn.
- [Sending activity](../developers/sending-activity.mdx):
Report settled rounds so linked players earn points.
- [For reward partners](../product/for-reward-partners.mdx):
How reward partners will plug into the shop.
---
# Personal data
Source: https://docs-internal.playerpal.io/docs/security-privacy/personal-data
This page describes player data only. Workforce identity (staff and partner admins) is a separate domain and may use business email ([ADR 0012](../decisions/0012-never-receive-player-pii.md)).
## Policy
> **Status: Implemented** — evidence: [ADR 0012](../decisions/0012-never-receive-player-pii.md), [spec §2.2](../../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md)
1. **No direct identifiers.** PlayerPal-controlled persistence, tokens, logs, traces, events and analytics must never contain a player's email, name or telephone number ([ADR 0012](../decisions/0012-never-receive-player-pii.md)).
2. **Pseudonymous data is personal data.** PlayerPal treats pseudonymous player identifiers and gameplay data as personal data. The spec says they "may remain personal data under applicable privacy law even though they are not direct identifiers" and must be "protected, access-controlled, retained appropriately and included in privacy-impact analysis" (spec §2.2). This is a product policy, not a legal conclusion.
## What we never receive
> **Status: Implemented** (code and tests) / **Not yet verified live** — evidence: [oidc.service.ts](../../services/api/src/auth/oidc.service.ts), [oidc.claims.spec.ts](../../services/api/src/auth/oidc.claims.spec.ts)
| Data | How it is kept out |
|---|---|
| Email, name, phone from sign-in | Player OIDC requests scope `openid` only. Only issuer and subject are kept; email, name and phone claims are ignored. IdP raw profile data is stripped before return ([oidc.service.ts](../../services/api/src/auth/oidc.service.ts); test "keeps only issuer and subject when the token carries email name phone" in [oidc.claims.spec.ts](../../services/api/src/auth/oidc.claims.spec.ts)) |
| Columns for those fields | None of the entities in [db/entities](../../services/api/src/db/entities/) has an email, name, phone, address or birth-date column. The schema notes "Never store email, name, or phone" ([initial-schema.sql](../../services/api/src/db/migrations/initial-schema.sql)) |
| Direct identifiers in partner facts | `player_ref` must match a restricted reference pattern and be an alias PlayerPal issued to that partner ([ingest.schemas.ts](../../services/api/src/ingest/ingest.schemas.ts); test "never accepts direct PII-shaped refs" in [ingest.schemas.spec.ts](../../services/api/src/ingest/ingest.schemas.spec.ts)) |
| Email-based linking or recovery | Disabled. Email magic links and SMS OTP are not player sign-in methods ([ADR 0012](../decisions/0012-never-receive-player-pii.md), [ADR 0028](../decisions/0028-v1-recovery-without-operator-assist.md)) |
| Operator credentials or session cookies | Excluded by the spec (spec §2.2) |
Two caveats from the repo:
- The IAM may require a synthetic placeholder email on its side when creating a user. PlayerPal never persists it ([ADR 0012 amendment](../decisions/0012-never-receive-player-pii.md)).
- A live capture proving that social sign-in leaves no email, name or phone in PlayerPal cookies, database, logs or traces has not been done yet. Code filtering is in place; the live hop is not verified ([open-questions.md](../open-questions.md)).
## Identifiers
> **Status: Implemented** — evidence: [identity.md](../identity.md), [ADR 0013](../decisions/0013-typed-pairwise-aliases.md)
| Identifier | Who may see it | Notes |
|---|---|---|
| Internal UUIDv7 | PlayerPal only | Database key, never exposed |
| `pp_plr_...` | Player and PlayerPal | Never sent to any partner |
| `pp_plr_opr_...` | That operator | Random per operator ([ADR 0002](../decisions/0002-random-pairwise-external-ids.md)) |
| `pp_plr_prv_...` | That provider-operator integration | One per integration, so a provider cannot join a player across casinos ([ADR 0014](../decisions/0014-provider-operator-alias-scope.md)) |
| `pp_anl_...` analytics subject | PlayerPal only | Never on a player or partner API ([ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md)) |
| IAM subject | PlayerPal only | Opaque sign-in subject, not returned on `/v1/me` |
**Aliases** are random 128-bit values, not derived from the UUID or any personal data. They are minted only for audiences on a completed link, never on activity ingest ([ADR 0027](../decisions/0027-alias-provisioning-at-link.md)). A foreign alias gets the same response as an unknown one ([ADR 0014](../decisions/0014-provider-operator-alias-scope.md)). Affiliate and reward-partner prefixes are reserved and not minted ([ADR 0013](../decisions/0013-typed-pairwise-aliases.md)).
**Analytics subject** is minted at Account activation, random, and used to cluster retained activity without the UUID ([ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md); tests in [analytics-subject.spec.ts](../../services/api/src/identity/analytics-subject.spec.ts), including "writes the analytics subject onto processed round_settled and hides it from APIs").
**IAM subject storage.** The `authentication_identities` row holds ([authentication-identity.entity.ts](../../services/api/src/db/entities/authentication-identity.entity.ts), [identity.service.ts](../../services/api/src/identity/identity.service.ts), [common/ids.ts](../../services/api/src/common/ids.ts)):
- `subject_lookup_hmac`: HMAC-SHA256 of issuer and subject, keyed with a pepper (`SUBJECT_PEPPER`), used for lookup.
- `subject_ciphertext`: despite the name, this is also a keyed HMAC of the subject (one-way), not reversible encryption.
- `iam_subject`: the opaque subject in plain text, used for password sign-in reverse lookup. The docs call it "not PII" ([data-model.md](../data-model.md), [CONTEXT.md](../../CONTEXT.md)).
Discrepancy: spec §20.2 asks to "encrypt sensitive mappings at application level" and keep keys in a managed KMS/HSM. The code reads the pepper from configuration and stores the plain `iam_subject`; no application-level encryption or KMS use is visible in the code.
## Unlink and Delete Account
> **Status: Implemented** — evidence: [ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md), [ADR 0030](../decisions/0030-unlink-delete-on-playerpal-account.md), [identity-unlink.spec.ts](../../services/api/src/identity/identity-unlink.spec.ts), [identity-delete.spec.ts](../../services/api/src/identity/identity-delete.spec.ts)
Both verbs live only on the PlayerPal Account screen, need recent reauthentication and take an `Idempotency-Key` ([identity.md](../identity.md)).
| | Unlink (one audience) | Delete Account |
|---|---|---|
| Aliases | Revoked, never reassigned; relink mints a new alias | All revoked |
| Later activity | Does not earn; delayed activity that occurred before Unlink still qualifies | A former alias is treated as unknown |
| Points | Stay on the ledger | Identity graph erased |
| Sign-in data | Unchanged | Authentication identities, passkeys, sessions deleted; IAM user deleted first. If IAM delete fails, the request fails and the Account stays active |
| Analytics | No new partner-player-day rows; history stays | Partner-player-day rows and fact rows deleted; `player_id` / `player_alias` nulled on retained activity; UUID↔analytics-subject mapping destroyed |
| Retained | Historical analytics days | `round_ref`, amounts, `occurred_at`, integration and analytics subject on activity; redemption rows with `player_id` nulled ([identity.store.ts](../../services/api/src/db/repositories/identity.store.ts)) |
| Partner | Optional async notice; Unlink does not wait for it ([ADR 0032](../decisions/0032-unlink-partner-notice.md)) | PlayerPal does not command partner erasure |
v1 does not crypto-shred backups. A restored pre-delete backup can still join, and the docs say "do not market otherwise" ([ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md)).
## Who can see what
> **Status: Implemented** (API, scoping and floors) / **In progress (demo only)** (partner portal sign-in) — evidence: [services/cube/security.js](../../services/cube/security.js), [kpi.service.ts](../../services/api/src/analytics/kpi/kpi.service.ts), [comparison.math.ts](../../services/api/src/analytics/compare/comparison.math.ts)
- **Partner scoping.** Partners call the NestJS KPI gateway, never the semantic layer directly. Each query is limited to the cubes allowed for the party type, refuses `player_id` and ungrouped queries, and gets a `party_id` filter on every cube ([security.js](../../services/cube/security.js), [services/cube/README.md](../../services/cube/README.md)).
- **KPI account floor.** Partner KPI cells under the floor are suppressed. `KPI_ACCOUNT_FLOOR` (falls back to `STUDIO_ACCOUNT_FLOOR`) defaults to 50; staff tier uses a floor of 0 ([kpi.service.ts](../../services/api/src/analytics/kpi/kpi.service.ts)).
- **Studio analytics floor.** `STUDIO_ACCOUNT_FLOOR` defaults to 50. Per-alias lookup is n=1 by design and returns numbers only ([activity.service.ts](../../services/api/src/activity/activity.service.ts), [ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md)).
- **Benchmark floors** (`DEFAULT_ANONYMITY` in [comparison.math.ts](../../services/api/src/analytics/compare/comparison.math.ts)): at least 5 peer parties (`minPeers`), at least 50 distinct Accounts (`minAccounts`), and suppression when one party holds more than 50% (`dominanceP`) or the top two more than 75% (`dominanceP2`). Overrides: `BENCHMARK_MIN_PEERS`, `BENCHMARK_MIN_ACCOUNTS`, `BENCHMARK_DOMINANCE_P`, `BENCHMARK_DOMINANCE_P2`; lower values apply only with `BENCHMARK_ALLOW_LOW_THRESHOLDS=1` ([comparison.job.ts](../../services/api/src/analytics/compare/comparison.job.ts)).
- **Legacy studio header.** The shared machine key plus `x-party-id` is still accepted unless `STUDIO_LEGACY_PARTY_HEADER=false`, and must be turned off in production ([loyalty.md](../loyalty.md)).
## Retention and purpose limits
> **Status: Partly decided** — evidence: [ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md), [spec](../../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md)
There is no retention schedule document. What the repo does state:
- Delete Account and Unlink behaviour, as in the table above.
- Command `Idempotency-Key` values are kept for at least 24 hours (spec §37).
- The raw accepted envelope is stored for replay, and "retention and pseudonymisation policies" must apply to it as personal data. No period is given (spec §38.3).
- IP/network velocity limits use "short retention". No period is given. IP addresses and device fingerprints must never become durable player-linking keys (spec, abuse controls section).
- Purpose: separate identity mappings from general analytics access (spec §20.2); partners get numeric enrichment only, never another audience's identifier ([ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md)).
## Open questions
The repo does not decide these. Do not assume answers; record an ADR instead ([open-questions.md](../open-questions.md)).
- Retention periods for activity, facts, analytics serving rows, the raw envelope layer, ledger entries and audit data.
- Retention of anonymised gameplay facts after Delete Account (kept, but for how long is not stated).
- Backup retention, given backups are not crypto-shredded.
- Whether a social provider that emits email regardless of scope may be enabled (product, legal and security must decide; [ADR 0012](../decisions/0012-never-receive-player-pii.md)).
- Live capture of the social sign-in hop ([open-questions.md](../open-questions.md)).
- Audience scope for affiliate and reward-partner aliases ([ADR 0013](../decisions/0013-typed-pairwise-aliases.md)).
- Joint-controller contracts remain "a product-permission assumption" ([ADR 0026](../decisions/0026-analytics-subject-and-provider-cohort.md)).
Related: [Product overview](../product/overview.mdx), [Privacy (public)](../trust/privacy.mdx), [identity.md](../identity.md), [authorization.md](../authorization.md).
---
# Reward redemption lifecycle
Source: https://docs-internal.playerpal.io/docs/shop/redemption-lifecycle
> **Status: In progress (demo only).** The shop exists only on the unmerged branch `feat/player-shop` (commit `09c682f`). It runs as an in-browser stand-in for a future API, `ShopServer`, behind a typed transport. Nothing on this page runs in `services/api` at `53fd444`.
The demo files are not on this branch, so they are cited as paths, not links. Read them with `git show feat/player-shop:`. All paths are under `demos/apps/portals/src/shop/`:
| File (branch feat/player-shop) | What it holds |
|---|---|
| `server/shopServer.ts` | The stand-in server: catalogue, redemption, reconciliation, access, ledger |
| `server/rules.ts` | Eligibility, pricing, the state machine and error codes. Pure functions, written to move into the API unchanged. |
| `server/fulfillment.ts` | The fulfilment provider interface and simulated adapters |
| `server/vault.ts` | AES-GCM secret vault and code masking |
| `server/network.ts` | Generated back-office activity for the staff store screens |
| `model.ts`, `transport.ts`, `services.ts` | Shared types, the HTTP contract, and the UI service layer |
| `server/shopServer.test.ts` | Tests for the rules below |
`services.ts` uses `ShopServer` unless `VITE_SHOP_API=http`, in which case it uses `HttpShopTransport` against an API that does not exist yet.
## Flow: points → catalogue → redemption
1. **Balance.** The balance is the sum of the player's ledger entries. It is never stored (`balanceOf` in `server/shopServer.ts`). Ledger entry types are `EARN`, `REDEEM`, `BONUS`, `EXPIRE`, `ADJUST` and `REVERSE`, signed so that debits are negative (`model.ts`).
2. **Catalogue.** For the session's player and market, each reward gets a server-computed `state`, a market price and, if short, a `shortfall` (`toShop`). Vendor and pricing internals are stripped from the player view (`ShopReward` in `model.ts`, test "strips vendor and pricing internals").
3. **Price.** A market override wins over the default points cost (`priceFor` in `server/rules.ts`). The code comment says points never derive from fiat.
4. **Confirm.** The UI makes one idempotency key per confirmation and calls `createRedemption` (`RedemptionService` in `services.ts`).
5. **Fulfil.** The server debits points, hands the order to the reward's fulfilment provider and settles asynchronously.
6. **Access.** Once fulfilled, the player reveals the code or claim link through a separate, rate-limited access call.
The player is always taken from the session. No transport method accepts a player ID (`transport.ts` header comment).
## Reward eligibility states
`rewardState` in `server/rules.ts` returns the first matching state, in this order. The order is deliberate: a reward that is not on sale says so before the player's balance is mentioned.
| Order | State | Condition | Refusal on redeem |
|---|---|---|---|
| 1 | `EXPIRED` | `validTo` is in the past | `409 reward_unavailable` |
| 2 | `COMING_SOON` | Reward inactive, or `validFrom` in the future | `409 reward_unavailable` |
| 3 | `UNAVAILABLE_MARKET` | Player's market not in `markets`, or in `excludedMarkets` | `409 market_unavailable` |
| 4 | `OUT_OF_STOCK` | `LIMITED` stock with none remaining | `409 out_of_stock` |
| 5 | `INSUFFICIENT_POINTS` | Balance below the market price | `409 insufficient_points` |
| 6 | `LIMITED_STOCK` | `LIMITED` stock at or below `lowStockThreshold` (default 5) | None: redeemable |
| 7 | `AVAILABLE` | Otherwise | None: redeemable |
A refusal never touches points (test "checks balance, market, stock and window server-side without touching points").
## Redemption state machine
The allowed transitions are the `NEXT` table in `server/rules.ts`. Any other transition throws `invalid_redemption_transition:->`. Terminal states are `POINTS_REVERSED`, `USED`, `EXPIRED` and `CANCELLED`.
```mermaid
stateDiagram-v2
[*] --> RESERVED
RESERVED --> POINTS_DEBITED
RESERVED --> CANCELLED
POINTS_DEBITED --> FULFILLMENT_PENDING
POINTS_DEBITED --> FULFILLMENT_FAILED
FULFILLMENT_PENDING --> FULFILLED
FULFILLMENT_PENDING --> FULFILLMENT_FAILED
FULFILLED --> USED
FULFILLED --> EXPIRED
FULFILLMENT_FAILED --> POINTS_REVERSED
POINTS_REVERSED --> [*]
USED --> [*]
EXPIRED --> [*]
CANCELLED --> [*]
```
How the demo server actually moves through it (`server/shopServer.ts`):
- `createRedemption` creates the record in `RESERVED`, holds one unit of `LIMITED` stock, appends a `REDEEM` ledger entry for `-price`, then moves to `POINTS_DEBITED` and `FULFILLMENT_PENDING` in the same synchronous step. The response is therefore normally `FULFILLMENT_PENDING`.
- `FULFILLED → USED` happens when the player marks the reward used (`markUsed`).
- `FULFILLED → EXPIRED` happens lazily when the record is read after its `expiresAt`. The demo only sets `expiresAt` when the reward's expiry policy text contains "12 months".
- `RESERVED → CANCELLED` and `POINTS_DEBITED → FULFILLMENT_FAILED` are allowed by the table, but no demo code path uses them.
## Idempotency
| Aspect | Behaviour (`createRedemption`) |
|---|---|
| Key | Client-generated, one per confirmation (`crypto.randomUUID()` in `services.ts`). A retry of the same confirmation reuses it. Required, at most 128 characters, or `400 invalid_request`. |
| Scope | Per player: the stored key is `:`. |
| Replay | Same key, same reward: returns the original `{ redemptionId, status }` with the current status. No second debit, even for concurrent calls (test "is idempotent"). |
| Conflict | Same key, different reward: `409 idempotency_conflict`. |
| Upstream | The redemption ID is passed to the provider as its idempotency key, so a resend cannot create a second order (`FulfillmentRequest.redemptionId` in `server/fulfillment.ts`). |
The replay check runs before the eligibility checks. Everything up to the debit is synchronous, so two calls with one key cannot both pass the checks (code comment on `createRedemption`).
## Fulfilment adapters
Every reward source sits behind `RewardFulfillmentProvider` (`getCatalog`, `quote`, `fulfill`, `getStatus`) in `server/fulfillment.ts`. All the adapters on the branch are **simulations with no network calls**. They exist to exercise the outcomes the engine must handle:
| Adapter kind | Simulated behaviour |
|---|---|
| Gift-card aggregator | Returns a code immediately |
| Second aggregator | Catalogue can lag reality: refuses some product/market pairs definitively (`FAILED`, `MARKET_UNAVAILABLE`) |
| Operator reward | Credits the operator account by the operator-scoped alias. The first call returns `UNKNOWN` (timeout) after the order was accepted. |
| Game provider entitlement | Returns `PENDING`; activation appears on the second status check |
| Claim link | Returns a single-use claim URL; delivery details are collected by the partner, not PlayerPal |
| Manual | Stays `PENDING` until staff complete it from the back office |
Fulfilment requests carry no email, name or phone. Partner rewards receive that partner's scoped alias, never the global PlayerPal ID (module comment in `server/fulfillment.ts`). Which real vendors would be used is not decided in the repo.
## Failure handling
| Provider outcome | What the server does |
|---|---|
| `FULFILLED` | Seal any secret in the vault, record a masked code, move to `FULFILLED` |
| `FAILED` (definitive) | Move to `FULFILLMENT_FAILED`, append a `REVERSE` ledger entry for `+price`, release held stock, set `pointsReturned`, move to `POINTS_REVERSED` |
| `PENDING` or `UNKNOWN` | Stay in `FULFILLMENT_PENDING` and schedule a status check (`reconcile`). Never reversed. |
| Status `NOT_FOUND` | The provider never received the order: resend with the same key, up to `maxAttempts` sends (default 3) |
If a pending or unknown outcome is still unresolved after `maxPolls` status checks (default 6, every `pollMs` = 2.5 s by default), or sends are exhausted, the record is flagged `needsReview`. It stays `FULFILLMENT_PENDING` with points still debited, and the staff redemption list shows "Outcome unknown: needs review". Late or duplicate answers are ignored once the record has left `FULFILLMENT_PENDING` (`settle`).
Evidence: tests "reverses points and releases stock on a definitive failure", "holds an uncertain outcome instead of reversing…" and "resends with the same key only when the provider never received the order…" in `server/shopServer.test.ts`.
## Secure reward access
> **Status: In progress (demo only)** — evidence: `server/vault.ts`, `access` in `server/shopServer.ts` (branch feat/player-shop)
- Codes and claim links are encrypted with AES-GCM (256-bit key, random 12-byte IV) before storage. The redemption ID is the additional authenticated data, so a ciphertext cannot be moved onto another redemption.
- The key is generated in the browser per session. It is exportable only so the demo survives a reload, and the demo persists the vault in `sessionStorage`. **This key handling is demo-only.**
- **Proposed:** in the API the key would come from a KMS (comment in `server/vault.ts`). No KMS integration exists in the repo.
- Lists and status responses carry only a masked hint (all but the last group replaced, `maskCode`).
- `access` is the only path to plaintext. It requires the player's own redemption (another player's looks like `404 redemption_not_found`), status `FULFILLED` or `USED` (else `409 not_accessible`), and at most 10 reveals per player per rolling minute (else `429 rate_limited`). Each reveal is audited without the secret, and the response carries an `expiresAt` 10 minutes ahead.
## HTTP contract
> **Status: Proposed** — evidence: `HttpShopTransport` in `transport.ts` (branch feat/player-shop). None of these routes exist in `services/api`.
Session cookie only, snake_case on the wire, no player ID in any URL or body.
| Method and path | Purpose |
|---|---|
| `GET /v1/shop/catalog` | Catalogue page with state, price, balance and market |
| `GET /v1/shop/rewards/:id` | Reward detail with balance after redemption |
| `POST /v1/shop/redemptions` | `{ reward_id, idempotency_key }` → `{ redemption_id, status }` |
| `GET /v1/shop/redemptions`, `GET /v1/shop/redemptions/:id` | The player's redemptions |
| `POST /v1/shop/redemptions/:id/access` | Reveal code or link |
| `POST /v1/shop/redemptions/:id/used` | Mark used |
| `GET /v1/me/points`, `GET /v1/me/points/ledger` | Balance summary and ledger |
| `POST /v1/shop/events` | Product analytics events (IDs only) |
| `/v1/staff/store/*` | Back office: overview, rewards, redemptions, providers, manual completion |
## Spec versus demo
The identity spec covers redemption in [§36.5 "View and redeem points"](../../spec/PLAYERPAL_AUTH_IDENTITY_SPEC.md) and lists the endpoints in §37.11.
| Topic | Spec | Demo |
|---|---|---|
| Endpoints | `GET /v1/me/wallet`, `POST /v1/me/redemptions`, `GET /v1/me/redemptions/{redemptionId}` | `/v1/me/points` and `/v1/shop/redemptions*` |
| Reservation | "Atomic reserve + idempotency", then a pending redemption and asynchronous fulfilment, ending fulfilled or failed | Reserve and debit in one synchronous step, then async fulfilment, with the richer state set above |
| Idempotency | Commands require an `Idempotency-Key` header with at least 24-hour retention (§37.1) | Key sent in the request body; no retention period implemented |
| Availability | If PlayerPal is unavailable, redemption is unavailable; this never blocks deposits, withdrawals, wagering or settlement | Consistent: the shop is PlayerPal-only |
| Assurance | `A0_PASSWORD_ONLY` accounts may not redeem (assurance table in the spec) | No assurance check in the shop code |
Which endpoint names and key placement win has not been decided. See [open-questions.md](../open-questions.md).
## Gap to production
> **Status: Proposed** — nothing below is decided
**The points ledger is credit-only.** `points_ledger` has a `ledger_action` column defaulting to `credit` and a required `activity_event_id` ([points-ledger.entity.ts](../../services/api/src/db/entities/points-ledger.entity.ts)). The only writer sets `ledgerAction: 'credit'` ([activity.store.ts](../../services/api/src/db/repositories/activity.store.ts)). There is no debit or reversal path, and a redemption debit has no activity event to reference.
**The redemption table does not match the shop's states.** `loyalty.reward_redemption` allows only `PENDING`, `FULFILLED`, `FAILED` and `CANCELLED` ([CanonicalFacts migration](../../services/api/src/db/migrations/1736160000019-CanonicalFacts.ts)). The shop uses nine states, including `POINTS_REVERSED`, `USED` and `EXPIRED`. How these map, or whether the table changes, is unresolved.
**The overview loyalty KPIs read seed data.** Only [seed-kpi-sim.ts](../../services/api/src/scripts/seed-kpi-sim.ts) writes `loyalty.reward_redemption` and `loyalty.mission_progress` ([analytics/metrics.md](../analytics/metrics.mdx)).
**Also not built:** the `/v1/shop/*` routes, KMS-backed vault keys, real fulfilment adapters, a durable idempotency store, a review queue for `needsReview` redemptions, and the spec's assurance-level gate.
---
# Implementation status
Source: https://docs-internal.playerpal.io/docs/status
Every row cites the code, test or decision it rests on. "Implemented" means
the behaviour exists in `services/*` with tests; it does not mean it has run in
production or against real partner traffic. Demo-only means it exists only as
simulated or in-browser code. See [Contributing to the docs](contributing/docs.md)
for how labels are assigned.
## Capabilities
| Capability | Status | Evidence |
|---|---|---|
| Player sign-in (passkey, password, social) through Nest and the IAM | Implemented | [ADR 0024](decisions/0024-nest-owned-portal-auth.md), [oidc.service.ts](../services/api/src/auth/oidc.service.ts) |
| No player email, name or phone held by PlayerPal | Implemented in code and tests; live social sign-in hop not yet captured | [ADR 0012](decisions/0012-never-receive-player-pii.md), [oidc.claims.spec.ts](../services/api/src/auth/oidc.claims.spec.ts), [Personal data](security-privacy/personal-data.md) |
| Linking and pairwise aliases | Implemented | [ADR 0027](decisions/0027-alias-provisioning-at-link.md), [linking.controller.ts](../services/api/src/linking/linking.controller.ts), [link-intent.service.spec.ts](../services/api/src/linking/link-intent.service.spec.ts) |
| Unlink and Delete Account | Implemented | [ADR 0030](decisions/0030-unlink-delete-on-playerpal-account.md), [identity-delete.spec.ts](../services/api/src/identity/identity-delete.spec.ts) |
| Activity batches and uploads | Implemented, behind the shared machine key (no per-partner credentials yet) | [activity.controller.ts](../services/api/src/activity/activity.controller.ts), [activity.controller.spec.ts](../services/api/src/activity/activity.controller.spec.ts) |
| Partner fact ingest (`/v1/ingest`, HMAC or partner OAuth) | Implemented | [ingest.controller.ts](../services/api/src/ingest/ingest.controller.ts), [Partner quickstart](developers/quickstart.mdx) |
| Points earning (ledger credits) | Implemented, credit only | [points-ledger.entity.ts](../services/api/src/db/entities/points-ledger.entity.ts) |
| Points redemption, debit and reversal in the API | Proposed | [Reward redemption lifecycle](shop/redemption-lifecycle.md) |
| Player shop, reward vault, store back office | In progress (demo only), unmerged branch `feat/player-shop` | [Reward redemption lifecycle](shop/redemption-lifecycle.md) |
| Partner KPIs through Cube | Implemented, over seeded and simulated data | [ADR 0033](decisions/0033-partner-kpi-platform.md), [kpi.catalogue.ts](../services/api/src/analytics/kpi/kpi.catalogue.ts), [Metric definitions](analytics/metrics.mdx) |
| Network comparison and benchmarks with anonymity floors | Implemented | [comparison.math.ts](../services/api/src/analytics/compare/comparison.math.ts) |
| AI insights and Ask | Implemented as deterministic rules; no language model | [detectors.ts](../services/api/src/analytics/intelligence/detectors.ts), [ask.service.ts](../services/api/src/analytics/intelligence/ask.service.ts) |
| Operator, provider, affiliate and staff portal sign-in | In progress (demo only): simulated sign-in | [portals.md](portals.md) |
| Workforce SSO for staff and partner staff | Proposed | [authentication.md](authentication.md) |
| Embeddable PlayerPal widget | In development: versioned releases are served by the API; the game-side integration exists only in demo code | [ADR 0025](decisions/0025-provider-launch-sessions-and-widget-distribution.md), [playerpal-widget](../demos/packages/playerpal-widget/src/index.ts) |
| Casino play continues when PlayerPal is unavailable | Decided; reference outbox implemented in the demo provider; no end-to-end test | [ADR 0015](decisions/0015-async-loyalty-sidecar.md), [Availability](developers/availability.mdx) |
| Reward-partner role and portal | Proposed (docs only) | [ADR 0001](decisions/0001-generic-party-model.md) |
| Go data-plane workers | Proposed | [services/worker](../services/worker/README.md) |
| Public documentation site | Proposed | [ADR 0034](decisions/0034-documentation-site.md) |
## Pages by status
Generated from each page's `status` front matter.
---
# Privacy
Source: https://docs-internal.playerpal.io/docs/trust/privacy
PlayerPal is built so that a loyalty network does not need players' personal
details. This page sets out the commitments PlayerPal makes about player
data. They are product commitments, not legal advice or a statement of legal
compliance.
This page covers players. Staff and partner staff who sign in to business
tools are a separate population and may use a business email address.
## Our commitments
[Available]
1. **No email, name or phone number.** PlayerPal never asks for, receives or
stores a player's email address, name or telephone number. This applies
to its databases, tokens, logs and analytics.
2. **Pseudonymous data is still personal data.** Player identifiers and
gameplay records are pseudonymous, and PlayerPal treats them as personal
data: protected, access-controlled and retained deliberately.
3. **A different identifier for every partner.** Each partner receives its
own random Alias for a player, so partners cannot match players with each
other.
4. **Partners only see their own players.** Every partner query is limited
to the players linked to that partner.
5. **Comparisons stay anonymous.** Figures that compare a partner with the
network are suppressed when a group is too small or too dominated by one
party.
6. **The player is in control.** Players Unlink a partner or delete their
Account themselves, from their PlayerPal Account.
## What PlayerPal holds
| Held | Not held |
|---|---|
| A random PlayerPal ID the player can see | Email address |
| Sign-in credentials: passkeys, an opaque reference to a social sign-in, an optional password | Name |
| Aliases issued to each connected partner | Telephone number |
| Settled game Activity reported by partners: amounts, times, game round references | Postal address or date of birth |
| Points earned | Casino login credentials or session cookies |
| A device label for each active session, so the player can recognise it | IP address or device fingerprint as an identifier |
Social sign-in requests only an opaque account identifier. Any email, name or
phone the sign-in provider returns is discarded before PlayerPal stores
anything. Accounts are never linked or recovered by email or SMS.
## Identifiers and who sees them
[Available]
| Identifier | Seen by |
|---|---|
| PlayerPal ID (`pp_plr_…`) | The player and PlayerPal only |
| Operator Alias (`pp_plr_opr_…`) | That Operator only |
| Provider Alias (`pp_plr_prv_…`) | That game provider, for one casino only |
| Analytics key | PlayerPal only; never shown to players or partners |
Aliases are random values. They contain nothing about the player, the
partner or the time they were created, and one cannot be worked out from
another. A game provider receives a different Alias for each casino, so it
cannot follow a player between casinos. An Alias that was issued to someone
else is treated as if it does not exist.
Aliases are created only when the player completes a link and consents to
that exact partner. No data about a player flows to PlayerPal before that.
## Analytics
[Available]
Partner analytics are numbers about groups, keyed only by the partner's own
Aliases. Partners never receive another partner's identifiers, the player's
PlayerPal ID or PlayerPal's internal keys.
Anything a partner sees about the wider network is protected by anonymity
thresholds: minimum numbers of Accounts and of peer parties, suppression when
one or two parties dominate, published statistics limited to quartiles and
bands, and fixed time periods so two results cannot be subtracted to isolate a
competitor. See [Privacy thresholds](../analytics/privacy-thresholds.mdx).
## Unlink and Delete Account
[Available]
Both are on the player's PlayerPal Account screen and require the player to
confirm their identity again.
| | Unlink one partner | Delete Account |
|---|---|---|
| Play at the casino | Continues | Continues; the casino account is separate |
| Later Activity | Stops earning Points | No longer connected to anyone |
| Points | Stay on the Account | No longer usable; the Account is closed |
| Aliases | Retired, never reused; relinking issues a new one | All retired |
| Sign-in methods and sessions | Unchanged | Deleted |
| Partner analytics | No new data for that partner; past figures stay | Per-player analytics for the player's Aliases removed |
| Partner | May be notified that the link ended | PlayerPal does not instruct partners to erase their own records |
Activity that happened before an Unlink but arrives late can still earn
Points. After Delete Account, PlayerPal keeps settled game records for
analytics and accounting without any link back to the player: sign-in
methods are deleted, Aliases are detached from those records and the mapping
to the analytics key is destroyed.
## Retention
[In development]
What is decided:
- Unlink and Delete Account behave as described above.
- Raw data received from partners is personal data and falls under the
retention policy.
- Network signals used for abuse protection are kept only briefly and never
become a way to identify or link players.
- Partners get numbers, never another Audience's identifiers.
Retention periods for Activity, analytics, ledger and audit records, and for
backups, are being finalised. PlayerPal will publish them here when they are
decided.
## Related
- [Security](./security.mdx):
How PlayerPal protects access to data.
- [Privacy thresholds](../analytics/privacy-thresholds.mdx):
The rules that keep comparisons anonymous.
- [Player experience](../product/player-experience.mdx):
Linking, Unlink and Delete Account from the player's side.
---
# Security
Source: https://docs-internal.playerpal.io/docs/trust/security
PlayerPal's security model starts from what it does not hold: no player
email, name or phone number, and no partner can see another partner's
players. On top of that, every request is authenticated, scoped to one party
and safe to retry. This page summarises the controls and their status.
The labels mean: **Available** (built and tested in the platform), **In
development** (being built; behaviour may change) and **Planned** (designed,
not built yet).
## At a glance
| Control | Status |
|---|---|
| OAuth client credentials for partner APIs | [Available] |
| Signed requests (HMAC with timestamp and nonce) for partner APIs | [Available] |
| Scope taken from credentials, never from the request | [Available] |
| Per-partner credentials for linking and activity batches | [In development] |
| Player sessions in secure, HTTP-only cookies | [Available] |
| Passkeys and recovery codes; no email or SMS sign-in | [Available] |
| Assurance levels | [In development] |
| Idempotent writes | [Available] |
| Separate portal per audience | [Available] |
| Production sign-in for partner and staff portals | [In development] |
| Workforce single sign-on | [Planned] |
## Partner API authentication
[Available]
Partner APIs for KPIs, studio analytics and fact ingest accept two schemes.
Both resolve to one principal: your party, your party type and the scopes
you were granted.
| Scheme | How it works |
|---|---|
| OAuth 2.0 client credentials (recommended) | Your service account requests a short-lived access token (5 to 15 minutes) and sends it as a bearer token |
| PP-HMAC-SHA256 request signing | Each request is signed over its method, path, query, body hash, a timestamp and a single-use nonce |
Signed requests are rejected if the timestamp is more than five minutes from
PlayerPal's clock or the nonce has been seen before, so a captured request
cannot be replayed. Signing keys are issued per party, two can be active at
once for rotation, the secret is shown once, and secrets are encrypted at
rest. See [Authentication](../developers/authentication.mdx).
:::info[In development]
Linking and activity batch endpoints currently use a single platform
credential issued by PlayerPal. Per-partner credentials for these endpoints
are being built. Operator link requests are already protected by a
short-lived assertion signed with the operator's own key.
:::
## Scoped access
[Available]
- **Scope comes from credentials.** A party ID in a query string, header or
payload field is ignored. Activity is attributed to the integration that
sent it, not to an operator named in the payload.
- **Only your Aliases work.** An Alias issued to another party gets the same
response as one that does not exist, so probing reveals nothing.
- **Fixed queries only.** Partners call a fixed catalogue of KPI queries.
Every query is filtered to the caller's party, and queries that would
return individual player records outside the caller's own Aliases are
refused.
- **UI is not a boundary.** Hiding a screen is never treated as access
control; the API enforces audience, party and permission on every call.
## Player sign-in and sessions
[Available]
- **Passkeys first.** Players sign in with passkeys, an approved social
account that shares only an opaque identifier, or a PlayerPal ID and
password. Email magic links and SMS codes are not offered.
- **Recovery without email.** One-time recovery codes are stored hashed,
limited by attempt rate and shown to the player only once.
- **Cookie sessions.** The player portal keeps the session in an
`HttpOnly`, `Secure`, `SameSite` cookie. Long-lived tokens are never stored
in the browser.
- **Session control.** Players can see their active sessions and sign out
other devices.
- **Step-up for sensitive actions.** Unlink, Delete Account and generating
new recovery codes require the player to confirm their identity again.
- **Embedded flows.** Linking from a casino opens a first-party PlayerPal
window with one-time codes and exact origin checks. No tokens go in URLs
and no third-party cookies are used.
## Assurance levels
[In development]
Not every Account carries the same trust. PlayerPal records an assurance
level for each Account and uses it to decide which actions are allowed.
| Level | Proof | Intended capabilities |
|---|---|---|
| A0 | PlayerPal ID and password only | Sign in, add a stronger credential, connect a partner; no redemption or transfers |
| A1 | Passkey or approved social sign-in | Normal access |
| A2 | A1 plus a confirmed partner relationship | Earning from that partner and standard redemption |
| A3 | Several independent trusted proofs | Higher-value actions where policy permits |
A0 and A1 are recorded at sign-in today. The checks on economic actions
arrive with redemption, which is Planned. Accounts are never merged
automatically on matching devices, network addresses or behaviour.
## Safe retries
[Available]
Every write that partners and players send can be retried safely. Activity
batches and fact ingest are deduplicated by batch and event ID; an identical
retry returns the original receipt, and a different payload under the same ID
is rejected as a conflict. Player account changes take an `Idempotency-Key`.
Points are credited at most once per event and campaign rule. See
[Idempotency and errors](../developers/idempotency-and-errors.mdx).
## Data minimisation
[Available]
PlayerPal never receives a player's email, name or phone number, and partner
facts must use an Alias PlayerPal issued; anything shaped like a direct
identifier is rejected. Device information is limited to a display label, and
network addresses and device fingerprints are never used to identify or link
players. See [Privacy](./privacy.mdx).
## Separate portals by audience
[Available]
Players, PlayerPal staff, operators, game providers and affiliates each use
their own portal address, with its own sign-in client, session cookie and
permission policy. There is no role switcher: opening the wrong portal
returns an access error. Sessions are never shared between audiences.
The player portal uses production sign-in. Sign-in for the partner and staff
portals is In development, and single sign-on for PlayerPal's workforce and
partner staff is Planned.
## Related
- [Privacy](./privacy.mdx):
What PlayerPal holds and who can see it.
- [Authentication](../developers/authentication.mdx):
OAuth and request signing in detail.
- [API reference](/api/partner):
Every partner endpoint and its security scheme.