Partner API authentication
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.
| Scheme | Use when | Header |
|---|---|---|
| OAuth 2.0 client credentials (recommended) | You can run a token flow | Authorization: Bearer <access token> |
| 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 <token>. 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=<base64url>
X-PP-Timestamp: <unix seconds>
X-PP-Nonce: <16–128 chars of A-Z a-z 0-9 _ ->
Canonical string
Join these six lines with \n (no trailing newline):
- HTTP method, uppercase
- Path, exactly as sent, without the query string
- 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. - Lowercase hex SHA-256 of the exact request body bytes (the hash of the empty string for GET)
- The
X-PP-Timestampvalue - The
X-PP-Noncevalue
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+)
import { createHash, createHmac, randomBytes } from 'node:crypto'
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)
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.