# Idempotency, retries 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.

<Explainer id="idempotency" />

## 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 `<field>_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.
