# Trigger reorder reminders from purchase events

:::summary Quick answer
To trigger reorder reminders from purchase events, record a `product.purchased` event when an order with a consumable is paid. Include the product, its reorder interval and the date the customer is likely to run out. A Flownally journey waits until shortly before that date and sends a WhatsApp reminder. Set the journey's `entryMode` to `concurrent` if every purchase should get its own reminder.

**Send the due date as data, and let the journey decide how early to remind.**
:::

This guide is the build companion to [How can ecommerce brands automate reorder reminders for consumable products?](https://www.flownally.com/blog/how-can-ecommerce-brands-automate-reorder-reminders-for-consumable-products) and [Reorder reminder message examples](https://www.flownally.com/blog/reorder-reminder-message-examples-for-consumable-products), which cover timing and wording.

## How do reorder reminders work with purchase events?

A reminder after the customer has run out comes too late, and one a week after the purchase is noise. **The due date in the event lets the journey send the reminder just before it's needed.**

**Order paid → `product.purchased` recorded → Journey waits until the due date → Reminder sent**

Your store knows the product and the quantity, so it calculates the date. The journey owns the lead time and the message, so your marketing team can change both in the Flownally app.

## Calculate the reorder date from the purchase

Keep the number of days one unit lasts with each consumable product, for example in a product field in your store. Multiply it by the quantity bought to get the reorder interval, and add the interval to the purchase date:

:::code
```js
// Days one unit lasts, by SKU. Products without an entry get no reminder.
const DAYS_PER_UNIT = {
  "CLEANSER-150ML": 30,
  "VITAMIN-D-60": 60,
  "COFFEE-BEANS-1KG": 21,
};

// Returns the consumable that runs out first, or null when the order has none.
export function reorderFor(order) {
  const [first] = order.items
    .filter((item) => DAYS_PER_UNIT[item.sku])
    .map((item) => ({ item, intervalDays: DAYS_PER_UNIT[item.sku] * item.quantity }))
    .sort((a, b) => a.intervalDays - b.intervalDays);
  if (!first) return null;

  const dueOn = new Date(order.paidAt);
  dueOn.setUTCDate(dueOn.getUTCDate() + first.intervalDays);
  return { ...first, dueOn: dueOn.toISOString().slice(0, 10) };
}
```

```python
from datetime import datetime, timedelta

# Days one unit lasts, by SKU. Products without an entry get no reminder.
DAYS_PER_UNIT = {
    "CLEANSER-150ML": 30,
    "VITAMIN-D-60": 60,
    "COFFEE-BEANS-1KG": 21,
}


# Returns the consumable that runs out first, or None when the order has none.
def reorder_for(order):
    candidates = [
        {"item": item, "interval_days": DAYS_PER_UNIT[item["sku"]] * item["quantity"]}
        for item in order["items"]
        if item["sku"] in DAYS_PER_UNIT
    ]
    if not candidates:
        return None

    first = min(candidates, key=lambda candidate: candidate["interval_days"])
    due_on = datetime.fromisoformat(order["paid_at"]) + timedelta(days=first["interval_days"])
    return {**first, "due_on": due_on.date().isoformat()}
```

```php
<?php
// reorder.php

// Days one unit lasts, by SKU. Products without an entry get no reminder.
const DAYS_PER_UNIT = [
    'CLEANSER-150ML' => 30,
    'VITAMIN-D-60' => 60,
    'COFFEE-BEANS-1KG' => 21,
];

// Returns the consumable that runs out first, or null when the order has none.
function reorderFor(array $order): ?array
{
    $first = null;
    foreach ($order['items'] as $item) {
        $intervalDays = (DAYS_PER_UNIT[$item['sku']] ?? 0) * $item['quantity'];
        if ($intervalDays > 0 && ($first === null || $intervalDays < $first['interval_days'])) {
            $first = ['item' => $item, 'interval_days' => $intervalDays];
        }
    }
    if ($first === null) {
        return null;
    }

    $dueOn = (new DateTimeImmutable($order['paid_at']))->modify("+{$first['interval_days']} days");
    return $first + ['due_on' => $dueOn->format('Y-m-d')];
}
```
:::

The item that runs out first decides the date, so each order schedules one reminder instead of one per product.

## Create the purchase event type

Create the event type once with [Create event type](/api/reference/events#create-event-type). **`reorderDueOn` uses `"format": "date"`, which is what lets the journey wait for it.**

:::code
```bash
curl -X POST "https://api.flownally.com/v1/event-types" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "product.purchased",
    "description": "Customer bought a consumable product.",
    "payloadSchema": {
      "orderNumber": { "type": "string" },
      "firstName": { "type": "string" },
      "productName": { "type": "string" },
      "productSku": { "type": "string" },
      "quantity": { "type": "number" },
      "reorderIntervalDays": { "type": "number" },
      "reorderDueOn": { "type": "string", "format": "date" },
      "reorderUrl": { "type": "string" }
    }
  }'
```

```js
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({
    name: "product.purchased",
    description: "Customer bought a consumable product.",
    payloadSchema: {
      orderNumber: { type: "string" },
      firstName: { type: "string" },
      productName: { type: "string" },
      productSku: { type: "string" },
      quantity: { type: "number" },
      reorderIntervalDays: { type: "number" },
      reorderDueOn: { type: "string", format: "date" },
      reorderUrl: { type: "string" },
    },
  }),
});

if (!response.ok) {
  throw new Error(`Flownally API returned ${response.status}: ${await response.text()}`);
}
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/event-types",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "name": "product.purchased",
        "description": "Customer bought a consumable product.",
        "payloadSchema": {
            "orderNumber": {"type": "string"},
            "firstName": {"type": "string"},
            "productName": {"type": "string"},
            "productSku": {"type": "string"},
            "quantity": {"type": "number"},
            "reorderIntervalDays": {"type": "number"},
            "reorderDueOn": {"type": "string", "format": "date"},
            "reorderUrl": {"type": "string"},
        },
    },
)
response.raise_for_status()
```

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

$client->post('https://api.flownally.com/v1/event-types', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'name' => 'product.purchased',
        'description' => 'Customer bought a consumable product.',
        'payloadSchema' => [
            'orderNumber' => ['type' => 'string'],
            'firstName' => ['type' => 'string'],
            'productName' => ['type' => 'string'],
            'productSku' => ['type' => 'string'],
            'quantity' => ['type' => 'number'],
            'reorderIntervalDays' => ['type' => 'number'],
            'reorderDueOn' => ['type' => 'string', 'format' => 'date'],
            'reorderUrl' => ['type' => 'string'],
        ],
    ],
]);
```
:::

`payloadSchema` is a simplified format, not a JSON Schema document. Each field has a `type`, an optional `format` (`date` or `date-time`), and is required unless it sets `"required": false`. [Recover abandoned carts with events and journeys](/api/abandoned-cart-events) describes the format in full.

## Record the purchase event when the order is paid

Find the contact by phone number and record the event with [Create contact event](/api/reference/events#create-contact-event). This example uses `findOrCreateContact` and `recordEvent` from the [abandoned cart guide](/api/abandoned-cart-events), which search the `cf_phone` field before creating a contact and treat `409` as already recorded.

:::code
```js
import { findOrCreateContact, recordEvent } from "./flownally.js";
import { reorderFor } from "./reorder.js";

export async function recordPurchase(order) {
  const reorder = reorderFor(order);
  if (!reorder) return;

  const contactId = await findOrCreateContact(order.phone, order.customerName);
  await recordEvent(contactId, {
    name: "product.purchased",
    payload: {
      orderNumber: order.number,
      firstName: order.firstName,
      productName: reorder.item.title,
      productSku: reorder.item.sku,
      quantity: reorder.item.quantity,
      reorderIntervalDays: reorder.intervalDays,
      reorderDueOn: reorder.dueOn,
      reorderUrl: `https://shop.example.com/reorder/${order.id}`,
    },
    idempotencyKey: `product-purchased-${order.id}`,
  });
}
```

```python
from flownally import find_or_create_contact, record_event
from reorder import reorder_for


def record_purchase(order):
    reorder = reorder_for(order)
    if not reorder:
        return

    contact_id = find_or_create_contact(order["phone"], order["customer_name"])
    record_event(contact_id, {
        "name": "product.purchased",
        "payload": {
            "orderNumber": order["number"],
            "firstName": order["first_name"],
            "productName": reorder["item"]["title"],
            "productSku": reorder["item"]["sku"],
            "quantity": reorder["item"]["quantity"],
            "reorderIntervalDays": reorder["interval_days"],
            "reorderDueOn": reorder["due_on"],
            "reorderUrl": f"https://shop.example.com/reorder/{order['id']}",
        },
        "idempotencyKey": f"product-purchased-{order['id']}",
    })
```

```php
<?php
require __DIR__ . '/flownally.php';
require __DIR__ . '/reorder.php';

function recordPurchase(array $order): void
{
    $reorder = reorderFor($order);
    if ($reorder === null) {
        return;
    }

    $contactId = findOrCreateContact($order['phone'], $order['customer_name']);
    recordEvent($contactId, [
        'name' => 'product.purchased',
        'payload' => [
            'orderNumber' => $order['number'],
            'firstName' => $order['first_name'],
            'productName' => $reorder['item']['title'],
            'productSku' => $reorder['item']['sku'],
            'quantity' => $reorder['item']['quantity'],
            'reorderIntervalDays' => $reorder['interval_days'],
            'reorderDueOn' => $reorder['due_on'],
            'reorderUrl' => "https://shop.example.com/reorder/{$order['id']}",
        ],
        'idempotencyKey' => "product-purchased-{$order['id']}",
    ]);
}
```
:::

Here's the same request on its own, and the recorded event Flownally returns with `201`:

:::code
```bash
curl -X POST "https://api.flownally.com/v1/contacts/$CONTACT_ID/events" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "product.purchased",
    "payload": {
      "orderNumber": "4827",
      "firstName": "Anna",
      "productName": "Hydrating Cleanser 150 ml",
      "productSku": "CLEANSER-150ML",
      "quantity": 1,
      "reorderIntervalDays": 30,
      "reorderDueOn": "2026-12-04",
      "reorderUrl": "https://shop.example.com/reorder/4827"
    },
    "idempotencyKey": "product-purchased-4827"
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/contacts/CONTACT_ID/events", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "product.purchased",
    payload: {
      orderNumber: "4827",
      firstName: "Anna",
      productName: "Hydrating Cleanser 150 ml",
      productSku: "CLEANSER-150ML",
      quantity: 1,
      reorderIntervalDays: 30,
      reorderDueOn: "2026-12-04",
      reorderUrl: "https://shop.example.com/reorder/4827",
    },
    idempotencyKey: "product-purchased-4827",
  }),
});

if (!response.ok) {
  throw new Error(`Flownally API returned ${response.status}: ${await response.text()}`);
}

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

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/contacts/CONTACT_ID/events",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "name": "product.purchased",
        "payload": {
            "orderNumber": "4827",
            "firstName": "Anna",
            "productName": "Hydrating Cleanser 150 ml",
            "productSku": "CLEANSER-150ML",
            "quantity": 1,
            "reorderIntervalDays": 30,
            "reorderDueOn": "2026-12-04",
            "reorderUrl": "https://shop.example.com/reorder/4827",
        },
        "idempotencyKey": "product-purchased-4827",
    },
)
response.raise_for_status()
event = response.json()
```

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

$response = $client->post('https://api.flownally.com/v1/contacts/CONTACT_ID/events', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'name' => 'product.purchased',
        'payload' => [
            'orderNumber' => '4827',
            'firstName' => 'Anna',
            'productName' => 'Hydrating Cleanser 150 ml',
            'productSku' => 'CLEANSER-150ML',
            'quantity' => 1,
            'reorderIntervalDays' => 30,
            'reorderDueOn' => '2026-12-04',
            'reorderUrl' => 'https://shop.example.com/reorder/4827',
        ],
        'idempotencyKey' => 'product-purchased-4827',
    ],
]);

$event = json_decode((string) $response->getBody(), true);
```
:::

```json
{
  "id": "evt_d7pjmuk9lfdk4tgvlnpg",
  "tenantId": "tn_d7phk42ge9t9t6324f20",
  "contactId": "con_d7pibvs6gljrc2pfdc1g",
  "eventTypeId": "ety_d7pji5t0tajnmqt4a2rg",
  "eventTypeName": "product.purchased",
  "payload": {
    "orderNumber": "4827",
    "firstName": "Anna",
    "productName": "Hydrating Cleanser 150 ml",
    "productSku": "CLEANSER-150ML",
    "quantity": 1,
    "reorderIntervalDays": 30,
    "reorderDueOn": "2026-12-04",
    "reorderUrl": "https://shop.example.com/reorder/4827"
  },
  "idempotencyKey": "product-purchased-4827",
  "createdAt": "2026-11-04T10:40:00Z"
}
```

A payload that doesn't match the event type fails with `400`. A contact or event type that doesn't exist fails with `404`.

## Build the reorder reminder journey

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

- **Start on `product.purchased`.** Pick the event type in the [Start block](/journeys/journey-blocks/start-block).
- **Wait until the due date.** Add a [Wait block](/journeys/journey-blocks/wait-block) that continues on a specific date, choose `reorderDueOn` from the event, and set an offset such as three days before the date.
- **Send the reminder.** Add a [Send message](/journeys/journey-blocks/send-message-block) step with a WhatsApp template that uses `firstName`, `productName` and `reorderUrl`.

Customer replies to the reminder arrive in the Flownally inbox, so your team can answer questions about sizes, flavors or timing.

## How do I send a reminder for every purchase?

A journey's `entryMode` decides how often a contact can enter it:

- **`once`.** A contact enters at most once, ever, so only the first purchase gets a reminder.
- **`repeatable`.** A contact enters again after the previous run ends, so a purchase made while they wait for an earlier reminder doesn't start its own run.
- **`concurrent`.** A contact enters on every matching event, even while earlier runs are still waiting.

**For reorder reminders, `concurrent` gives every purchase its own reminder.** Set it with [Update journey](/api/reference/journeys#update-journey), then publish and activate the journey again, because entry changes reach contacts only from the next active version:

:::code
```bash
curl -X PATCH "https://api.flownally.com/v1/journeys/$JOURNEY_ID" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "entryMode": "concurrent" }'

curl -X POST "https://api.flownally.com/v1/journeys/${JOURNEY_ID}:publish" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"

curl -X POST "https://api.flownally.com/v1/journeys/${JOURNEY_ID}:activate" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const journeyUrl = "https://api.flownally.com/v1/journeys/JOURNEY_ID";
const headers = { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` };

await fetch(journeyUrl, {
  method: "PATCH",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ entryMode: "concurrent" }),
});
await fetch(`${journeyUrl}:publish`, { method: "POST", headers });
await fetch(`${journeyUrl}:activate`, { method: "POST", headers });
```

```python
import os

import requests

journey_url = "https://api.flownally.com/v1/journeys/JOURNEY_ID"
headers = {"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"}

requests.patch(journey_url, headers=headers, json={"entryMode": "concurrent"}).raise_for_status()
requests.post(f"{journey_url}:publish", headers=headers).raise_for_status()
requests.post(f"{journey_url}:activate", headers=headers).raise_for_status()
```

```php
$client = new GuzzleHttp\Client();
$journeyUrl = 'https://api.flownally.com/v1/journeys/JOURNEY_ID';
$headers = ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')];

$client->patch($journeyUrl, ['headers' => $headers, 'json' => ['entryMode' => 'concurrent']]);
$client->post("{$journeyUrl}:publish", ['headers' => $headers]);
$client->post("{$journeyUrl}:activate", ['headers' => $headers]);
```
:::

See [Publish journey](/api/reference/journeys#publish-journey) and [Activate journey](/api/reference/journeys#activate-journey).

## Schedule reorder reminders from your store instead

A waiting run doesn't know about later orders, so a customer who reorders early still gets the reminder for the earlier purchase. If that happens often, keep the schedule in your store.

**Daily job finds due orders → Skips customers who reordered → Records `reorder.due` → Journey sends right away**

- **Save the due date.** Store `reorderDueOn` with the order.
- **Run a daily job.** Find orders whose reminder date is today, and skip customers who reordered since.
- **Record `reorder.due`.** Use the same payload fields as `product.purchased`.
- **Send without waiting.** Start the journey on `reorder.due` and send the template right away.

Your store then decides who gets a reminder, and the journey still owns the message.

## FAQ

### Why didn't a second purchase get its own reminder?

The journey most likely uses `repeatable`, which lets a contact enter again only after their previous run ends. A purchase recorded while the contact waits for an earlier reminder doesn't start a run. Set `entryMode` to `concurrent`, then publish and activate the journey.

### Why did a customer who reordered early still get a reminder?

The run for the earlier purchase keeps waiting for its own date, and later orders don't change it. If early reorders are common, schedule reminders from your store and record `reorder.due` only for customers who haven't reordered.

### Why can't I choose reorderDueOn in the Wait block?

The Wait block offers the date fields of the event types selected in the Start block. Check that the journey starts on `product.purchased`, and that the event type declares `reorderDueOn` as a `string` with `"format": "date"` or `"date-time"`.

### What date format does reorderDueOn need?

Send `YYYY-MM-DD`, such as `2026-12-04`, for a `date` field. Use a `date-time` field instead if you need a time of day.

## Related articles

- [How can ecommerce brands automate reorder reminders for consumable products?](https://www.flownally.com/blog/how-can-ecommerce-brands-automate-reorder-reminders-for-consumable-products)
- [Reorder reminder message examples](https://www.flownally.com/blog/reorder-reminder-message-examples-for-consumable-products)
- [Recover abandoned carts with events and journeys](/api/abandoned-cart-events)
- [Sync store customers and phone numbers](/api/sync-store-customers)
