# Build an escalating offer chain

**URL:** https://heroiclabs.com/docs/satori/guides/offer-chain/
**Summary:** Use a journey and live events to show a starter offer with a per-player countdown, then escalate the discount for anyone who doesn't buy.
**Keywords:** journeys, offers, monetization, offer chain, live events, countdown, hiro, economy
**Categories:** satori, journeys, guides, liveOps

---


# How to convert non-spenders with an escalating offer chain

This guide walks you through how to convert non-spenders into first-time buyers with an offer that improves the more they buy. You'll build an escalating chain: a starter offer with a personal countdown, and a discounted version when they buy the starter offer.

## Before you start

You'll need:

- A Satori instance and access to the Satori console
- [Nakama](../../../nakama/getting-started/install/docker/) and [Hiro](../../../hiro/concepts/getting-started/install/) installed

Read about [Journeys](../../concepts/journeys/) first if you aren't familiar with the feature.

## What you'll build

An escalating offer chain, assembled from five parts:

* a **journey** that drives the chain and decides when to escalate
* **live events** that carry each offer and its countdown
* **Hiro** economy definitions for the offers themselves
* a **custom Nakama server runtime in Go** that publishes the purchase signal advancing the chain
* **client code** that renders the timer. The examples here are Unity and C#. You can use any other Satori client library.

Satori and Hiro talk to each other through **Satori Personalizer**, registered on your Nakama server. It reads one feature flag per Hiro system, named `Hiro-[SystemName]`, so `Hiro-Economy` is where an economy override lands. Make sure your Nakama server is connected to Satori and the personalizer is registered, following [Set up personalizers](../../../hiro/guides/personalizer/setup-personalizers/).

Register it with economy event publishing turned on. The chain's entry condition filters on `purchaseCompletedCount`, and that computed property only exists if Hiro forwards economy events to Satori:

```go
systems.AddPersonalizer(hiro.NewSatoriPersonalizer(ctx,
    hiro.SatoriPersonalizerPublishEconomyEvents(),
))
```

Without `SatoriPersonalizerPublishEconomyEvents()`, `purchaseCompletedCount` stays at zero for everyone and the chain admits players who have already spent. See [Publishers](../../../hiro/concepts/publishers/#satoripersonalizer) for the full list of event options.

Here's the flow:

```text
Entry Point
 └─ Add to Live Event: starter_offer          [48h participation]
     └─ Send Message: "Starter pack available"
         └─ Bought, or window closed?
             ├─ Bought  → Revert starter_offer → Exit
             └─ Expired → Revert starter_offer
                           └─ Add to Live Event: discount_offer   [72h participation]
                               └─ Send Message: "Last chance, now 40% off"
                                   └─ Bought, or window closed?
                                       ├─ Bought  → Revert discount_offer → Exit
                                       └─ Expired → Revert discount_offer → Exit
```

## Build the offer chain

{{< pretitle "Step 1" >}}

### Define the offers in Hiro

Both offers live in your economy config as ordinary store items, disabled by default. The live events enable them one at a time.

Add them to `base-economy-dev1.json`:

```json
{
  "initialize_user": {
    "currencies": {
      "gems": 0
    }
  },
  "store_items": {
    "starter_offer": {
      "name": "Starter Pack",
      "description": "A one-time boost to get you going.",
      "cost": {
        "sku": "com.yourgame.starter_pack"
      },
      "reward": {
        "guaranteed": {
          "currencies": {
            "gems": {
              "min": 500,
              "max": 500
            }
          }
        }
      },
      "disabled": true
    },
    "discount_offer": {
      "name": "Starter Pack (40% off)",
      "description": "Last chance, now 40% off.",
      "cost": {
        "sku": "com.yourgame.starter_pack_discount"
      },
      "reward": {
        "guaranteed": {
          "currencies": {
            "gems": {
              "min": 500,
              "max": 500
            }
          }
        }
      },
      "disabled": true
    }
  },
  "placements": {},
  "donations": {}
}
```

Both items are `disabled`, so nobody sees them until a live event turns one on.

{{< pretitle "Step 2" >}}

### Publish a purchase signal per offer

To know when a player buys one of your offers, you need the server to publish that event. Hiro already publishes `purchaseCompleted` to Satori, but its [computed property](../../concepts/segmentation/understand-player-properties/#computed-properties) counts every purchase together, so `purchaseCompletedCount` can't tell you which offer converted. Send a distinct event per offer from the store reward hook:

```go
systems.GetEconomySystem().SetOnStoreItemReward(func(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, userID, sourceID string, source *hiro.EconomyConfigStoreItem, rewardConfig *hiro.EconomyConfigReward, reward *hiro.Reward) (*hiro.Reward, error) {
	var eventName string

	switch sourceID {
	case "starter_offer":
		eventName = "starterOfferPurchased"
	case "discount_offer":
		eventName = "discountOfferPurchased"
	default:
		return reward, nil
	}

	if err := nk.GetSatori().EventsPublish(ctx, userID, []*runtime.Event{{
		Name:      eventName,
		Timestamp: time.Now().Unix(),
	}}); err != nil {
		logger.Error("error publishing %s to Satori: %s", eventName, err.Error())
	}

	return reward, nil
})
```

Sending from the server keeps the signal authoritative. A client can't fake having bought the offer. It also keeps the whole change server side, without needing a client update.

Register the two events on the Satori Console so Satori accepts them:

1. Go to **Settings** > **Events**.
2. Select **Create New Event**.
3. For **Event Name** enter `starterOfferPurchased`.
4. Repeat for `discountOfferPurchased`.

Satori derives a computed property from each event name automatically, giving you `starterOfferPurchasedCount` and `discountOfferPurchasedCount` computed properties.

{{< pretitle "Step 3" >}}

### Create the journey

1. Go to **Journeys** and select **Create Journey**.
2. On the **Details** stage, enter `starter_offer_chain` for **Name** and describe what the chain does.
3. Select **Create**.
4. Leave **Rejoin cooldown period** as **Never rejoin**, so each player sees the chain only once.

Completing the wizard drops you into the flow editor with **Entry Point** already placed.

{{< screenshot
  src="/images/pages/satori/guides/offer-chain/create-journey.png"
  alt="The Scheduling stage of the Create Journey wizard"
  caption="Scheduling decides when the journey admits players and whether they ever return"
>}}

{{< pretitle "Step 4" >}}

### Define who enters

1. Select the **Entry Point** step with the label **Immediate** to open its configuration.
2. Select **Choose Conditions**.
3. Add the conditions limiting entry to engaged non-spenders:
    - `purchaseCompletedCount` less than 1
    - `sessionStartCount` more or equal than 3

Players who've never spent and have opened the game at least three times now qualify. Leaving the condition empty would admit everyone.

{{< screenshot
  src="/images/pages/satori/guides/offer-chain/journey-entry-conditions.png"
  alt="Journey's dashboard showing the entry conditions for the journey setup."
  caption="Journey's dashboard showing the entry conditions for the journey setup."
>}}

{{< pretitle "Step 5" >}}

### Present the first offer

Create the live event from inside the journey step rather than from the **Live Events** page. A journey supplies membership itself, so an event created this way doesn't need an audience.

1. Drag **Add to Live Event** from the palette onto the canvas.
2. Connect **Entry Point** to it.
3. Select the **Create new Live Event** icon (**+** sign) beside the live event picker.
4. Name the event `starter_offer` and leave **Audience(s)** empty.
5. Leave **Explicit Join** off, so journey enrollment takes effect without the client opting in.
6. Leave **Sticky Membership** off. It keeps a player in the event after eligibility ends, so it would leave the offer visible after the revert.
7. Set **Participation Duration (seconds, optional)** to `172800`, which is 48 hours.
8. Under **Feature Flags to override**, select `Hiro-Economy` and give it this value:

```json
{
  "store_items": {
    "starter_offer": {
      "name": "Starter Pack",
      "description": "A one-time boost to get you going.",
      "cost": { "sku": "com.yourgame.starter_pack" },
      "reward": {
        "guaranteed": {
          "currencies": { "gems": { "min": 500, "max": 500 } }
        }
      },
      "disabled": false
    }
  }
}
```
9. Set the live event's start and end times so that the run window fully contains the 48-hour participation window.
10. Save the event. It's selected on the step automatically.

`SatoriPersonalizer` reads one flag per Hiro system, named `Hiro-[SystemName]`. Economy config comes from `Hiro-Economy`, and a live event override outranks the flag's own variants, so the live event is all you need.

{{< note "important" "Create journey live events from the journey" >}}
Opening the **Live Events** page and creating an event there makes **Audience(s)** a required field, because audiences are normally what admit players. An event created from a journey step gets its membership from the step instead, so the field becomes optional. If you've already made an event with a placeholder audience, remove that audience before pointing a journey at it, or those players enter the chain's offer without entering the chain journey.
{{< /note >}}

{{< note "important" "Repeat the whole store item, not just the field you're changing" >}}
The override value is decoded onto the config Hiro already loaded. Store items you don't mention are left alone, but any item you mention is replaced in its entirety. An override containing only `{"store_items": {"starter_offer": {"disabled": false}}}` enables the item but wipes its name, cost, and rewards. Always repeat the full definition of every item an override touches.
{{< /note >}}

Optionally, add a message to the tier. This step needs a message template to already exist. If you don't have one, create it from the **Messages** section of the console first. See [Create message templates](../../concepts/player-messaging/create-message-templates/).

1. Drag **Send Message** onto the canvas and connect the live event step to it.
2. Select your offer **Template**.
3. Turn on **Persist in Identity Inbox** to keep the message after external delivery succeeds.

Players reaching these steps join `starter_offer`. Their participation clock starts, `SatoriPersonalizer` picks up the economy override, and the offer appears in the store.

{{< pretitle "Step 6" >}}

### Watch for the purchase or the expiry

The player waits here until one of two things happens: they buy the offer, or the wait you set runs out. Satori re-evaluates the condition every time that player sends an event, so a purchase advances them the moment it lands.

1. Drag **Conditional Branch** onto the canvas and connect the **Send Message** step to it. If you skipped the optional message, connect the live event step instead.
2. Add a rule and label its branch `Bought`:
    - `starterOfferPurchasedCount` greater than 0
3. Turn on **Wait for a condition to be true** and set it to 2 days (how long the offer will be available to the player).

A purchase sends the player down `Bought` as soon as the event is emitted, even if it arrives on the same pass that their window closes. Everyone else waits at this step until the timeout you set elapses, then leaves through `No Match`.

{{< screenshot
  src="/images/pages/satori/guides/offer-chain/condition-wait.png"
  alt="A conditional branch matching on a purchase or on the live event participation ending"
  caption="Two rules, evaluated in order, with the wait toggle holding the player until one matches"
>}}

{{< pretitle "Step 7" >}}

### Escalate when the window closes

1. Drag **Revert Step** onto the canvas and connect it to the **Bought**.
2. Select the `starter_offer` live event step as the step to undo.
3. Add an **Add to Live Event** step connected to the revert, and create `discount_offer` from it the same way. Enable the `discount_offer` store item.
4. (optional) Add a **Send Message** step announcing the discount.
5. Add a second **Conditional Branch** with the same two-rule shape, matching `discountOfferPurchasedCount` and the **Wait for a condition to be true** timeout.

{{< pretitle "Step 8" >}}

### Clear the offer on the way out

Live event membership outlives the journey. Leaving the journey doesn't remove it, so the player keeps seeing the offer. You have to take it away yourself. Add a **Revert Step** on every remaining path before the player reaches **Exit**:

1. Drag **Revert Step** onto the canvas.
2. Select the live event step it should undo.
3. Connect it to **Exit**.
4. Repeat for each remaining path out of the chain.

{{< screenshot
  src="/images/pages/satori/guides/offer-chain/completed-journey.png"
  alt="How the complete journey should look"
  caption="Complete Journey of a chain offer."
>}}

{{< pretitle "Step 9" >}}

### Render the offer and its countdown

This step shows the offer in your store and counts down to the moment it expires. The examples use the Hiro and Satori C# client libraries. The offer itself comes from Hiro's economy config. The countdown comes from the live event in Satori.

Start with the store. When the journey adds a player to the live event, `SatoriPersonalizer` folds that override into the economy config Hiro serves to that player. Call `RefreshStoreAsync` to fetch that updated config. The client's cached store predates the override, so the offer stays invisible until you refetch:

```csharp
private async void OnEnable()
{
    // Picks up whichever offer the journey has enabled for this player.
    await _economySystem.RefreshStoreAsync();
}
```

The countdown comes from the live event. Each returned event carries `ActiveParticipationEndTimeSec`, the moment this specific player's participation ends:

```csharp
var events = await client.GetLiveEventsAsync(session, names: new[] { "starter_offer", "discount_offer" });

foreach (var liveEvent in events.LiveEvents)
{
    // 0 means the player has no participation limit on this event.
    if (liveEvent.ActiveParticipationEndTimeSec == 0)
    {
        continue;
    }

    var expiresAt = DateTimeOffset.FromUnixTimeSeconds(liveEvent.ActiveParticipationEndTimeSec);
    var remaining = expiresAt - DateTimeOffset.UtcNow;

    offerTimerText.text = remaining > TimeSpan.Zero
        ? $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}"
        : "Expired";

    break;
}
```

{{< note "important" "Don't poll the server for the countdown" >}}
Fetch `ActiveParticipationEndTimeSec` once, then tick the remaining time down locally. Calling `GetLiveEventsAsync` every frame puts a request per frame per player on your Satori instance. Refetch from the server only when the player opens the store or returns to the foreground.
{{< /note >}}

Both offers are priced with a `sku`, so they're real-money purchases. Buy them through `UnityPurchasingSystem`, which triggers the platform's native purchase sheet, submits the receipt to Nakama for validation, and resolves only once the reward has been granted. See [Purchase a store item](../../../hiro/guides/unity/unity-iap/#purchase-a-store-item):

```csharp
IPurchaseResult result = await _purchasingSystem.BuyProductByIdAsync(itemId);
await _economySystem.RefreshAsync();
```

For a complete store UI, see [One Time Store Offers](../../../hiro/guides/personalizer/one-time-store-offers/).

{{< pretitle "Step 10" >}}

### Validate and enable

The editor validates the graph as you build. Fix anything it flags before saving:

- **Entry Point** connects to at least one step
- Every step except **Entry Point** has exactly one incoming connection
- Only condition steps have more than one outgoing path
- Every path eventually reaches **Exit**

Save the journey, then set its status to **Enabled**. If the start time is still ahead, it reports as **Scheduled** until the window opens, then flips to **Running** on its own.

## Confirm it's working

Open **Journey Analytics** for per-step counts. The two condition steps are the interesting ones: compare how many players leave through `Bought` against how many leave through `No Match` to see each tier's conversion. Total completions and average duration tell you how long the chain takes end to end.

To debug a specific player, open their identity page and check their journey history. It lists every step they've entered and when, which is the quickest way to answer why someone did or didn't get an offer.

If an offer isn't appearing, check the resolution chain before suspecting the journey. `GetFlagOverridesAsync` returns every source affecting a player's flags, so you can see whether the live event override is being applied and whether anything is taking precedence over it.

## Adapt the chain

**Add a third tier**: Repeat the revert-then-escalate pattern from the second `Expired` branch. Watch the total wait, because a chain that takes a week to resolve delays the player's next journey too.

**Run it on a cadence**: Set a **Rejoin cooldown period** instead of **Never rejoin**. Players become eligible again once it elapses, which turns the chain into a recurring campaign. The minimum cooldown is 60 seconds.

**Carry display data with the offer**: Give the live event a **Value**, a one-off JSON object the client reads alongside the countdown. Useful for banner art or copy that doesn't belong in your economy config.

## Things to watch out for

**Override completeness**: An override replaces every store item it names. Mentioning an item without repeating its full definition wipes the fields you left out. Keep override values complete.

**Run window coverage**: The run window has to contain the participation window. Participation expiry is only set when the player's join time plus the duration falls before the run ends. Otherwise participation lasts the full run and the client gets `0`, meaning no limit, so the countdown disappears.

**Dormant players**: Expiry doesn't wake a dormant player. A participation window closing isn't an event, so a player who stops playing escalates on their next session rather than the moment their window ends. The wait duration on the condition step is what eventually catches players who never return.

**Lifetime event counts**: `starterOfferPurchasedCount` never resets, so a player running the chain a second time after a rejoin cooldown looks like they already converted. For recurring chains, publish a season-specific event name from the reward hook.

**Fixed action types**: A step's action type is fixed once saved. To turn a **Send Message** step into an **Add to Live Event** step, delete it and add a new one.

## See next

- [Journeys](../../concepts/journeys/)
- [Live Events](../../concepts/live-events/)
- [One Time Store Offers](../../../hiro/guides/personalizer/one-time-store-offers/)
- [Set up personalizers](../../../hiro/guides/personalizer/setup-personalizers/)
