The Events Engine is TPP's event motor. You deliver an event; a single audited pipeline resolves the player, evaluates your rules and moves points on the coin ledger. Points redeem for physical prizes — so every path is built like a money operation: exactly-once, row-locked, fully auditable.
Six ideas the whole design rests on. v1 ships a single action — Grant / Remove points — but the model is additive: new actions plug into the same pipeline without a rewrite.
Definition vs. execution
An Event Definition is the configured rule (code, points, constraints, player-id field), authored per sub-brand. Every delivery is an immutable Execution that freezes a snapshot of the definition it ran against — later edits never rewrite history.
One pipeline, two channels
REST (POST /engine/events) and inbound webhooks (POST /engine/webhooks/{code}) feed the exact same pipeline: validate → resolve player → business rules → ledger → audit. Two wire shapes, identical guarantees.
Points are the coin ledger
The Events Engine never keeps a parallel balance. It writes to the existing coin wallet under a row lock (reason engine:<execution_id>), so the balance is always the sum of an append-only movement log — reconcilable to the cent.
Exactly-once by construction
An insert-first idempotency claim on (definition_id, idempotency_key) plus a fencing token means a retried or replayed delivery credits the wallet once and only once — a crash mid-flight is safely reclaimed, never double-applied.
Immutable audit trail
Every delivery — completed, skipped, failed, replay, even a rejection — lands in an append-only audit log, independent of the ledger and behind a repository interface that swaps PostgreSQL for ClickHouse without touching business logic.
Correlation IDs everywhere
A correlation_id ties the execution, the audit record and the API request log together. Pass your own (echoed back) or let the Events Engine mint one; quote it to support to trace a delivery end to end.
The delivery pipeline
Every event — REST or webhook — walks the same seven stages inside one transaction boundary. A failure at any stage records the outcome; it never leaves the wallet half-moved.
1
Validate & size-guard
Event code shape, points-override rules and a 16 KB payload budget are checked at the choke point — every source (API, webhook, future worker) is bounded before anything is persisted.
2
Resolve the definition
The event_code maps to an active definition for your sub-brand; its snapshot (points, constraints, player-id field) is frozen onto the execution.
3
Claim idempotency
An insert-first claim on (definition_id, idempotency_key) wins the right to execute; a losing racer replays the frozen result. A fencing token guards the terminal write.
4
Resolve the player
The definition's player-id field (default externalUserId) is matched against the customer's external_id within your sub-brand — no cross-tenant leakage.
5
Apply business rules
Active window and per-player velocity caps (max daily, cooldown) decide grant vs. skip. Unknown/reserved constraints fail closed rather than silently pass.
6
Move the ledger
The wallet row is locked (SELECT FOR UPDATE), the movement is written with a balanceAfter snapshot, and the non-negative-balance guard can't be raced.
7
Write the audit
The terminal outcome (fenced on the claim) plus a full audit record are committed. The response carries event_id, status and the balance snapshot.
On the wire
Send snake_case JSON with a Bearer key scoped to engine:events. The Idempotency-Key header is required on the REST channel — it doubles as the business idempotency key.
Request
One event, two interchangeable channels. Same pipeline, same ledger, same audit trail.
The Idempotency-Key header is required. A same-key retry is short-circuited by the HTTP idempotency layer: the original response body verbatim (so replayed reads false) plus an idempotent-replay: true response header. Same key + a different body → 409 idempotency_conflict.
Webhook — the payload is the key
Redeliveries dedupe on the definition's idempotencyKeyField (default event_id, then id). Ids longer than 64 chars are hashed deterministically. No usable id and no header → 400 missing_idempotency_key.
Business replay vs. transient
replayed: true in the body marks a business-layer replay (e.g. the same id already arrived via webhook) — the frozen first result, never a fresh read. A failed with internal_error returns 500 and is safe to retry with the same key; the pipeline reclaims the abandoned row after its TTL.
Constraints (per definition)
Rules are configured on the definition, evaluated per delivery, and enforced server-side. Unrecognized or reserved keys fail closed — the delivery is skipped with invalid_constraints rather than silently ignored.
Constraint
Effect
Skip code
starts_at / ends_at
Active window; deliveries outside it are skipped.
outside_active_window
max_daily_per_player
Caps completed grants per player per UTC day.
max_daily_reached
cooldown_seconds
Minimum interval between two grants to the same player.
cooldown_active
Built like a money operation
Money-grade concurrency
Every credit/debit runs inside a transaction that locks the wallet row, so simultaneous deliveries for the same player serialize — no lost update, no negative balance, no double-credit.
Fail-closed anti-replay
A webhook with no derivable id (no idempotencyKeyField match and no Idempotency-Key header) is rejected 400 missing_idempotency_key rather than executed — a replayed body can never farm points.
Bounded blast radius
Points are capped at |points| ≤ 1,000,000, payloads at 16 KB, and points_override only applies to definitions that opt in — otherwise 400 override_not_allowed, before any row is created.
Tamper-evident history
The audit trail is append-only and separate from the ledger; a ledger backstop (a partial-unique on engine: movement reasons) makes a duplicated credit impossible even if the pipeline were bypassed.
Wire up your first event
Mint a key scoped to engine:events, author a definition on the brand's Events tab, and POST your first delivery. Every field and schema lives in the reference.