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

# Abandoned checkout

> The useful signal is not an event — it is an event that never arrived. Turn that silence into a reminder, and let an enricher decide at send time whether it should still go out.

## TL;DR

Maison Verdure sells plants and garden tools online. Two baskets in three are filled and then left. They want to come back **once**, kindly, and never speak to someone who has already paid — or whose basket no longer exists.

| Need                                         | How you do it in Notifizz                                                                              |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Trigger on something that **did not** happen | A **Reminder** synthetic event: `cart.updated` happens, `checkout.completed` does not, delay `1 h`     |
| Catch them on the screen, then in the inbox  | Two messages with their delay, inside the campaign — web push, then email a day later                  |
| Go quiet the moment they pay                 | Objective = `checkout.completed`, plus *ends when → Objective is reached*                              |
| Never show a basket that is no longer true   | The enricher reads the basket **at send time** — a paid or emptied basket produces no recipient at all |

## The situation

The Maison Verdure backend is loud about what happens. It emits `cart.updated` every time someone adds a shrub, `checkout.completed` when the payment clears, `order.shipped` when the van leaves. Every one of those is an action.

Abandonment is not an action. Nobody clicks *I give up*. The customer closes the tab, the phone rings, the evening happens. The only trace is a basket that stopped moving.

## Why the obvious approach fails

There is no `cart.abandoned` event, and writing one is more work than it sounds. You need a timer per basket, armed on every update, disarmed on payment, surviving a deploy and a restart, not firing twice when two workers pick the same row. That is a small scheduling system living inside a shop — code to write, test, monitor and page someone about at 3 a.m.

And it puts a marketing decision in a release. "Make it 90 minutes instead of 60" becomes a ticket.

## 1. Make the silence an event

Create a **Reminder** synthetic event from **Events → New event**:

* **If this event happens…** — `cart.updated`
* **…but none of these happen in time** — `checkout.completed`
* **Delay** — `1 h`
* **Group by** — `cartId`

Read it as the dashboard states it back to you: *if `cart.updated` happens but `checkout.completed` does not within 1 h, fire — for each `cartId`.*

Notifizz arms a timer when the trigger arrives, re-arms it on the next update, cancels it on the reset, and emits an event only if the hour runs out in silence. No timer table on your side, no release to change the delay.

<Note>
  The reminder is an **extra timer laid on top**, not a replacement. `cart.updated` and `checkout.completed` keep flowing through your other campaigns exactly as before — the reminder observes them, it does not consume them. Nothing you already send changes.
</Note>

## 2. Two messages, two channels

The campaign subscribes to that reminder event and holds two messages:

| When        | Channel  | Message                                 |
| ----------- | -------- | --------------------------------------- |
| Immediately | Web push | Your basket is waiting                  |
| +24 h       | Email    | Still interested? Here is what you left |

Web push first, because it lands on the device even with the tab closed and costs nothing to ignore. Email a day later, because a push that arrived during a meeting is gone.

<Warning>
  The email is **not a duplicate of the push** — it is the coverage. On iPhone and iPad, web push only exists for a product installed as a PWA (Add to Home Screen, iOS 16.4 or later); a regular Safari tab cannot receive one. For a mobile-heavy shop, a push-only sequence silently skips a large share of the audience. See [platforms & privacy](/docs/sdks/web-push/platform-support).
</Warning>

This is a `Product` campaign: promotional mail, so it carries a one-click unsubscribe and honours the person's [notification preferences](/docs/concepts/notification-preferences). A basket reminder is not a receipt — do not file it as transactional to dodge the opt-out.

## 3. The enricher makes the basket honest

Here is the part that decides whether this campaign is helpful or embarrassing.

The email leaves 25 hours after the last basket update. In that window a plant sells out, a price changes, the customer removes half the order, or they pay from a different device. An email built from the payload captured yesterday shows a basket that no longer exists — with a total that no longer applies.

Notifizz never stores that basket. The orchestrator runs **at each step of the sequence**, and the [enricher](/docs/why/enrichers) it calls reads your database at that moment. The push shows the basket as it was an hour ago; the email shows the basket as it is this morning. Same campaign, same code, two truthful messages.

The same call is the guardrail. If the basket comes back paid, empty, or gone, the orchestrator returns an empty list and **nothing is sent** — the run is recorded in the Outbox as **No recipients** and no message is created.

<Accordion title="For the developer: the enricher and the orchestrator" icon="code">
  The enricher is registered once, in your backend, with the Node SDK:

  ```ts theme={null}
  notifizz.enricher("fetchCart", {
    description: "The basket exactly as it stands right now",
    input: z.object({ cartId: z.string() }),
    output: z.object({
      userId: z.string(),
      email: z.string(),
      status: z.enum(["open", "paid", "cancelled"]),
      total: z.number(),
      items: z.array(z.object({ name: z.string(), price: z.number(), inStock: z.boolean() })),
    }),
    cache: false, // always live — a cached basket is a wrong basket
    handler: async ({ cartId }) => toCartView(await db.carts.findOne({ id: cartId })),
  });
  ```

  `cache: false` matters here. The default caches an enricher's answer for an hour, which is right for a user profile and wrong for a basket you are about to quote in an email.

  The orchestrator — AI-generated from the campaign description, then yours to review — reads the original payload off the reminder and asks for the live version:

  ```ts theme={null}
  export const orchestrate = async (event: Event, campaign: Campaign, sdk: Sdk): Promise<Recipient[]> => {
    const trigger = event.properties.triggerEvent;          // the cart.updated payload that armed the timer
    const cart = await sdk.enrichWith("fetchCart", { cartId: trigger.cartId });

    // Paid, cancelled or emptied since the timer was armed — say nothing.
    if (cart.status !== "open" || cart.items.length === 0) return [];

    return [{
      id: cart.userId,
      email: cart.email,
      items: cart.items.filter((i) => i.inStock),
      total: cart.total,
    }];
  };
  ```

  Filtering on `inStock` is the small detail that keeps the message credible: you never advertise the one item the customer can no longer buy.
</Accordion>

## 4. Stop on the payment, not on the open

On the **Recipients** card, set the objective to `checkout.completed` and **and ends when → the objective is reached**.

Someone who pays after the push never receives the email. The remaining messages of **their** sequence are cancelled; everybody else keeps theirs.

That gives Maison Verdure two independent guards, and they catch different things:

| Guard                    | Catches                                                                                                                           |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| The objective            | A payment, matched to the person who received the campaign                                                                        |
| The empty recipient list | Everything the objective cannot see — a basket emptied by hand, an order placed over the phone, an item pulled from the catalogue |

Belt and braces, and the braces cost one `if`.

## What Maison Verdure sets up

* `cart.updated`, carrying `cartId` and `userId`
* `checkout.completed`, carrying the **same** `cartId`, plus a canonical identity (`id` or `email`) so the objective can be credited
* One enricher, `fetchCart`, returning the live basket
* Web push: the environment key generated in *Settings → Web push*, and `notifizz-sw.js` served at the root of the shop's origin

No customer data moves into Notifizz. The basket is read at send time and forgotten.

## Rehearsing it before it is Live

Open the reminder event: the **Scheduled reminders** panel lists the timers armed in your non-production environments, with when each one fires — production timers are never shown here. **Fire now** triggers one immediately instead of waiting an hour, **Cancel** disarms it; both are test actions, available on non-production environments only. Whatever you fire lands in the Outbox like a normal run, so you can fill a test basket, fire the reminder, and read the message that comes out — then pay, and watch the second one not come out.

## Reading the results

The campaign's **Sends** bar shows fewer emails than reminders fired. That is the campaign working:

* People who paid between the push and the email sit in their own layer — a success, not a warning.
* Runs where the enricher found nothing left to talk about appear in the Outbox as **No recipients**, with the enriched payload attached so you can see what came back.

A basket reminder that sends to everyone who entered it is a basket reminder that is talking to customers who already paid.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The reminder fires even though the customer paid">
    The reset never matched. Either `checkout.completed` is not listed in **Reset events**, or it does not carry the same **Grouped by** value as the trigger. The timer is keyed on that value: a `cart.updated` grouped by `cartId` is only cancelled by an event carrying that exact `cartId`. Check the payload of a real `checkout.completed` in the events catalogue.
  </Accordion>

  <Accordion title="The same customer gets reminded twice in one session">
    `cart.updated` re-arms the timer on every change, so the hour runs from the **last** basket activity. That is usually what you want — someone still adding items is not abandoning anything. If you would rather count from the moment the basket was born, trigger the reminder on `cart.created` instead: it fires once per basket, whatever happens next.
  </Accordion>

  <Accordion title="Nobody receives the web push">
    Two usual causes, both visible in *Settings → Web push*. The environment has no key pair generated, or `notifizz-sw.js` is not served at the root of your origin — the widget verifies this from a real browser and reports it per environment. In either case the opt-in status stays `unavailable` and no subscription is ever created, so there is nothing to deliver to.
  </Accordion>

  <Accordion title="Far fewer emails than pushes">
    Expected. Three populations drop out between the two messages: those who paid (removed by the objective), those whose basket came back empty or paid from the enricher (no recipient), and those who opted out of promotional mail. The Sends bar separates them — the first two are wins, and only the third is worth acting on.
  </Accordion>

  <Accordion title="The email shows an item that is out of stock">
    The enricher is being served from cache. The default policy keeps an answer for an hour, keyed on the parameters you pass, so a basket fetched for the push can still be returned for the email. Declare `cache: false` on the enricher that reads the basket — it is the one place where a cache hit is a bug.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Synthetic events" icon="wand-magic-sparkles" href="/docs/concepts/synthetic-events">
    The four types Notifizz generates for you, and what each one puts in `event.properties`.
  </Card>

  <Card title="Objectives & conversions" icon="bullseye" href="/docs/concepts/objectives">
    What counts as done, how it is credited, and how it stops a sequence in flight.
  </Card>

  <Card title="Why enrichers" icon="puzzle-piece" href="/docs/why/enrichers">
    Why Notifizz reads your data at send time instead of holding a copy of it.
  </Card>

  <Card title="Migration deadline" icon="building-columns" href="/docs/use-cases/migration-deadline">
    The other side of the coin: one entry per person, five reminders, stopped on the act.
  </Card>
</CardGroup>
