# Send player activity

Game providers send PlayerPal the rounds that linked players settle. PlayerPal turns eligible rounds into Points and into the activity KPIs you can read from the [KPI API](./kpi-api.mdx). Activity is sent after settlement, from your backend, asynchronously. It never sits in the wager or settlement path, so a PlayerPal outage cannot block play.

## Batches or uploads?

| | Activity batch | Activity upload |
|---|---|---|
| Endpoint | `POST /v1/activity-batches` | `POST /v1/activity-uploads`, then `PUT` the file, then complete |
| Format | JSON body | NDJSON, JSON, CSV, or any of those gzip-compressed |
| Best for | Frequent small batches sent close to settlement | Large periodic exports, backfills |
| Size | One request body | Up to the upload limit (64 MiB by default) |

Both paths end in the same batch, with the same validation, states and idempotency.

## What PlayerPal does with your events today

[Available]

- **The whole batch is rejected (`400`) if any event fails basic checks**: a missing `event_id`, `player_id` or `type`, a `player_id` that is not a provider alias, or a malformed `market`. Nothing is stored. Fix the event and send the batch again.
- **Only `round_settled` is processed.** Other types are accepted and then dropped.
- **Only `EUR` is processed.** `currency` defaults to `EUR`; events in any other currency are dropped.
- **Events for unlinked aliases are dropped**: unknown or revoked aliases, and rounds whose `occurred_at` is missing, before the Link's `effective_from` or at or after an Unlink.
- Events without a positive `bet`, or with a `market` not configured for your integration, are also dropped.

Dropped events never fail the rest of the batch, and they earn nothing.

## Set up the integration key

Activity routes use the integration key PlayerPal issues during [onboarding](./onboarding.mdx), sent as `x-api-key: <your-integration-key>` or `Authorization: Bearer <your-integration-key>`.

Every request also sends `x-integration-id: pp_int_XXXX`. The batch belongs to that integration only; `operator_id` in a body is accepted and ignored. Keep the key server-side.

(Code sample: integration-key. See the page for curl, Node.js, Python, PHP, Java and C#.)

Environment:

```
export PP_API_BASE="https://<playerpal-api-host>"
export PP_INTEGRATION_KEY="<your-integration-key>"
export PP_INTEGRATION_ID="pp_int_XXXX"
```

## Send a batch

`activitySubmitBatch` (see the API reference)

A batch is a `batch_id` and a list of events. Open `events` below to see every event field.

(Parameter table: see the API reference.)

- `event_id` is your stable ID for the event, unique per integration. Do not reuse it in another batch.
- `player_id` is the provider alias from [linking](./linking-players.mdx), starting `pp_plr_prv_`.
- `occurred_at` is the settlement time in ISO 8601 UTC. Without it the event cannot qualify.
- `bet` and `win` are decimal strings with up to two decimal places, such as `"2.50"`.
- `market` is ISO 3166-1 alpha-2 in upper case, such as `MT`. If you omit it and your integration has exactly one market, that market is used.

(Parameter table: see the API reference.)

(Code sample: activity-batch. See the page for curl, Node.js, Python, PHP, Java and C#.)

Response:

```
{
  "batch_id": "rounds-2026-10-15-0001",
  "state": "accepted",
  "received_events": 1,
  "received_at": "2026-10-15T12:04:00.120Z"
}
```

One event fails basic checks:

```
{
  "statusCode": 400,
  "message": "player_id_must_be_provider_alias",
  "error": "Bad Request"
}
```

## Check processing

`activityGetBatch` (see the API reference)

`202` means PlayerPal has stored the batch. Processing happens afterwards. Poll the batch with backoff to follow it.

| `state` | Meaning |
|---|---|
| `accepted` | Stored, waiting to be processed |
| `processing` | Events are being evaluated |
| `processed` | Every event was evaluated |
| `processed_with_errors` | Processing finished but some events are still pending |

`completed_at` stays `null` until processing finishes. Per-event outcomes, including dropped events, are not returned by the API today. Your acceptance rate is available as the `own.ingest_acceptance` KPI.

You get `200` with `state` `processed` and `received_events` matching the number of events you sent.

(Code sample: activity-status. See the page for curl, Node.js, Python, PHP, Java and C#.)

Response:

```
{
  "batch_id": "rounds-2026-10-15-0001",
  "state": "processed",
  "received_events": 1,
  "received_at": "2026-10-15T12:04:00.120Z",
  "completed_at": "2026-10-15T12:04:00.480Z"
}
```

## Bulk upload for backfills

`activityRequestUpload` (see the API reference)

For large exports, upload a file instead of a JSON body.

1. **Request an upload** with the file's `content_type` and exact `content_length` in bytes. You get an `upload_id`, a presigned `upload_url`, `required_headers` and an `expires_at` 15 minutes ahead by default.
2. **`PUT` the exact bytes** to `upload_url` with every header in `required_headers`, without PlayerPal credentials. The file may not be larger than the size you declared.
3. **Complete the upload** with a `batch_id` and the SHA-256 `checksum` of the file as 64 hex characters (a `sha256:` prefix is accepted). If you send `row_count`, it must equal the number of parsed events.

`activityCompleteUpload` (see the API reference)

`POST /v1/activity-uploads/{uploadId}:complete` is the same call. The response is the same `202` as a batch, and the same all-or-nothing checks apply to the file's events.

**File formats.** NDJSON has one event object per line. JSON can be an array of events, an object with an `events` array, or a single event. CSV uses a header row with the field names; without a header, columns are read in this order: `event_id, player_id, type, bet, win, currency, occurred_at, game_ref, round_ref, market`. CSV values cannot contain commas. Gzip is detected from the content type or the file itself. Parquet is not supported.

(Code sample: activity-upload. See the page for curl, Node.js, Python, PHP, Java and C#.)

Upload ticket:

```
{
  "upload_id": "upl_XXXX",
  "upload_url": "<presigned-upload-url>",
  "expires_at": "2026-10-15T12:19:00.000Z",
  "required_headers": {
    "Content-Type": "application/x-ndjson",
    "Content-Length": "1843302"
  }
}
```

Complete:

```
{
  "batch_id": "backfill-2026-10-0001",
  "state": "accepted",
  "received_events": 18210,
  "received_at": "2026-10-15T12:10:42.000Z"
}
```

## Retry safely

Network errors happen. Retry on timeouts and `5xx` with the **same** `batch_id`, and nothing is counted twice.

- `batch_id` is idempotent per integration. Re-sending the same `batch_id` with the same events returns the existing batch, with its current `state`. Event order does not matter.
- `Idempotency-Key` is optional and works the same way.
- The same `batch_id` or key with different events returns `409 idempotency_conflict`. Use a new `batch_id` for new data.
- Completing an upload twice with the same checksum returns the original batch; a different checksum is `409 idempotency_conflict`.
- If a batch was rejected with `400`, nothing was stored: fix it and send it again under the same `batch_id`.

See [Idempotency and errors](./idempotency-and-errors.mdx).

Same batch_id, different events:

```
{
  "statusCode": 409,
  "message": "idempotency_conflict",
  "error": "Conflict"
}
```

## Errors

| Response | Cause | Fix |
|---|---|---|
| `400 integration_id_required` | No `x-integration-id` header | Send your integration ID |
| `400 batch_id_required`, `events_required` | Empty `batch_id` or no events | Send at least one event under a `batch_id` |
| `400 event_id_required`, `player_id_required`, `event_type_required` | An event lacks a required field | Fix that event; the whole batch was rejected |
| `400 player_id_must_be_provider_alias` | A `player_id` does not start `pp_plr_prv_` | Send the provider alias, never your own player ID |
| `400 market_invalid` | `market` is not two upper-case letters | Use ISO 3166-1 alpha-2, such as `MT` |
| `400 integration_not_found` | Unknown integration ID | Use the ID PlayerPal gave you |
| `400 content_type_unsupported`, `content_length_required`, `object_too_large` | Upload request not accepted | Use an allowed type and the exact size, within the limit |
| `400 checksum_required`, `checksum_mismatch` | Checksum missing or not the uploaded bytes | Hash the exact file you uploaded |
| `400 upload_expired`, `upload_incomplete` | The slot expired or no file arrived | Request a new upload and `PUT` the file before `expires_at` |
| `400 row_count_mismatch` | `row_count` differs from the parsed events | Fix the count or leave it out |
| `400 object_not_json`, `gzip_invalid`, `format_unsupported` | The file could not be parsed | Send NDJSON, JSON or CSV, optionally gzipped |
| `401 machine_auth_required` | Integration key missing or wrong | Send it as `x-api-key` or `Authorization: Bearer` |
| `404 upload_not_found`, `batch_not_found` | Unknown ID for your integration | Check the ID and `x-integration-id` |
| `409 idempotency_conflict` | Same `batch_id`, key or upload, different content | Use a new `batch_id` for new data |

The error code is in the `message` field.

## Related

  - [Link players](./linking-players.mdx): 
    Get the provider aliases your events carry.
  
  - [Fact ingest](./fact-ingest.mdx): 
    Send daily aggregated facts that power the KPI overview.
  
  - [API reference](/api/partner): 
    Activity batch and upload schemas.
