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

# Event-driven Journeys

> Design Journeys that respond to user behavior using Custom Events as entry rules, Wait Until conditions, exit rules, and message personalization sources.

Event-driven Journeys turn user behavior into automated messaging. Instead of waiting on the clock or relying on broad segment membership, the Journey reacts the moment a user performs a specific action. This guide shows you how to wire Custom Events into Journeys cleanly so your automation stays accurate as you scale.

This guide builds on [Custom Events](./custom-events) for sending events and [Personalize with Custom Events](./personalization-custom-event) for the full Liquid reference. The focus here is choosing the right event-to-Journey shape for the behavior you're modeling.

## Prerequisites

* A OneSignal app sending [Custom Events](./custom-events) via SDK or the [Create Custom Events API](/reference/create-custom-events)
* Familiarity with [Journey settings](./journeys-settings) and [Journey actions](./journeys-actions)
* One or more messaging channels configured (push, email, SMS, or in-app)

## Where Custom Events fit in a Journey

A single Custom Event can drive a Journey in three different places. Picking the right slot is the first design decision.

| If the event represents...                                               | Use it as...                                  | Reference                                                                                              |
| ------------------------------------------------------------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| The starting moment of an experience (signup, purchase, completion)      | Entry rule                                    | [Custom Event entry rules](./journeys-settings#custom-events)                                          |
| The expected next step inside an in-progress Journey                     | Wait Until condition                          | [Wait Until](./journeys-actions#wait-until)                                                            |
| A signal the user is no longer eligible (cancelled, refunded, opted out) | Exit rule                                     | [Exit when Custom Event condition occurs](./journeys-settings#exit-when-custom-event-condition-occurs) |
| A repeating triggering action (cart updated, plan changed)               | Both entry and exit, with the same event name | [Same-event entry and exit](./journeys-settings#exit-when-custom-event-condition-occurs)               |

Reusing the same event name on both entry and exit lets the latest trigger replace any in-progress instance — useful for Journeys that should always reflect the most recent state (e.g., a cart-recovery Journey that always operates on the latest `cart_updated` payload, not a stale one from yesterday).

<Note>
  Each Journey instance stores the events that triggered entry and matched any Wait Until step. Those events become available to message templates as `journey.first_event`, `journey.last_event`, and `journey.event.EVENT_NAME`. Each instance can store up to **100 event properties per user** (oldest dropped). See [Event property storage rules](./personalization-custom-event#event-property-storage-rules).
</Note>

## Design events for Journey use

The shape of your events determines how clean your Journey logic stays. A few rules of thumb pay off as automation grows.

### Naming

* Use **snake\_case** for event names and property keys so they read predictably.
* Use a **past-tense verb phrase** for the action: `lesson_completed`, `cart_updated`, `payment_failed`. Past tense is unambiguous; it describes something that already happened.
* Keep event names **stable**. Renaming an event after Journeys depend on it silently breaks every reference. Add new event names; don't rename old ones.
* Prefer **specific names over umbrella names**. `subscription_cancelled` and `subscription_paused` produce cleaner Journey logic than a single `subscription_changed` event with a `status` property.

### Property design

Include properties when you'll actually use them, for personalization, branching, or filtering. Skip properties just because they're available. Useful categories:

* **Identifiers** for matching across events: `order_id`, `workspace_id`, `course_id`. These are what Wait Until's [Event Matching](./journeys-actions#event-matching) keys on.
* **Display values** for templates: `product_name`, `price`, `image_url`. These render directly inside messages.
* **Branching values** for filters: `plan_tier`, `is_first_purchase`, `payment_method`.
* **Numeric values** for entry rule property filters: `amount`, `quantity`, `score`.

### Data types

Pick the right type up front. Journey filters and Liquid behave differently across types.

| Type                | Use when                                         | Example                    |
| ------------------- | ------------------------------------------------ | -------------------------- |
| String              | Categorical or identifier values                 | `"premium"`, `"prod_3342"` |
| Number              | Values you'll filter or compare                  | `49.99`, `12`              |
| Boolean             | Binary flags                                     | `true`, `false`            |
| Datetime (ISO 8601) | Time-of-event values used in Liquid date filters | `"2025-04-30T14:22:00Z"`   |
| Array               | Repeating items rendered with for-loops          | `["push", "email"]`        |

<Warning>
  Sending numbers as strings is the most common Journey filter bug. Numeric comparisons like `>`, `<`, and `>=` require numbers. A `price: "49.99"` will not match `price > 25`. Always send numbers as numbers.
</Warning>

### What to avoid

* **PII in properties.** Names, emails, phone numbers, payment details, and sensitive health or auth data should not travel inside event properties. Use anonymized internal identifiers instead.
* **Properties you don't reference.** They eat into the 2024-byte event payload limit and clutter analytics. See [Custom Event structure](./custom-events#custom-event-structure).
* **Inconsistent naming across events.** `productId` in one event and `product_id` in another will silently break Wait Until's Event Matching.
* **Firing the same event from both the SDK and a backend webhook.** A common Journey-doubling bug — the mobile SDK fires `purchase_completed` from the client *and* a Stripe webhook fires it server-side, so every purchase triggers two Journey entries. Pick a single source of truth, or deduplicate using the [`idempotency_key` field](./custom-events#custom-event-structure) on the API.

<Warning>
  Verify your event source map before turning a Journey live. The most common cause of "users are getting two of every message" is the same event firing from two places.
</Warning>

## Pattern 1: React in real time with an entry trigger

Use this pattern when the event itself is the moment you want to message the user. The Journey enters, sends one or two personalized messages immediately, and exits.

**Use it for:**

* Order or appointment confirmations
* Course or task completions
* Account creation, plan upgrades

### Example: Course completion celebration

A learning app fires `course_completed` whenever a user finishes a course. The Journey congratulates them and surfaces what to take next.

Event payload:

```json JSON theme={null}
{
  "name": "course_completed",
  "properties": {
    "course_id": "intro_to_python",
    "course_title": "Intro to Python",
    "completion_score": 92,
    "next_course_title": "Python for Data Analysis",
    "next_course_url": "https://example.com/courses/python-for-data-analysis"
  },
  "external_id": "user_12345",
  "timestamp": "2025-04-30T18:42:00Z"
}
```

<Steps>
  <Step title="Set the Journey entry rule">
    In **Journey settings**, under **Entry rules**, choose **Custom events** and set the event name to `course_completed`. Leave **Filter by property** unset so every completion enters the Journey. See [Custom event entry rules](./journeys-settings#custom-events).
  </Step>

  <Step title="Add a celebration push step">
    Reference the event payload directly with Liquid:

    ```liquid Liquid theme={null}
    You finished {{ journey.first_event.properties.course_title | default: "your course" }}! Score: {{ journey.first_event.properties.completion_score }}/100. Ready for what's next?
    ```
  </Step>

  <Step title="Add a follow-up email">
    Recommend the next course using the event's recommendation properties.

    **Subject:** Up next: `{{ journey.first_event.properties.next_course_title }}`

    **Body excerpt:**

    ```liquid Liquid theme={null}
    You earned {{ journey.first_event.properties.completion_score }}/100 in {{ journey.first_event.properties.course_title }}. Keep your streak going. Start {{ journey.first_event.properties.next_course_title }} now.
    ```

    Set the call-to-action button URL to `{{ journey.first_event.properties.next_course_url }}`.
  </Step>
</Steps>

**Why this works.** The event payload carries everything the messages need. There's no segment lookup, no follow-up event, and no branching. The Journey is a personalization wrapper around a single triggering moment.

## Pattern 2: Wait for a follow-up event with Event Matching

Use this pattern when the entry event sets the stage but the actual decision depends on whether a *second* event fires within a window. [Event Matching](./journeys-actions#event-matching) ties the wait event to the entry event so concurrent Journey instances stay scoped to the same workflow (the same order, the same workspace, the same conversation).

**Use it for:**

* Order tracking with a delivery SLA
* Trial-to-feature-activation funnels
* Quote-to-acceptance windows
* Any "did the expected next step happen?" question

### Example: Food delivery order tracking

A food delivery app enters users into an order Journey when `order_placed` fires. If `order_delivered` fires within 90 minutes for the same `order_id`, the Journey sends a review prompt. Otherwise, it sends a delay-handling support message.

Entry event (sent when the user places the order):

```json JSON theme={null}
{
  "name": "order_placed",
  "properties": {
    "order_id": "ord_88412",
    "restaurant_name": "Marcello's Pizzeria",
    "estimated_delivery_minutes": 45
  },
  "external_id": "user_12345"
}
```

Wait Until event (sent by the courier system when delivery is confirmed):

```json JSON theme={null}
{
  "name": "order_delivered",
  "properties": {
    "order_id": "ord_88412"
  },
  "external_id": "user_12345"
}
```

<Steps>
  <Step title="Set the entry rule">
    **Custom events** entry rule on `order_placed`. No property filter, since every order should enter the Journey.
  </Step>

  <Step title="Add a Wait Until on the delivery event">
    Add a [Wait Until](./journeys-actions#wait-until) step:

    * **Condition:** Custom Event `order_delivered`
    * **Event Matching:**
      * Trigger Event Property: `order_id`
      * Wait Event Property: `order_id`
    * **Expiration:** 90 minutes

    The Event Matching binding ensures that if the same user places two concurrent orders, each order's Journey instance only advances when *that order's* delivery event fires.
  </Step>

  <Step title="Configure the event branch">
    On the event branch (delivery happened in time), send a review prompt:

    ```liquid Liquid theme={null}
    How was your order from {{ journey.first_event.properties.restaurant_name }}? Tap to leave a review.
    ```

    You can use `journey.last_event` here if you want to reference properties on the matched `order_delivered` event. The full reference is in [Custom Event Liquid reference](./personalization-custom-event#custom-event-liquid-reference).
  </Step>

  <Step title="Configure the expiration branch">
    On the expiration branch (90 minutes elapsed without `order_delivered`), send a delay-handling message:

    ```liquid Liquid theme={null}
    Your order from {{ journey.first_event.properties.restaurant_name | default: "the restaurant" }} is taking longer than expected. Tap to track it or reach support.
    ```

    This branch references `journey.first_event` because the wait event never fired. `journey.last_event` would point to the same `order_placed` record in this case.
  </Step>
</Steps>

**Why Event Matching matters.** Without it, the Wait Until would advance on *any* `order_delivered` event for that user. If the user has two open orders, the first delivery would prematurely complete both Journey instances. Binding to a shared `order_id` keeps each instance scoped to its own context.

## Pattern 3: Wait for a threshold or milestone event

Use this pattern when the question is "did the user accumulate enough behavior?" Total spend, total sessions, total invitations sent. Wait Until matches discrete events, not aggregates, so the right way to model thresholds is to **fire a discrete event from your backend the moment a threshold is crossed**.

**Use it for:**

* Lifetime value tiers (silver, gold, platinum)
* Engagement milestones (10 workouts, 50 lessons, 100 transactions)
* Funded-account thresholds
* Streak or consistency achievements

### Example: 30-day VIP loyalty Journey

An ecommerce app enters new users into a 30-day loyalty Journey on `signup_completed`. When cumulative spending crosses a threshold, the backend fires a derived `loyalty_milestone_reached` event. The Journey waits up to 30 days for that event to land and routes accordingly.

Backend-fired milestone event (sent the first time the user crosses the threshold):

```json JSON theme={null}
{
  "name": "loyalty_milestone_reached",
  "properties": {
    "tier": "vip",
    "amount_spent_cents": 25000,
    "currency": "USD"
  },
  "external_id": "user_12345"
}
```

<Steps>
  <Step title="Send the milestone event from your backend">
    Track cumulative spend in your own data store. Each time a `purchase` succeeds, recompute the running total. The first time it crosses the threshold, fire `loyalty_milestone_reached` to OneSignal.

    Fire this event **once per user per milestone**. Use the [`idempotency_key` field](./custom-events#custom-event-structure) to deduplicate if your retry logic could re-fire it.
  </Step>

  <Step title="Set the entry rule">
    **Custom events** entry rule on `signup_completed`. Every new user enters the Journey at signup; the wait then determines what they receive.

    <Note>
      With default re-entry rules, only users who fire `signup_completed` *after* the Journey is published will enter — existing users won't be retroactively enrolled. To run the Journey for your existing user base, fire a one-time `loyalty_journey_eligible` event from your backend or use a segment-based entry rule on `Last Session > 0` instead.
    </Note>
  </Step>

  <Step title="Add a Wait Until on the milestone">
    Wait Until step:

    * **Condition:** Custom Event `loyalty_milestone_reached`
    * **Expiration:** 30 days
    * No Event Matching needed. The milestone fires once per user per signup, so there is no instance ambiguity to resolve.
  </Step>

  <Step title="Configure the event branch">
    On the event branch, send a "Welcome to VIP" email and a celebratory push using the milestone event's properties:

    ```liquid Liquid theme={null}
    You just unlocked {{ journey.last_event.properties.tier | upcase | default: "VIP" }} status. Your perks are live now.
    ```
  </Step>

  <Step title="Configure the expiration branch">
    On the expiration branch (30 days elapsed without the milestone), send a motivating nudge. Two ways to surface progress:

    **Option A — generic copy (simplest):** Don't reference a specific dollar amount, since the cumulative spend lives in your backend rather than the Journey instance:

    ```liquid Liquid theme={null}
    You're closer to VIP than you think. See what's waiting for you.
    ```

    **Option B — Tag-driven progress (recommended for personalization):** Have your backend write a `cents_to_next_tier` Tag on each purchase. Reference the Tag directly in Liquid — no extra Journey machinery needed:

    ```liquid Liquid theme={null}
    You're {{ cents_to_next_tier | divided_by: 100 | money_with_currency }} away from VIP. See what's waiting for you.
    ```

    The Tag-based approach is simpler than firing a second derived event and adding another Wait Until — Tags are evaluated at send time, so the value is always current. Fire a `loyalty_progress_check` event only if you specifically need the progress check to *trigger* a Journey step rather than just personalize copy.
  </Step>
</Steps>

<Tip>
  **Why a derived event beats raw event filtering.** You could try to model thresholds with raw `purchase` events and an entry rule property filter, but that only catches *single purchases* above a threshold, not *cumulative* spending. Cumulative state lives in your backend. Fire a discrete event the moment the state crosses a meaningful boundary, and let the Journey react to that boundary event.
</Tip>

## Personalize messages with event properties

Every Journey instance stores the events that drove its entry and any Wait Until matches. Reference them in templates with Liquid:

```liquid Liquid theme={null}
{{ journey.first_event.properties.product_name }}
{{ journey.last_event.properties.amount }}
{{ journey.event.signup_completed.properties.plan }}
```

* `journey.first_event` is the entry-triggering event.
* `journey.last_event` is the most recently matched event (entry or any Wait Until).
* `journey.event.EVENT_NAME` returns the most recent matched event with a specific name.

For nested property access, default fallbacks, arrays, and the full reference, see the canonical [Custom Event Liquid reference](./personalization-custom-event#custom-event-liquid-reference).

<Card title="Personalize with Custom Events" icon="wand-magic-sparkles" href="./personalization-custom-event">
  Full Liquid reference, nested property access, for-loops over event arrays, and worked examples.
</Card>

## Optimization checklist

Work through this list as you ship more event-driven Journeys.

* **Use Event Matching wherever the same user can have concurrent instances.** Orders, conversations, surveys, support tickets. Bind Wait Until events to a shared identifier so each instance advances independently. See [Event Matching](./journeys-actions#event-matching).
* **Always pair Wait Until with a meaningful expiration.** A wait without a thoughtful expiration leaves users in limbo. Decide what message, or no message, should fire if the expected event never arrives.
* **Reuse the same event for entry and exit on repeating signals.** Cart updates, address changes, plan changes. When a triggering event repeats, set the same event name on the entry rule and the exit rule. The user exits the current instance and re-enters with the latest properties. See [Exit when Custom Event condition occurs](./journeys-settings#exit-when-custom-event-condition-occurs).
* **Watch the 100-property storage cap.** Each Journey instance stores at most 100 event properties across all matched events. Long-running Journeys with many Wait Until matches can hit the cap.
* **Stay under the 2024-byte event payload limit.** Strip properties you won't reference. Move large content like HTML, base64 images, or full carts behind a deep link or a backend lookup.
* **Throttle high-velocity events at the source.** Backfills, retry storms, and dual SDK + backend writes can flood Journey instances and trigger duplicate messages. Fire critical events from a single source of truth and use the [`idempotency_key` field](./custom-events#custom-event-structure) to deduplicate retries. Watch out especially for analytics pipelines that replay events on schema changes.
* **Use Custom Events to drive in-app messages and SMS too, not just push and email.** A `feature_unlocked` event can target the next-session IAM that surfaces the new feature, and a high-intent event like `payment_failed` is the right moment for SMS to consenting users. See [In-app message triggers](./in-app-messages-setup#triggers).
* **Verify events with the Custom Events Activity feed before activating.** The dashboard's [Event Activity tab](./custom-events#event-activity-tab) shows events as they land. Use it to confirm property names, types, and values match your Journey configuration before turning the Journey live.
* **Check Journey entry logs when events land but no Journey runs.** If your Custom Events Activity feed shows events arriving but the Journey isn't entering users, see [Journey analytics](./journeys-analytics) for entry/exit logs that show which entry rule a user matched (or why they didn't).

## FAQ

### When should I use a Custom Event entry rule vs. a segment-based entry rule?

Use a Custom Event entry rule when the trigger is a discrete user action (the user just placed an order). Use a segment-based entry rule when the trigger is a state (the user has been inactive for 7 days). You cannot combine the two on the same Journey, but you can still reference Custom Events inside a segment-based Journey via Wait Until steps.

### Can a single user be in the same Journey multiple times?

Yes. Custom Event entry rules allow concurrent instances by default. Each `order_placed` for the same user starts its own instance with its own stored event. To prevent overlap, either filter the entry rule by a property or set the same event name as both the entry rule and the exit rule, which exits the current instance before the new one begins. See the [Custom Event entry rule warning](./journeys-settings#custom-events).

### Why is my Wait Until step matching the wrong event?

A Wait Until without [Event Matching](./journeys-actions#event-matching) advances on *any* event of the configured name for that user. If the user has multiple concurrent Journey instances or fires the event from an unrelated workflow, the Wait Until completes prematurely. Bind it to a shared property like `order_id` or `workspace_id` so each instance scopes to its own context.

### How do I model thresholds like "spent more than \$100"?

For per-event thresholds (this single purchase was above $100), use a property filter on the entry rule. For cumulative thresholds (lifetime spend crossed $100), compute the cumulative state in your backend and fire a derived event the moment the threshold is crossed. Wait Until matches discrete events, not aggregates.

### What happens to the stored event when a user exits a Journey?

The stored event is cleared. If the user re-enters the same Journey, a fresh `journey.first_event` is captured from the new entry trigger. See [Event property storage rules](./personalization-custom-event#event-property-storage-rules).

### Do I need to send Custom Events from a mobile or web SDK?

No. You can send Custom Events from any source: SDKs, the [Create Custom Events API](/reference/create-custom-events), or one of OneSignal's [data integrations](./custom-events#integrations). All sources are treated identically inside Journeys.

## Related pages

<Columns cols={2}>
  <Card title="Custom Events" icon="bolt" href="./custom-events">
    Send events from your SDK or REST API, configure retention, and verify events in the dashboard.
  </Card>

  <Card title="Personalize with Custom Events" icon="wand-magic-sparkles" href="./personalization-custom-event">
    Full Liquid reference for `journey.first_event`, `journey.last_event`, and `journey.event.EVENT_NAME`.
  </Card>

  <Card title="Journey actions" icon="code-branch" href="./journeys-actions">
    Wait, Wait Until, Time Window, Yes/No branch, Split Branch, and Tag User reference.
  </Card>

  <Card title="Journey settings" icon="gear" href="./journeys-settings">
    Configure entry rules, exit rules, re-entry, and scheduling.
  </Card>

  <Card title="Welcome Journey: Mobile gaming" icon="gamepad" href="./welcome-journey-mobile-gaming">
    Worked example of a 7-day onboarding Journey using a `daily_session` Wait Until.
  </Card>

  <Card title="Abandoned cart tutorial" icon="cart-shopping" href="./abandoned-cart">
    End-to-end ecommerce Journey using cart events for entry, exit, and personalization.
  </Card>
</Columns>
