TPP Public API · Events Engine

Turn events into points, exactly once

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.

Open the Events Engine reference →POST /api/v1/engine/events

Core concepts

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

curl -X POST https://api.thepowerplugin.com/api/v1/engine/events \
  -H "Authorization: Bearer tpp_live_<32 hex>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-bonus-6b1e9f2a" \
  -d '{
    "event_code": "signup_bonus",
    "data": {
      "externalUserId": "player-8842",
      "campaign": "summer-2026"
    },
    "correlation_id": "corr_3f2a6d1e9c084b7f"
  }'

Response

A delivery that passes pre-flight always returns 200 with an EngineEventResult. Check status — don't assume completed.

{
  "data": {
    "event_id": "01933b1c-2300-7000-8000-0000000000aa",
    "status": "completed",
    "replayed": false,
    "correlation_id": "corr_3f2a6d1e9c084b7f",
    "customer_id": "01933b1c-1f00-7000-8000-000000000001",
    "points_requested": 500,
    "points_applied": 500,
    "balance_before": 100,
    "balance_after": 600,
    "error_code": null,
    "error_message": null
  }
}

Outcomes at a glance

A passed pre-flight always returns 200 with a status. Pre-flight rejections use the error envelope. Only internal_error is retryable.

StatusHTTPMeaningerror_code
completed200Points were applied to the wallet.
skipped200Business rules declined; execution recorded, no points moved.outside_active_window · max_daily_reached · cooldown_active · invalid_constraints
failed200 / 500Ran but did not apply. internal_error returns 500 and is retryable.player_id_missing · player_not_found · insufficient_balance · internal_error
rejected400 / 403 / 404 / 409Pre-flight rejection — error envelope, no execution row.missing_idempotency_key · payload_too_large · override_not_allowed · invalid_points · forbidden · not_found · conflict

Idempotency, replay & retries

REST — you supply the key

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.

ConstraintEffectSkip code
starts_at / ends_atActive window; deliveries outside it are skipped.outside_active_window
max_daily_per_playerCaps completed grants per player per UTC day.max_daily_reached
cooldown_secondsMinimum 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.

Open the Events Engine reference →