pex

Journey Events API

Adaptive Journeys consume events from the same /api/events endpoint your snippet and SDK already use. There's no separate "journey events" ingestion path — events are first-class data that journeys subscribe to via trigger contracts.

This page covers two related surfaces:

  1. Trigger contracts — versioned mappings from event names to journey-relevant fields, decoupled from the underlying TrackingEvent schema.
  2. Goal events — the event a journey is optimizing for, used for calibrated impact and adaptive branch attribution.

Why trigger contracts (not raw event names)?

Journeys reference event names through a trigger contract layer instead of binding directly to event names in /api/events. The reason: event schemas evolve. If you rename signup to user.signed_up in your SDK, every journey that hard-coded signup would silently break.

A trigger contract is:

  • Versioned — each contract has a versionAt timestamp; journeys pin to a specific version.
  • Field-mapped — the contract maps the event's natural fields (e.g. user_id, email) to journey-runtime fields (endUserId, recipientEmail).
  • Workspace-scoped — same PROJ#<projectKey> scoping as everything else.

List trigger contracts

GET/api/journey-triggers

List all trigger contracts in the workspace.

[
  {
    "id": "trig-signup",
    "projectKey": "pk_live_xyz",
    "name": "User signed up",
    "eventName": "user.signed_up",
    "subjectFieldPath": "endUserId",
    "recipientEmailFieldPath": "email",
    "versionAt": "2026-04-15T12:00:00.000Z"
  }
]

Create a trigger contract

POST/api/journey-triggers

Create a new trigger contract.

ParameterTypeDescription
namerequiredstringDisplay name.
eventNamerequiredstringExact event name from your SDK / snippet (e.g. 'user.signed_up').
subjectFieldPathrequiredstringDot-path to the end-user identity in the event payload. Defaults to 'endUserId'.
recipientEmailFieldPathstringDot-path to the recipient's email in the event payload. Required for email send steps; optional otherwise.
filterAudiencePredicatePre-filter applied at trigger time before the journey audience runs. Same predicate language as audiences.

Update / delete

PATCH/api/journey-triggers/[id]

Update a contract. Bumps versionAt — in-flight executions stay on their pinned version.

DELETE/api/journey-triggers/[id]

Delete the contract. Journeys referencing it will fail to start new executions.

How triggers fire

When /api/events receives an event matching a trigger contract's eventName:

  1. Resolve subject — extract the end-user ID from subjectFieldPath. If null/missing, the trigger is skipped (no journey starts).
  2. Resolve recipient email — extract from recipientEmailFieldPath if set.
  3. Apply pre-filter — if the trigger has a filter predicate, evaluate it against the subject. Skip if it doesn't match.
  4. For each journey in the workspace listening to this trigger:
    • Evaluate the journey's audience predicate.
    • If the audience matches, compute a deterministic execution name: <projectKey>__<journeyId>__v<version>__<endUserId>__<eventId>. The eventId is the event's stable ID; this ensures the same event firing twice produces only one execution.
    • Call sfn.StartExecution with the subject snapshot. Step Functions' 90-day execution-name uniqueness handles idempotency.

Per-projectKey trigger cache (60s TTL) keeps the hot path fast — typical added latency on /api/events is < 5ms.

Goal events

A journey's goal event is configured on the journey itself (not on the trigger). See goalEventName and goalEventWindowDays in the Journeys API.

When the goal event fires for a subject who has an active execution on a journey:

  1. The trigger evaluator sees the goal event come through /api/events.
  2. Goal attribution checks JEXEC# records: any execution with status: succeeded or status: running for this journey/subject within the attribution window is counted.
  3. Journey-level: bumps the JIMPACT# converted-exposed counter.
  4. Branch-level: for any adaptive branch the subject traversed, bumps the JBARM# goalEvents counter for the arm they were assigned to.

Censoring rules (per the council Stage-2 review):

  • failed and timed_out executions are censored — neither denominator nor numerator bumps.
  • Goal events outside the attribution window don't credit.
  • A subject can credit at most once per journey-version (deduplicated via JIMPACT# + JBARM# write-once-per-subject conditional).

See Calibrated Impact for the full estimand definition.

Holdout cohort tracking

When the trigger evaluator decides a subject is in the global holdout, it also persists a HOLDSUB#<journeyId>#v<n>#<endUserId> record. This is what enables calibrated impact's "what would have happened without the journey?" measurement:

  • Goal events from held-out subjects credit the holdout cohort.
  • Goal events from exposed subjects credit the exposed cohort.
  • The lift is (exposed_rate / holdout_rate) - 1.

The HOLDSUB# record TTLs to triggerTime + goalEventWindowDays + 24h grace so the table stays clean.

Common patterns

"Trigger when the user signs up — but only Pro tier"

// POST /api/journey-triggers
{
  "name": "Pro tier signup",
  "eventName": "user.signed_up",
  "subjectFieldPath": "endUserId",
  "recipientEmailFieldPath": "email",
  "filter": {
    "kind": "attribute",
    "fieldPath": "tier",
    "operator": "equals",
    "value": "pro"
  }
}

"Trigger on a custom event from your SDK"

If your SDK sends apex.track('checkout.completed', { userId: '...', email: '...', total: 99.00 }):

// POST /api/journey-triggers
{
  "name": "Checkout completed",
  "eventName": "checkout.completed",
  "subjectFieldPath": "userId",
  "recipientEmailFieldPath": "email"
}

The total field stays in the event payload and is available to journey templates as {{ event.total }}.

"Goal event isn't a tracked event yet"

You can use any event name, including ones you'll add later. The journey will simply never credit a goal until that event starts firing. Use this when you're authoring a journey before the SDK instrumentation lands.