getNodi

Templates / Abandoned cart sweep

Sweeping abandoned carts on a clock

Cart recovery works on a tight window. Too early and you email someone who is still shopping; too late and the intent has gone. An hourly sweep is the shape that fits.

The recipe

Request
POST https://{{domain}}/api/carts/abandoned/sweep
Schedule
Every hour (0 * * * *)
Headers
Authorization: Bearer {{secret}}encrypted
Timeout
30s
Attempts
3
Counts as success
2xx

You supply

Your domain
example.com

Host only — no https:// and no trailing slash.

Shared secret
s3cr3t-valuestored encrypted

Whatever your endpoint checks the caller against.

Why this job exists

Abandoned cart email is among the highest-converting mail a store sends, and its performance depends almost entirely on timing. The first message wants to land within a few hours of abandonment — early enough that the purchase is still live in the customer’s mind, late enough that they have genuinely left.

Sending on an event does not work, because abandonment is not an event. Nothing happens when a customer walks away; the signal is the absence of activity, and absence can only be detected by looking. So something has to periodically ask which carts have gone quiet.

That query is cheap and the cadence is forgiving. Hourly gives you a worst-case error of an hour on a window measured in hours, and it means the sweep is small every time it runs rather than enormous once a day.

Setting it up

Put the window and the exclusions in the endpoint, not in the schedule. The sweep should be safe to run at any cadence, and safe to run twice — you want a job that cannot double-send if it is retried:

A sweep that is safe to repeat

const carts = await db.carts.find({
  updatedAt: { $lt: hoursAgo(4), $gt: hoursAgo(72) },
  checkedOutAt: null,
  recoveryEmailSentAt: null,
  'customer.email': { $ne: null },
});

for (const cart of carts) {
  await mail.queue('cart-recovery', cart);
  await db.carts.update(cart.id, { recoveryEmailSentAt: new Date() });
}

return Response.json({ swept: carts.length });

Marking the cart before or as you queue is what makes the job idempotent. Without that flag, a retry after a partial failure mails everyone in the batch a second time, and there is no apology that undoes it.

Checking it actually works

  • Read the swept count in the run history. Zero every hour on a store with traffic means the query window is wrong.
  • Abandon a cart yourself with a test account and confirm exactly one email arrives, in the right window.
  • Check that customers who completed checkout after abandoning are excluded — this is the failure customers actually complain about.

Where it goes wrong

Mailing people who already bought

The window has to exclude carts that were converted, including ones converted through a different session or channel. Filtering only on cart age is how a customer receives "you left something behind" an hour after their order confirmation.

No upper bound on the window

A sweep with only a lower bound will, the first time it runs, find every abandoned cart in your history and mail all of them. Bound both ends, and cap the batch on the first run.

Consent and unsubscribes

Cart recovery is marketing mail in most jurisdictions. Honour marketing consent and suppression lists in the query itself — checking at send time is one layer too late if the queue is processed by something else.

Questions

What is the right delay before the first email?

Most stores land between two and six hours. The recipe’s hourly cadence lets you tune the window in the query without touching the schedule, which is the right place for it.

Should I send more than one?

A two- or three-message sequence usually outperforms one. Drive it from the same sweep with a stage counter on the cart rather than from three separate jobs.

Does Shopify or WooCommerce not do this already?

Both have built-in recovery. This recipe is for custom checkouts, or for stores whose sequence lives in their own system rather than the platform’s.

Running it here

This is a preset in the product, not an illustration. Pick it in the dashboard, fill in the 2 values above, and the job is created, scheduled and enabled — with retries, a record of every run showing status, latency and response, and an alert the first time one fails. Secret values are encrypted at rest and never rendered back.

The free plan runs 3 jobs hourly and needs no card. Where a recipe wants a finer cadence than your plan allows, it is slowed to the fastest schedule you are permitted rather than rejected.