Skip to main content
AvailableVerified against d96759dMarkdown

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.

You will
  • Sign requests with PP-HMAC-SHA256
  • Send a fact batch and read its receipt
  • Read one of your KPIs for a date range
You need
  • A PlayerPal partner account
  • A key ID and secret with the ingest.write and kpis.read scopes
  • One player alias PlayerPal issued to you
Time
About 15 minutes
For
Game providers and operators
Using OAuth instead?

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 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://<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.

  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.

Checkpoint

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.

Sign a request
playerpal.sh
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

POST/v1/ingestSubmit a fact batchingest.writeReference →

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_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.
Request body
  • schemastringrequired
    Provider 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.v1
  • schema_versionintegerrequired
    Schema version; only 1 is accepted.
  • batch_idstringrequired
    Your id for this batch and its idempotency key, unique per party. Re-sending the same batch_id with identical schema and records returns the stored receipt; different content is 409 idempotency_conflict.
  • source_partystringrequired
    GAME_PROVIDER for provider schemas, OPERATOR for operator and attribution schemas.
    One of: GAME_PROVIDEROPERATOR
  • source_idstringrequired
    Your party public id. Must equal the authenticated party, otherwise 403 source_id_must_be_authenticated_party.
  • period_startstring (date-time)required
    Start of the reporting period (ISO 8601, UTC).
  • period_endstring (date-time)required
    End of the reporting period (ISO 8601, UTC). Must be after period_start.
  • generated_atstring (date-time)required
    When you produced the batch (ISO 8601, UTC).
  • recordsarray of one ofrequired
    Record shape depends on schema.
POST /v1/ingest
playerpal.sh
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
Response202
{
"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.

Response fields
  • records_acceptedintegerrequired
    Records that passed validation and were stored.
  • records_rejectedintegerrequired
    Records that failed validation; see rejections.
  • rejectionsarray of objectrequired
    Per-record rejections, capped at the first 500 (records_rejected holds the full count).
  • replayedbooleanrequired
    true when this receipt was read back from an earlier submission of the same batch, or fetched by ID.
Checkpoint

records_accepted is 1. Send the same batch again and the receipt comes back with replayed: true.

GET/v1/ingest/batches/{batchId}ingest.writeReference →
Same batch_id, different records409
{
"statusCode": 409,
"message": "idempotency_conflict",
"error": "Conflict"
}
A rejected record in the receipt202
{
"records_received": 2,
"records_accepted": 1,
"records_rejected": 1,
"rejections": [{ "index": 1, "reason": "player_ref_invalid" }],
"replayed": false
}

5Read a KPI

GET/v1/kpis/ownOne of your own KPIs over a periodkpis.readReference →

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.

Checkpoint

You get 200 with a series covering every week in the range.

GET /v1/kpis/own
playerpal.sh
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]}"
Response200
{
"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​

ResponseCauseFix
401 invalid_signatureThe canonical string or secret differs from PlayerPal'sCheck your signer against the test value in step 2; sign the exact body bytes
401 stale_timestampYour clock is more than 300 seconds offSync the clock with NTP
401 replayed_nonceA nonce was reusedGenerate a fresh random nonce per request
403 partner_scope_missingThe key lacks the route's scopeAsk PlayerPal to add ingest.write or kpis.read
403 source_id_must_be_authenticated_partysource_id is not your party IDUse the party ID PlayerPal gave you
409 idempotency_conflictSame batch_id, different recordsUse a new batch_id for new data

Every error code is listed in Idempotency and errors.

Next steps​