Event ingestion API
The single endpoint a PAM needs.
POST /api/v1/events
x-api-key: plq_… # brand-pinned key
Content-Type: application/jsonRequest — a single event
{
"player_id": "player-123",
"event_type": "CashIn",
"event_id": "pam-evt-9a7f3",
"payload": { "amt": 2500, "currency": "EUR", "method": "card" },
"timestamp": "2026-07-09T10:15:30Z",
"session_id": "sess-42"
}| Field | Required | Notes |
|---|---|---|
player_id | ✅ | Your internal player id (stored as the player’s external_id). |
event_type | ✅ | Whatever string your PAM emits. Translated by the mapping. |
event_id | — | Idempotency key (≤128 chars). Re-sends with the same key are de-duped. |
payload | — | Free-form JSON object. Defaults to {}. |
timestamp | — | ISO-8601. Defaults to server receive time. Set it for replays/backfills. |
session_id | — | Optional grouping id for a play session. |
Request — a batch (up to 100)
{
"events": [
{ "player_id": "p1", "event_type": "Login" },
{ "player_id": "p2", "event_type": "CashIn", "payload": { "amt": 5000 } }
]
}A batch is up to 100 events. For higher throughput, send batches concurrently — the endpoint is stateless per request.
Players are upserted automatically
You do not pre-create players. On ingest, PlaylogiQ upserts the player by
(brand, player_id): unknown players are created, known players are matched. If
your mapping has a player field map, mapped attributes
(email, name, …) are written on the same upsert.
Idempotency & retries
Send an event_id and you can safely retry on any network/5xx error: a repeat
with the same event_id is stored once (deduplicated on event_id within
the tenant). This makes the PAM’s job simple — at-least-once delivery with
retries is sufficient; you never risk double-counting a deposit.
Response
{
"received": 2,
"stored": 2,
"deduped": 0,
"status": "queued",
"timestamp": "2026-07-09T10:15:30.412Z"
}| Field | Meaning |
|---|---|
received | Events in the request. |
stored | Newly stored (excludes idempotent duplicates). |
deduped | Duplicates skipped via event_id. |
rejected | (if any) Events refused by the mapping’s reject policy. |
ignored | (if any) Events dropped by the mapping’s ignore policy. |
unknown_event_types | (if any) Types stored but neither canonical nor mapped — a hint to extend your mapping. |
rejected_event_types | (if any) The distinct types that were rejected. |
status | Always "queued" — accepted for asynchronous derivation. |
Error responses
| Status | code | Cause | Retry? |
|---|---|---|---|
| 400 | BAD_REQUEST | Body isn’t valid JSON, or fails schema validation (see details). | ❌ Fix the payload |
| 401 | UNAUTHORIZED | Missing/invalid/expired API key. | ❌ Fix the key |
| 403 | FORBIDDEN | Key lacks the events:write scope. | ❌ Fix the key |
| 429 | RATE_LIMITED | Over the per-minute budget (see below). | ✅ After Retry-After |
| 500 | INTERNAL_ERROR | Unexpected server error. | ✅ With backoff |
A validation failure returns Zod-style details:
{ "error": "Invalid event payload", "details": { "fieldErrors": { "player_id": ["player_id is required"] } } }Rate limits
Ingestion has its own generous per-tenant budget, separate from the admin/read API so the event firehose can’t starve it.
| Tier | Default budget | Env override |
|---|---|---|
Ingestion (POST /events) | 6000 requests / minute per tenant | RATE_LIMIT_INGEST_PER_MIN |
| Admin/read API | 1200 requests / minute per tenant | RATE_LIMIT_PER_MIN |
- With batches of 100, 6000 req/min = up to 600,000 events/minute.
- A
429carriesRetry-After(seconds) plusX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders. HonourRetry-Afterbefore retrying. - The limiter fails open — if the platform’s cache is unavailable, requests
are allowed rather than rejected. Do not rely on
429as a hard ceiling. - Budgets are the fixed-window per-tenant defaults; a specific installation may raise them. Confirm the effective limit with the operator if you expect sustained high volume.
Capacity planning
The limit counts requests, not events — so batching is the throughput lever:
| Sending style | Throughput per tenant (default) |
|---|---|
| Unbatched (1 event/request) | 100 events/second |
| Batched (100 events/request) | 10,000 events/second (600k/min) |
For a brand with tens of thousands of concurrent players this is comfortable when batched — e.g. 50,000 active players × 10 events/min = 500k events/min, well inside the batched budget. If you send unbatched you’ll hit the 100 req/s request cap first, so batch wherever you can.
Raising it is a one-line operator change — set RATE_LIMIT_INGEST_PER_MIN in the
api environment and recreate the container; no code change. The budget is
per-tenant and Redis-backed (fail-open). Past a certain volume the real ceiling
becomes database write throughput, not the limiter.
Limits & constraints
| Constraint | Value |
|---|---|
| Events per request (batch) | 1–100 |
event_id length | ≤ 128 chars |
player_id | required, non-empty string (your internal id) |
event_type | required, non-empty string |
payload | JSON object (defaults to {}) |
timestamp | ISO-8601; send in UTC. Defaults to server receive time |
| Transport | HTTPS only |
cURL example
curl -X POST https://crm-api.logiqdesk.com/api/v1/events \
-H "x-api-key: plq_your_brand_key" \
-H "Content-Type: application/json" \
-d '{
"player_id": "player-123",
"event_type": "CashIn",
"event_id": "pam-evt-9a7f3",
"payload": { "amt": 2500, "currency": "EUR" }
}'The base URL above is the reference self-hosted deployment (
crm-api.logiqdesk.com). Use whatever host your installation exposes.
Next: translate CashIn / amt into the canonical deposit / amount with
the mapping engine.