Recover abandoned carts with events and journeys
This guide is the build companion to 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.startedfor it. - The journey starts a run. An active journey that starts on
checkout.startedwaits fororder.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.
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. Check List event types first, because a name the workspace already uses fails with 409.
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" }
}
}'If you already send order updates as events, as in Send WooCommerce order updates on WhatsApp or Send Shopify order updates on WhatsApp, 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 ofstring,number,integer,booleanornull.format.dateordate-time, forstringfields only.nullable.truealso acceptsnull.required.falsemakes 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.
// 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;
}
}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 (POST /contacts/{contactId}/events), because POST /events is deprecated.
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}`,
});
}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.
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:
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}`,
});
}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:
- Start on
checkout.started. Allow contacts to enter again, so a returning shopper gets a reminder for a new cart. See the Start block. - Wait for
order.confirmed. Add a 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 step with a WhatsApp template that uses
itemNameandcheckoutUrlfrom the event. - Add a second reminder only if it adds something. Put another wait for
order.confirmedand 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, filtered by event type:
curl -G "https://api.flownally.com/v1/contacts/$CONTACT_ID/events" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
--data-urlencode "eventTypeId=$EVENT_TYPE_ID"To see which contacts entered the journey and where their runs are, use 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. 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.