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.
If PlayerPal registered an OAuth client for you, send Authorization: Bearer <access-token> and skip step 2. Everything else is the same. See Authentication.
1Set up your credentials
PlayerPal issues credentials during onboarding. 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 needsingest.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.
export PP_API_BASE="https://<playerpal-api-host>"
export PP_KEY_ID="pp_kid_XXXX"
export PP_SECRET="<your-secret>"
2Sign 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.
- HTTP method, uppercase
- Path, without the query string
- Query pairs sorted by key then value, RFC 3986 encoded, joined with
&(empty if none) - Lowercase hex SHA-256 of the exact body bytes (of the empty string for
GET) - The timestamp, in Unix seconds
- 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.
API="${PP_API_BASE:-}" # https://<playerpal-api-host>
KEY_ID="${PP_KEY_ID:-}" # pp_kid_…
SECRET="${PP_SECRET:-}" # pp_sk_…
# sign METHOD PATH QUERY BODY [TIMESTAMP] [NONCE]
# QUERY must already be canonical: pairs sorted by key then value, RFC 3986 encoded.
# Prints three header lines for curl.
sign() {
local method=$1 path=$2 query=$3 body=$4
local ts=${5:-$(date +%s)} nonce=${6:-$(openssl rand -hex 16)}
local body_hash sig
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 '=')
printf 'Authorization: PP-HMAC-SHA256 keyId=%s, signature=%s\nX-PP-Timestamp: %s\nX-PP-Nonce: %s\n' \
"$KEY_ID" "$sig" "$ts" "$nonce"
}
3Send your first batch
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 for every schema.
source_idmust be your own party ID.player_refmust 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.
schemastringrequiredProvider schemas require source_party GAME_PROVIDER and a PROVIDER party; operator and attribution schemas require OPERATOR.One of:playerpal.provider.player_game_daily.v1playerpal.provider.game_catalogue.v1playerpal.operator.player_commercial_daily.v1playerpal.affiliate.attribution.v1schema_versionintegerrequiredSchema version; only1is accepted.batch_idstringrequiredYour id for this batch and its idempotency key, unique per party. Re-sending the samebatch_idwith identicalschemaandrecordsreturns the stored receipt; different content is409 idempotency_conflict.source_partystringrequiredGAME_PROVIDERfor provider schemas,OPERATORfor operator and attribution schemas.One of:GAME_PROVIDEROPERATORsource_idstringrequiredYour party public id. Must equal the authenticated party, otherwise403 source_id_must_be_authenticated_party.period_startstring (date-time)requiredStart of the reporting period (ISO 8601, UTC).period_endstring (date-time)requiredEnd of the reporting period (ISO 8601, UTC). Must be afterperiod_start.generated_atstring (date-time)requiredWhen you produced the batch (ISO 8601, UTC).recordsarray of one ofrequiredRecord shape depends onschema.
BODY='{"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","period_start":"2026-10-12T00:00:00Z","period_end":"2026-10-13T00:00:00Z","generated_at":"2026-10-13T01:15:00Z","records":[{"date":"2026-10-12","player_ref":"pp_plr_prv_XXXX","game_ref":"your-game-ref","currency":"EUR","rounds":120,"total_bet":"60.00","total_win":"52.40"}]}'
HEADERS=(); while IFS= read -r h; do HEADERS+=("$h"); done < <(sign POST /v1/ingest "" "$BODY")
curl -sS -X POST "$API/v1/ingest" \
-H "${HEADERS[0]}" -H "${HEADERS[1]}" -H "${HEADERS[2]}" \
-H 'Content-Type: application/json' \
--data-binary "$BODY" # 202 and the batch receipt
{
"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
}
4Read 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.
records_acceptedintegerrequiredRecords that passed validation and were stored.records_rejectedintegerrequiredRecords that failed validation; seerejections.rejectionsarray of objectrequiredPer-record rejections, capped at the first 500 (records_rejectedholds the full count).replayedbooleanrequiredtruewhen this receipt was read back from an earlier submission of the same batch, or fetched by ID.
records_accepted is 1. Send the same batch again and the receipt comes back with replayed: true.
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
{
"records_received": 2,
"records_accepted": 1,
"records_rejected": 1,
"rejections": [{ "index": 1, "reason": "player_ref_invalid" }],
"replayed": false
}
5Read a KPI
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 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.
You get 200 with a series covering every week in the range.
QUERY='from=2026-09-01&grain=week&kpi=own.rounds&to=2026-09-30' # already sorted and encoded
HEADERS=(); while IFS= read -r h; do HEADERS+=("$h"); done < <(sign GET /v1/kpis/own "$QUERY" "")
curl -sS "$API/v1/kpis/own?$QUERY" -H "${HEADERS[0]}" -H "${HEADERS[1]}" -H "${HEADERS[2]}"
{
"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.