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

# Events

> Record behavioral events, then read and list them back.

An **event** is something a person or object *did* at a point in time: a
`payment_made`, a `checkout_started`, a `loan_disbursed`. Unlike people and
custom objects (which you upsert to a current state), events are an
append-only log: each one is recorded once and read back later, individually
or as a filtered, paginated stream.

## Anatomy of an event

| Field | Required | Notes |
| - | - | - |
| `name` | ✅ | Letters, numbers, and underscores only, no spaces or symbols. |
| `externalId` | ✅ | Your stable id for this event. It's the **idempotency key**: re-sending the same one returns `created: false` instead of duplicating. |
| `timestamp` | Optional | ISO 8601 event time. Defaults to now() when omitted. |
| `personExternalId` | Optional | The person subject. |
| `customObjectType` + `customObjectExternalId` | Optional | The object subject (sent together). |
| `properties` | Optional | Free-form JSON describing the event. |

<Note>
  At least one subject is required: `personExternalId`, **or** the
  `customObjectType` + `customObjectExternalId` pair. You may set both (a
  person event about an object).
</Note>

Subjects are referenced by **your** external ids. They're stored as-is and
Boom resolves the internal link best-effort: recording an event for a person
who hasn't synced yet still succeeds, and the link is back-stitched later. A
missing subject never fails the request.

## Record an event

```bash theme={null}
curl -X POST "$BASE/events" \
  -H "Authorization: Bearer $BOOM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"payment_made","externalId":"evt_123","personExternalId":"user_42","properties":{"amount":1200}}'
```

Returns `{ "created": true, "eventId": "evt_db_1", "externalId": "evt_123" }`.

`externalId` is your own id echoed back. Keep it: it's the key the
[journey webhooks](/webhooks) report as `trigger.externalId`, so you can match an
arriving `journey_run.started` / `journey_run.ended` to the record that caused it
without storing `eventId`.

For historical loads, **Record events in bulk** accepts up to 1000 per request.
Bulk ingest is treated as backfill: it does **not** fire journey enrollment,
so use the single endpoint for real-time, journey-triggering events.

## Read events back

Fetch one by the `externalId` you supplied:

```bash theme={null}
curl "$BASE/events/evt_123" -H "Authorization: Bearer $BOOM_KEY"
```

Or **list** them, newest first, with optional filters (combined with AND):

```bash theme={null}
# every payment_made for one person, in June
curl "$BASE/events?name=payment_made&personExternalId=user_42&start=2026-06-01T00:00:00Z&end=2026-07-01T00:00:00Z" \
  -H "Authorization: Bearer $BOOM_KEY"
```

Filters match the external references you supplied at ingest, so events
recorded before their subject synced are still returned.

## Count events

To answer "how many", ask for a count rather than paging the list and tallying
it — a page is capped, so counting one gives you a number that looks right and
is wrong.

```bash theme={null}
# how many payment_made events, broken down by the payload's "bank"
curl "$BASE/events/aggregate?name=payment_made&group_by=bank" \
  -H "Authorization: Bearer $BOOM_KEY"
```

```json theme={null}
{
  "total": 716,
  "unique_people": 402,
  "groups": [
    { "value": "BBVA", "count": 430, "unique_people": 250 },
    { "value": null,   "count": 286, "unique_people": 152 }
  ]
}
```

`total` counts **events**; `unique_people` counts **people**. They differ
whenever someone fires the same event more than once, so don't report one as the
other. `group_by` buckets by a payload property, and events that don't carry it
land under `"value": null`. You can narrow first with `start`/`end`,
`personExternalId`, or a `filter` on the payload
(`&filter={"currency":"MXN"}`).

If more distinct values exist than we return, `groups_truncated` is `true` — the
bucket counts then don't add up to `total`.

One caveat worth reading twice: the **`unique_people` inside each bucket never
add up to the top-level `unique_people`**. Somebody who paid once with BBVA and
once with HSBC is a real person in both buckets. The `count`s split your events
into groups; the `unique_people` don't split your people.

This counts **one** event name. To relate two different events — how many people
who did X went on to do Y — build a segment with `event` predicates instead.

## Pagination

Every list endpoint (events, people, custom objects) is **cursor-paginated**.
A response carries `next_cursor`; pass it back as `?cursor=` to get the next
page, and loop until `next_cursor` is `null`:

```
GET /events?limit=1000              → { data:[1000], next_cursor:"c1" }
GET /events?limit=1000&cursor=c1    → { data:[1000], next_cursor:"c2" }
GET /events?limit=1000&cursor=c2    → { data:[42],   next_cursor:null }   # done
```

`limit` defaults to 100 (max 1000). The cursor is opaque and stable under
concurrent writes: no skipped or duplicated rows.

## Related

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Record your first event alongside people and objects.
  </Card>

  <Card title="Relationship types" icon="shapes" href="/relationship-types">
    Model the links between the people and objects your events reference.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.