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

# The CDP event model

> What a CDP event is in Orbit: the append-only event stream per tenant, how events bind to contacts, how tracking plans and event schemas govern the stream, sessionization, and where events land downstream.

# The CDP event model

Every behavioral fact Orbit learns about a customer — a page view, a
purchase, a link click — enters the platform as a **CDP event**. This page
defines that event: what shape it has, where it is stored, how it binds to
a contact, how a tracking plan governs what is accepted, how events group
into sessions, and what consumes them downstream. Segments, computed
traits, attribution, and funnels all read the same event stream; this page
is the concept those guides assume.

## Section 1 — The event stream: one append-only table per tenant

Inbound events arrive on the Segment-compatible ingest surface —
`POST /api/v1/cdp/track`, `/identify`, `/page`, `/group`, `/alias`,
`/screen`, and `/batch` — recognized by the URL's `ingest_id` and an
HMAC signature against your tenant's ingest secret. A `track` call
carries an event name, a properties payload, an optional context
envelope, an optional client timestamp, and identity hints
(`userId` or `anonymousId` — the Segment spec requires one of the two):

```json theme={null}
{
  "event": "Order Completed",
  "userId": "user_42f8",
  "anonymousId": "anon_71ba",
  "properties": {
    "order_id": "ord_99171",
    "total": 129.0,
    "currency": "USD"
  },
  "context": {
    "library": { "name": "@devotel-orbit/web", "version": "1.4.0" },
    "page": { "url": "https://devotel.io/checkout" }
  },
  "timestamp": "2026-08-25T13:04:11Z",
  "messageId": "2b1c9d2e-…"
}
```

Each accepted event lands in the tenant schema's `cdp_events` table — one
append-only table per tenant, alongside `usage_events`, per the tenant
catalog in [Tenant isolation](/concepts/tenant-isolation). Rows are never
updated or deleted by the platform (only erased on a GDPR erasure cascade
from the owning contact). The stored row keeps the ingest truth
faithfully:

| Column                      | Holds                                                                     |
| --------------------------- | ------------------------------------------------------------------------- |
| `id`                        | Stable id minted at ingest                                                |
| `type`                      | The Segment verb: `track`, `identify`, `page`, `group`, `alias`, `screen` |
| `event_name`                | The event name for `track` rows (`"Order Completed"`)                     |
| `message_id`                | The client-minted idempotency key — retries dedup on it                   |
| `user_id` / `anonymous_id`  | The caller-supplied identity hints                                        |
| `contact_id`                | The resolved contact — NULL until identity resolution runs                |
| `properties`                | The event payload (JSONB)                                                 |
| `context`                   | The library/page/ip envelope, plus stamped enrichments                    |
| `received_at`               | Server-stamped arrival time (never the forgeable client timestamp)        |
| `processed_at`              | Set when the downstream fan-out worker is done with the row               |
| `source`                    | Ingest source (e.g. `http`)                                               |
| `gcs_uri` / `spilled_bytes` | Pointer when a payload over the inline cap spills to object storage       |

Two practical consequences of this shape:

* **The server time is authoritative.** `received_at` drives the dashboard
  event index; the client's `timestamp` field is stored but never trusted
  for ordering.
* **Retries are safe.** `message_id` dedups SDK retries, so an at-least-once
  client does not double-count.

### Platform-emitted events: `whatsapp_flow_submitted`

Not every event on the stream arrives from your SDK through the HTTP
ingest surface. Orbit itself mints events when a customer action outside
the browser deserves to be on the profile — each one enters through the
same ingest chokepoint as SDK traffic, so tracking-plan, sessionization,
and downstream consumers cannot tell the difference. The first of these is
the WhatsApp Flow completion:

| Event attribute | Holds                                                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `event_name`    | `whatsapp_flow_submitted`                                                                                                            |
| `type`          | `track`                                                                                                                              |
| `properties`    | The customer's submitted answers, verbatim — one property per Flow screen field                                                      |
| `context`       | Stamped with the source (`whatsapp_flows_endpoint`), the channel (`whatsapp`), the flow's Meta id and Orbit id, and the flow token   |
| `message_id`    | Deterministic per (flow, flow token) — a WhatsApp retry of the same submission dedups onto one row, forever                          |
| `contact_id`    | The resolved submitter when the sender's number matches a contact; `NULL` when unidentifiable — a submission never creates a contact |

Landing guarantees — deliberately identical to the [custom-field
write-back](/channels/whatsapp#flow-submissions-write-to-the-contact-profile)
that runs beside it:

* **Additive write.** The event appends to the stream the moment the
  customer completes the Flow; nothing already on the profile is removed
  or downgraded.
* **Blank answers are ignored.** A `null` or empty answer never wipes a
  value an earlier submission set — re-running a Flow with an optional
  field skipped is safe.
* **Unmatched field ids are skipped.** A Flow field with no matching
  custom-field definition still rides on the event's properties (segments
  and journeys can target it), but it is never invented as a new contact
  field.

Point event-triggered journeys and `performed_event` segment operators at
`whatsapp_flow_submitted`; the per-submission `landed_in_cdp` marker on
the [submissions list](/channels/whatsapp#browse-individual-flow-submissions)
tells you whether a given row has landed.

## Section 2 — Event identity: how an event binds to a contact

An event is born with the identity hints the SDK sent (`user_id`,
`anonymous_id`, or both) — it does not yet point at a person. A downstream
identity-resolution pass matches those hints to a contact row and stamps
`contact_id`, and from then on the event reads as that person. Merges follow
the event: when duplicate contacts collapse, the event's ownership follows
the golden record.

The contract, hierarchy, and review flow for that matching are covered in
[Identity resolution](/guides/identity-resolution) — this page only fixes
the distinction it matters on: *identity hints ride on the event; binding
happens after ingest, not at ingest.*

## Section 3 — Event schemas and tracking plans

Left ungoverned, an event stream drifts: one SDK ships `Order Completed`,
another `order_completed`, a third adds a `totle` typo property, and every
downstream consumer quietly fragments. Orbit exposes two cooperating
declare-and-enforce surfaces to stop that:

* **Tracking plan** (`/api/v1/cdp/tracking-plan/*`) — the tenant-level
  catalog of allowed events. Each entry declares an event name, its
  description, the properties it may carry, the required-property list,
  and an enforcement mode.
* **Event schemas** (`/api/v1/cdp/schemas/*`) — a per-event-type JSON Schema
  with its own enforcement mode: `strict` (rejects a violating event),
  `warn` (accepts and records the violation), or `off` (passthrough).
  No schema row exists for an event type → the event passes through,
  which keeps the surface back-compatible before you declare anything.

Enforcement runs on the ingest hot path, per (tenant, event type). Every
violation is recorded durably to `cdp_tracking_plan_violations` — and a
spike in violation volume fires an operator alert, so an SDK regression
that starts shipping malformed events surfaces in minutes rather than at
the next manual audit.

Declare a schema when the cost of property drift is real: when segments
filter on the properties, when destination syncs map fields, or when a
data dictionary needs to hold. Until then, passthrough costs nothing.

## Section 4 — Sessionization

A stream of individual "what happened" rows is not directly the analytic
grain most reports want; those reports read at the session grain — "the
visit that produced this purchase." Orbit sessions the stream **at
ingest, server-side**, rather than leaving session reconstruction to
every consumer:

* The subject of a session is
  `COALESCE(contact_id, anonymous_id, user_id)` — the same journey key
  funnel and path analytics use.
* Consecutive events for one subject belong to one session as long as the
  gap between them stays under the industry-standard 30-minute timeout.
  Exceed it (or there is no prior event for the subject) and a fresh
  session id is minted.
* The resolved `session_id` rides on `cdp_events.context`, so no
  schema migration and no per-consumer re-derivation — funnels, paths,
  and session traits read the same stamped id.

The lookup is fail-open: any error mints an extra session boundary rather
than delaying or dropping the customer event. An out-of-order or
backdated client timestamp cannot start spurious sessions — only a
genuine gap in either direction does.

## Section 5 — Event governance

Three tenant-owned controls sit on top of the stream:

* **Event-property governance** (`/api/v1/cdp/event-property-governance/*`) —
  a data dictionary for the *other* half of the catalog (contact traits
  are classified separately). Each event property can carry a sensitivity
  level, data categories (financial, health/PHI, government id, …), an
  accountable owner, the allowed uses (analytics, activation, ML
  training, …), and a masking strategy for exports. A property with no
  entry resolves to the safe public/ungated default.
* **Event-volume anomaly detection** (`GET /api/v1/cdp/event-volume-anomaly`) —
  compares the most recent complete hourly ingest bucket against a learned
  baseline of the preceding hours and classifies it `spike`, `drop`,
  `normal`, or `insufficient_data`. A broken SDK deploy or an expired
  ingest key shows first as a volume drop — before any tracking-plan
  violation appears.
* **The event debugger** (`GET /api/v1/cdp/events/debug`) — a live tail of
  the incoming stream with per-field filters (type, event name, source,
  identity, message id, spillover status) and a free-text search across
  the payload. Newest-events use an `after` cursor for incremental
  polling and a `before` cursor for back-pagination. The debugger streams
  the full event payload — which routinely carries checkout PII — so it
  is scope-gated the same way as the contact surface.

## Section 6 — Where events land

Events are only the raw material; the consumers sit downstream:

| Event grain               | Consumer surface                                                                                                        |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Profile + behavior        | [CDP segments](/guides/cdp-segments) — segment operators like `performed_event` / `event_count` filter over this stream |
| Contact-scoped aggregates | Computed traits — derived values stamped back onto the contact                                                          |
| Session grain             | Funnels and path analytics — the session boundary stamped at ingest                                                     |
| Revenue flows             | Attribution and media-mix models — events feed the conversion side                                                      |
| Regulatory                | DSAR / erasure cascades — events follow the contact's erasure                                                           |

For the full parameter-level surface of every endpoint named here, see
the [CDP API reference](/api-reference/endpoints/cdp); this page names
the model and stops there.

## Cross-references

* [Tenant isolation](/concepts/tenant-isolation) — `cdp_events` /
  `usage_events` per tenant schema.
* [Identity resolution](/guides/identity-resolution) — how hints become
  a contact binding.
* [CDP segments](/guides/cdp-segments) — building audiences over this
  stream.
* [WhatsApp Flow submissions](/channels/whatsapp#flow-submissions-write-to-the-contact-profile) —
  the `whatsapp_flow_submitted` event's source surface.
* [CDP API reference](/api-reference/endpoints/cdp) — endpoint-level
  detail.
