# Partner KPI API

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=<public party id>&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.
