> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fourdos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Earning events

> The event types that can earn XP, and the payload each one expects

An XP rule can only price an event type that one of your installed modules
declares. This page catalogs the engagement events declared by the 4D
Engagement module and the payload each expects, so your backend knows what to
send. How events get in is covered in [Events](/concepts/events); what can
stop an award is covered in [XP](/concepts/xp).

The machine-readable source of truth is the module version manifest your
installation is pinned to: `GET /v1/installations` gives you the `module_id`
and pinned `version_id`, `GET /v1/modules/{moduleId}/versions` returns each
version's manifest, and the `emits[]` entry for your event type carries the
JSON Schema for its payload. An installation stays on its pinned version until
you adopt a newer one, so read the pinned manifest rather than assuming the
newest catalog version.

## The catalog

| Event type                     | A member...                             | Same content item                              |
| ------------------------------ | --------------------------------------- | ---------------------------------------------- |
| `engagement.content.viewed`    | read or viewed a content item           | Pays once per item                             |
| `engagement.quiz.completed`    | completed a quiz                        | Pays once per item                             |
| `engagement.video.completed`   | watched a video to the end              | Pays once per item                             |
| `engagement.profile.completed` | finished the profile the club asked for | Repeatable - cap it with a lifetime limit of 1 |
| `engagement.venue.attended`    | checked in at a club venue              | Repeatable - each visit can pay                |
| `engagement.reply.posted`      | posted a reply in a club thread         | Repeatable - each reply is its own event       |

"Pays once per item" is the always-on same-content guard described in
[XP](/concepts/xp#what-can-stop-an-award): the first paid event claims that
item (`content_id`, `quiz_id`, `video_id`) for the member, and a later event
for the same item is declined as `content_already_earned` - even if it
arrives under a fresh `event_id`. The repeatable types have no per-item
identity to claim, so the brakes there are the per-action daily and lifetime
award limits your organization configures in the console.

## Payload contracts

Send these as the `payload` of your event. The schemas are closed: an
undeclared field is rejected. Every event needs a member identity - your
`member_external_id` at the envelope level or a platform `user_id` in the
payload; an event with neither is recorded but earns nothing.

### `engagement.content.viewed`

| Field              | Status   | Type                                 |
| ------------------ | -------- | ------------------------------------ |
| `content_id`       | Required | string - identifies the content item |
| `user_id`          | Required | string                               |
| `content_type`     | Optional | string                               |
| `duration_seconds` | Optional | non-negative number                  |

### `engagement.quiz.completed`

| Field     | Status   | Type                         |
| --------- | -------- | ---------------------------- |
| `quiz_id` | Required | string - identifies the quiz |
| `user_id` | Required | string                       |
| `score`   | Required | number                       |

### `engagement.video.completed`

Completion is your claim; the measurement fields use Google Analytics 4's
parameter names so a video player integration can forward what it already
emits.

| Field                | Status   | Type                                |
| -------------------- | -------- | ----------------------------------- |
| `video_id`           | Required | string - identifies the video asset |
| `video_title`        | Optional | string                              |
| `video_url`          | Optional | string                              |
| `video_duration`     | Optional | non-negative number                 |
| `video_current_time` | Optional | non-negative number                 |
| `video_percent`      | Optional | number from 0 to 100                |
| `user_id`            | Optional | string                              |

### `engagement.profile.completed`

A profile milestone, not a field-change event. No required payload fields.

| Field                | Status   | Type                 |
| -------------------- | -------- | -------------------- |
| `completion_percent` | Optional | number from 0 to 100 |
| `completed_fields`   | Optional | non-negative integer |
| `required_fields`    | Optional | non-negative integer |
| `user_id`            | Optional | string               |

### `engagement.venue.attended`

Check-in evidence is yours: the platform validates the field shapes, stores
them and passes them through, but does not verify a geofence, QR value or
beacon signal.

| Field                 | Status   | Type                                                     |
| --------------------- | -------- | -------------------------------------------------------- |
| `checkin_id`          | Required | string - your check-in record                            |
| `venue_id`            | Required | string - the venue in your system                        |
| `venue_name`          | Optional | string                                                   |
| `fixture_id`          | Optional | string - the match or occasion; also scopes the event id |
| `verification_method` | Optional | one of `geofence`, `qr`, `beacon`, `manual`              |
| `accuracy_meters`     | Optional | non-negative number                                      |
| `user_id`             | Optional | string                                                   |

### `engagement.reply.posted`

One event per reply, so a member can have many.

| Field             | Status   | Type                                                        |
| ----------------- | -------- | ----------------------------------------------------------- |
| `reply_id`        | Required | string - the reply in your system                           |
| `thread_id`       | Required | string                                                      |
| `parent_id`       | Optional | string - the reply this one answers                         |
| `character_count` | Optional | non-negative integer - for a later minimum-length condition |
| `user_id`         | Optional | string                                                      |

## Choose your event ids deliberately

`event_id` is the deduplication anchor: a repeat send of the same id credits
nothing a second time. Derive it from the identity of the *moment*, not at
random, and reuse it on retries:

* **Profile completed** - `organization_id + member_id`. One milestone per
  member.
* **Venue attended** - `organization_id + member_id + venue_id + fixture_id`,
  or the UTC calendar date of `occurred_at` when there is no fixture. One
  check-in per member, venue and occasion.
* **Video completed** - `organization_id + member_id + video_id`. The
  platform's same-content guard backs this up regardless of event ids.
* **Reply posted** - `organization_id + reply_id`. A different reply must get
  a different id.

Any collision-resistant deterministic string works, as long as the same
identity always produces the same id and your organization id is part of what
you hash.

Finally, pick **one event type per asset**. `engagement.content.viewed` and
`engagement.video.completed` are different types: if you emit both for the
same asset and both are priced, both pay. The platform does not infer that
they describe the same content.
