# Recover abandoned carts with events and journeys

:::summary Quick answer
To recover abandoned carts with events and journeys, record a `checkout.started` event when a shopper leaves a phone number at checkout, and an `order.confirmed` event when they pay. A Flownally journey starts on the checkout event, waits for the order, and sends a WhatsApp reminder only if the wait times out. Your store reports what happened, and the journey owns the timing and the wording.

**Record facts in the store, decide timing in the journey.**
:::

This guide is the build companion to [How to recover abandoned carts with WhatsApp, SMS, and customer messaging](https://www.flownally.com/blog/how-to-recover-abandoned-carts-with-whatsapp-sms-and-customer-messaging), which covers timing and message ideas.

## How does abandoned cart recovery work with events?

A reminder that arrives after the customer has paid is worse than no reminder. **The order event is what stops the reminder, so record both events.**

**Checkout started → Journey waits for the order → No order in time → Reminder sent**

- **The shopper enters a phone number.** Your store finds or creates the Flownally contact for that number and records `checkout.started` for it.
- **The journey starts a run.** An active journey that starts on `checkout.started` waits for `order.confirmed`, for example for one hour.
- **The shopper pays.** Your store records `order.confirmed`, the waiting run continues on its event path, and no reminder goes out.
- **The wait times out.** The journey sends a WhatsApp template with the cart details and a link back to checkout.

:::callout Use the same contact for both events
The journey waits for events on the contact that started the run. Find the contact by phone number the same way for both events, or the order won't stop the reminder.
:::

## Create the abandoned cart event types

Create each event type once with [Create event type](/api/reference/events#create-event-type). Check [List event types](/api/reference/events#list-event-types) first, because a name the workspace already uses fails with `409`.

:::code Create the event types
```bash
curl -X POST "https://api.flownally.com/v1/event-types" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "checkout.started",
    "description": "Shopper entered a phone number at checkout.",
    "payloadSchema": {
      "cartId": { "type": "string" },
      "itemName": { "type": "string" },
      "itemCount": { "type": "number" },
      "cartTotal": { "type": "number" },
      "currency": { "type": "string" },
      "checkoutUrl": { "type": "string" }
    }
  }'

curl -X POST "https://api.flownally.com/v1/event-types" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.confirmed",
    "description": "Store confirmed a paid order.",
    "payloadSchema": {
      "orderNumber": { "type": "string" },
      "firstName": { "type": "string" },
      "total": { "type": "number" },
      "currency": { "type": "string" }
    }
  }'
```

```js
const eventTypes = [
  {
    name: "checkout.started",
    description: "Shopper entered a phone number at checkout.",
    payloadSchema: {
      cartId: { type: "string" },
      itemName: { type: "string" },
      itemCount: { type: "number" },
      cartTotal: { type: "number" },
      currency: { type: "string" },
      checkoutUrl: { type: "string" },
    },
  },
  {
    name: "order.confirmed",
    description: "Store confirmed a paid order.",
    payloadSchema: {
      orderNumber: { type: "string" },
      firstName: { type: "string" },
      total: { type: "number" },
      currency: { type: "string" },
    },
  },
];

for (const eventType of eventTypes) {
  const response = await fetch("https://api.flownally.com/v1/event-types", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(eventType),
  });
  if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);
}
```

```python
import os

import requests

event_types = [
    {
        "name": "checkout.started",
        "description": "Shopper entered a phone number at checkout.",
        "payloadSchema": {
            "cartId": {"type": "string"},
            "itemName": {"type": "string"},
            "itemCount": {"type": "number"},
            "cartTotal": {"type": "number"},
            "currency": {"type": "string"},
            "checkoutUrl": {"type": "string"},
        },
    },
    {
        "name": "order.confirmed",
        "description": "Store confirmed a paid order.",
        "payloadSchema": {
            "orderNumber": {"type": "string"},
            "firstName": {"type": "string"},
            "total": {"type": "number"},
            "currency": {"type": "string"},
        },
    },
]

for event_type in event_types:
    response = requests.post(
        "https://api.flownally.com/v1/event-types",
        headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
        json=event_type,
    )
    response.raise_for_status()
```

```php
$eventTypes = [
    [
        'name' => 'checkout.started',
        'description' => 'Shopper entered a phone number at checkout.',
        'payloadSchema' => [
            'cartId' => ['type' => 'string'],
            'itemName' => ['type' => 'string'],
            'itemCount' => ['type' => 'number'],
            'cartTotal' => ['type' => 'number'],
            'currency' => ['type' => 'string'],
            'checkoutUrl' => ['type' => 'string'],
        ],
    ],
    [
        'name' => 'order.confirmed',
        'description' => 'Store confirmed a paid order.',
        'payloadSchema' => [
            'orderNumber' => ['type' => 'string'],
            'firstName' => ['type' => 'string'],
            'total' => ['type' => 'number'],
            'currency' => ['type' => 'string'],
        ],
    ],
];

$client = new GuzzleHttp\Client();

foreach ($eventTypes as $eventType) {
    $client->post('https://api.flownally.com/v1/event-types', [
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
        'json' => $eventType,
    ]);
}
```
:::

If you already send order updates as events, as in [Send WooCommerce order updates on WhatsApp](/api/woocommerce-order-updates) or [Send Shopify order updates on WhatsApp](/api/shopify-order-updates), reuse that `order.confirmed` type.

`payloadSchema` is a simplified format, not a JSON Schema document. It lists payload fields by name, and each field is an object with these keys:

- **`type`.** One of `string`, `number`, `integer`, `boolean` or `null`.
- **`format`.** `date` or `date-time`, for `string` fields only.
- **`nullable`.** `true` also accepts `null`.
- **`required`.** `false` makes the field optional, because fields are required by default.

Flownally rejects an event whose payload doesn't match these fields. Payload fields that aren't listed are accepted, but **declare every field the reminder uses**, so the journey can offer it as a template variable.

## Find or create the contact by phone number

Events belong to a contact. Two contacts for one shopper split their history, and the order event can land on the wrong one.

Look the contact up by its phone number in the predefined `cf_phone` custom field, and create it only when the search finds nothing. Format the number as E.164 first, as shown in [Sync store customers and phone numbers](/api/sync-store-customers).

:::code Find or create the contact
```js
// flownally.js
const FLOWNALLY_API = "https://api.flownally.com/v1";

export async function flownally(path, body) {
  const response = await fetch(`${FLOWNALLY_API}${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) {
    throw Object.assign(new Error(`Flownally ${path} returned ${response.status}`), { status: response.status });
  }
  return response.json();
}

export async function findOrCreateContact(phone, name) {
  const { contacts } = await flownally("/contacts:search", {
    query: {
      match: "all",
      conditions: [
        { type: "customFieldText", fieldId: "cf_phone", operator: "equals", value: phone },
        { type: "archiveStatus", status: "all" },
      ],
    },
    pageSize: 1,
  });
  if (contacts.length > 0) return contacts[0].id;

  const contact = await flownally("/contacts", { name, phone });
  return contact.id;
}

export async function recordEvent(contactId, event) {
  try {
    await flownally(`/contacts/${contactId}/events`, event);
  } catch (error) {
    // 409 means an event with this idempotency key was already recorded.
    if (error.status !== 409) throw error;
  }
}
```

```python
# flownally.py
import os

import requests

FLOWNALLY_API = "https://api.flownally.com/v1"


def flownally(path, body):
    response = requests.post(
        f"{FLOWNALLY_API}{path}",
        headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
        json=body,
    )
    response.raise_for_status()
    return response.json()


def find_or_create_contact(phone, name):
    contacts = flownally("/contacts:search", {
        "query": {
            "match": "all",
            "conditions": [
                {"type": "customFieldText", "fieldId": "cf_phone", "operator": "equals", "value": phone},
                {"type": "archiveStatus", "status": "all"},
            ],
        },
        "pageSize": 1,
    })["contacts"]
    if contacts:
        return contacts[0]["id"]

    return flownally("/contacts", {"name": name, "phone": phone})["id"]


def record_event(contact_id, event):
    try:
        flownally(f"/contacts/{contact_id}/events", event)
    except requests.HTTPError as error:
        # 409 means an event with this idempotency key was already recorded.
        if error.response.status_code != 409:
            raise
```

```php
<?php
// flownally.php
const FLOWNALLY_API = 'https://api.flownally.com/v1';

function flownally(string $path, array $body): array
{
    $client = new GuzzleHttp\Client();
    $response = $client->post(FLOWNALLY_API . $path, [
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
        'json' => $body,
    ]);
    return json_decode((string) $response->getBody(), true);
}

function findOrCreateContact(string $phone, string $name): string
{
    $contacts = flownally('/contacts:search', [
        'query' => [
            'match' => 'all',
            'conditions' => [
                ['type' => 'customFieldText', 'fieldId' => 'cf_phone', 'operator' => 'equals', 'value' => $phone],
                ['type' => 'archiveStatus', 'status' => 'all'],
            ],
        ],
        'pageSize' => 1,
    ])['contacts'];
    if ($contacts) {
        return $contacts[0]['id'];
    }

    return flownally('/contacts', ['name' => $name, 'phone' => $phone])['id'];
}

function recordEvent(string $contactId, array $event): void
{
    try {
        flownally("/contacts/{$contactId}/events", $event);
    } catch (GuzzleHttp\Exception\ClientException $e) {
        // 409 means an event with this idempotency key was already recorded.
        if ($e->getResponse()->getStatusCode() !== 409) {
            throw $e;
        }
    }
}
```
:::

The `archiveStatus` condition includes archived contacts in the search, so a returning shopper isn't created twice.

## Record the checkout.started event

Record the event once the shopper has entered a phone number. Use [Create contact event](/api/reference/events#create-contact-event) (`POST /contacts/{contactId}/events`), because `POST /events` is deprecated.

:::code Record checkout.started
```js
import { findOrCreateContact, recordEvent } from "./flownally.js";

// checkout is your store's checkout or cart, with the phone number already in E.164.
export async function recordCheckoutStarted(checkout) {
  const contactId = await findOrCreateContact(checkout.phone, checkout.customerName);

  await recordEvent(contactId, {
    name: "checkout.started",
    payload: {
      cartId: checkout.id,
      itemName: checkout.items[0].title,
      itemCount: checkout.items.length,
      cartTotal: checkout.total,
      currency: checkout.currency,
      checkoutUrl: checkout.recoveryUrl,
    },
    idempotencyKey: `checkout-started-${checkout.id}`,
  });
}
```

```python
from flownally import find_or_create_contact, record_event


# checkout is your store's checkout or cart, with the phone number already in E.164.
def record_checkout_started(checkout):
    contact_id = find_or_create_contact(checkout["phone"], checkout["customer_name"])

    record_event(contact_id, {
        "name": "checkout.started",
        "payload": {
            "cartId": checkout["id"],
            "itemName": checkout["items"][0]["title"],
            "itemCount": len(checkout["items"]),
            "cartTotal": checkout["total"],
            "currency": checkout["currency"],
            "checkoutUrl": checkout["recovery_url"],
        },
        "idempotencyKey": f"checkout-started-{checkout['id']}",
    })
```

```php
require_once 'flownally.php';

// $checkout is your store's checkout or cart, with the phone number already in E.164.
function recordCheckoutStarted(array $checkout): void
{
    $contactId = findOrCreateContact($checkout['phone'], $checkout['customerName']);

    recordEvent($contactId, [
        'name' => 'checkout.started',
        'payload' => [
            'cartId' => $checkout['id'],
            'itemName' => $checkout['items'][0]['title'],
            'itemCount' => count($checkout['items']),
            'cartTotal' => $checkout['total'],
            'currency' => $checkout['currency'],
            'checkoutUrl' => $checkout['recoveryUrl'],
        ],
        'idempotencyKey' => "checkout-started-{$checkout['id']}",
    ]);
}
```
:::

The `idempotencyKey` records the event once per cart, even when your store reports the checkout several times. On Shopify, the `checkouts/create` webhook carries the checkout's phone numbers and its `abandoned_checkout_url`, which works as `checkoutUrl`.

:::callout Record only shoppers who opted in
WhatsApp requires the customer's opt-in before you message them first. Record `checkout.started` only for shoppers who agreed to WhatsApp messages, and say at checkout what you'll send.
:::

## Record the order.confirmed event

Record `order.confirmed` for every paid order, on the contact with the same phone number:

:::code Record order.confirmed
```js
import { findOrCreateContact, recordEvent } from "./flownally.js";

export async function recordOrderConfirmed(order) {
  const contactId = await findOrCreateContact(order.phone, order.customerName);

  await recordEvent(contactId, {
    name: "order.confirmed",
    payload: {
      orderNumber: order.number,
      firstName: order.firstName,
      total: order.total,
      currency: order.currency,
    },
    idempotencyKey: `order-confirmed-${order.id}`,
  });
}
```

```python
from flownally import find_or_create_contact, record_event


def record_order_confirmed(order):
    contact_id = find_or_create_contact(order["phone"], order["customer_name"])

    record_event(contact_id, {
        "name": "order.confirmed",
        "payload": {
            "orderNumber": order["number"],
            "firstName": order["first_name"],
            "total": order["total"],
            "currency": order["currency"],
        },
        "idempotencyKey": f"order-confirmed-{order['id']}",
    })
```

```php
require_once 'flownally.php';

function recordOrderConfirmed(array $order): void
{
    $contactId = findOrCreateContact($order['phone'], $order['customerName']);

    recordEvent($contactId, [
        'name' => 'order.confirmed',
        'payload' => [
            'orderNumber' => $order['number'],
            'firstName' => $order['firstName'],
            'total' => $order['total'],
            'currency' => $order['currency'],
        ],
        'idempotencyKey' => "order-confirmed-{$order['id']}",
    ]);
}
```
:::

Once the event is recorded, the contact's runs waiting for `order.confirmed` continue, and active journeys that start on it can start a run.

## Build the abandoned cart journey in Flownally

Build the journey in the Flownally app, following [How to create a journey](/journeys/create-journey):

- **Start on `checkout.started`.** Allow contacts to enter again, so a returning shopper gets a reminder for a new cart. See the [Start block](/journeys/journey-blocks/start-block).
- **Wait for `order.confirmed`.** Add a [Wait block](/journeys/journey-blocks/wait-block) that continues when the event is recorded for the contact, with a timeout such as one hour.
- **Send the reminder after the timeout.** Add a [Send message](/journeys/journey-blocks/send-message-block) step with a WhatsApp template that uses `itemName` and `checkoutUrl` from the event.
- **Add a second reminder only if it adds something.** Put another wait for `order.confirmed` and a different message after the first reminder.

A contact who is still in a run doesn't start another one, so a shopper gets one reminder sequence at a time.

## How do I check that cart events arrive?

List a contact's events with [List contact events](/api/reference/events#list-contact-events), filtered by event type:

:::code List a contact's events
```bash
curl -G "https://api.flownally.com/v1/contacts/$CONTACT_ID/events" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  --data-urlencode "eventTypeId=$EVENT_TYPE_ID"
```

```js
const url = new URL("https://api.flownally.com/v1/contacts/CONTACT_ID/events");
url.searchParams.set("eventTypeId", "EVENT_TYPE_ID");

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/contacts/CONTACT_ID/events",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    params={"eventTypeId": "EVENT_TYPE_ID"},
)
response.raise_for_status()
data = response.json()
```

```php
$client = new GuzzleHttp\Client();

$response = $client->get('https://api.flownally.com/v1/contacts/CONTACT_ID/events', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'query' => ['eventTypeId' => 'EVENT_TYPE_ID'],
]);
$data = json_decode((string) $response->getBody(), true);
```
:::

To see which contacts entered the journey and where their runs are, use [List journey runs](/api/reference/journeys#list-journey-runs).

## FAQ

### Why did a shopper get a reminder after paying?

The `order.confirmed` event most likely went to a different contact, or arrived after the wait had timed out. List the events of the contact in the run with List contact events. If the order is on another contact, make both events use the same phone lookup.

### Can one cart start more than one reminder sequence?

No. The `idempotencyKey` records `checkout.started` once per cart, and a contact who is already in a run doesn't start another one. A later cart starts a new run once the earlier one has ended.

### What happens when the payload is missing a field?

Create contact event fails with `400`, because fields are required unless they set `"required": false`. Fix the payload or mark the field optional with [Update event type](/api/reference/events#update-event-type). A new `payloadSchema` must keep every existing field.

### Should I use POST /events or POST /contacts/{contactId}/events?

Use `POST /contacts/{contactId}/events`. `POST /events` is deprecated, takes the contact ID in the body, and otherwise behaves the same.

### What happens when the shopper opted out?

The reminder fails with `failureCode` `recipient_opted_out`, and the customer doesn't get it. Record `checkout.started` only for shoppers who agreed to WhatsApp messages, so the journey doesn't try.

## Related articles

- [How to recover abandoned carts with WhatsApp, SMS, and customer messaging](https://www.flownally.com/blog/how-to-recover-abandoned-carts-with-whatsapp-sms-and-customer-messaging)
- [Trigger reorder reminders from purchase events](/api/reorder-reminders)
- [Sync store customers and phone numbers](/api/sync-store-customers)
- [How to create a block in a journey to pause it](/journeys/journey-blocks/wait-block)
