Back to BlogIndustry Guides

Building Abandoned-Cart Recovery That Actually Recovers Carts

Rupak Amin

Founder & Lead Engineer, RAITHub

9 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

Abandoned-cart recovery fails on the plumbing, not the copy. It emails carts that already converted, re-contacts people who unsubscribed, and cannot tell "saved for later" from "abandoned." Build it on a cart event stream, a clear abandonment rule (inactive for N minutes, not yet ordered), a timed sequence that suppresses anyone who buys or opts out, and consent you can prove.

If you would rather have it built and tested for you, see how RAITHub would build this below.

Why does abandoned-cart recovery so often not work?

Because it is built as a cron job that scans carts, not as a system that reacts to what the shopper actually did. A scan-based job sends on stale data: the shopper checked out two minutes ago, but the email still goes out. The fix is to drive recovery from cart events, so a conversion or an opt-out cancels the sequence the instant it happens.

FailureWhy it happensThe fix
Emails a converted cartThe job reads a stale snapshot and does not re-check at send timeCancel the sequence on the order-placed event; re-check right before sending
Re-emails an unsubscribeConsent and suppression live apart from the senderOne suppression list the sender must check on every send
Fires on a browsing cart"Has items" is treated as "abandoned"Abandonment is "inactive for N minutes with intent," not merely non-empty
Wrong or missing contactGuest carts have no verified emailOnly enter the sequence once you have a consented, deliverable address

What does the data model look like?

Record what the shopper does as events, and derive the cart's state from them. The two tables are the cart and its events; the recovery sequence reads the events, never a guessed snapshot.

CREATE TABLE carts (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id  bigint REFERENCES customers(id),   -- null for a guest until captured
  email        text,                              -- captured + consented
  status       text NOT NULL DEFAULT 'active',    -- active | abandoned | converted
  last_activity_at timestamptz NOT NULL DEFAULT now(),
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE cart_events (
  id         bigserial PRIMARY KEY,
  cart_id    uuid NOT NULL REFERENCES carts(id),
  type       text NOT NULL,   -- item_added | item_removed | checkout_started | order_placed | email_captured
  payload    jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX cart_events_by_cart ON cart_events (cart_id, created_at);

Every interaction updates last_activity_at. An order_placed event flips the cart to converted and cancels any pending recovery. Because the history is in events, you can also measure recovery honestly later: how many abandoned carts were emailed, and how many then converted.

When is a cart actually abandoned?

When it has intent, a deliverable contact, and no activity for a set window. "Has items" is not enough: someone adding a product to compare is browsing, not abandoning. A workable rule:

  • Intent: the shopper reached checkout, or added a meaningful item, not just opened a product.
  • Contact: you have a consented, deliverable email. No consent, no sequence.
  • Inactivity: no cart event for N minutes (30 to 60 is common), and no order placed.

Detect it by scheduling a check per cart when activity stops, rather than scanning all carts constantly. When a cart goes quiet, enqueue a delayed job for N minutes later; if an order_placed or email-unsubscribe event arrives first, the job cancels itself.

How do you build the recovery sequence?

As a small state machine with timed steps and a single rule: re-check before every send. A typical sequence is a reminder after the inactivity window, a second after a day, and a last after three days, each cancelled the moment the cart converts or the shopper opts out.

// Before each scheduled send, re-check the cart. Never trust the snapshot.
async function sendRecoveryStep(cartId: string, step: number) {
  const cart = await getCart(cartId)
  if (cart.status !== 'abandoned') return            // converted or emptied: stop
  if (await isSuppressed(cart.email)) return          // unsubscribed or bounced: stop
  await sendEmail(cart.email, recoveryTemplate(step, cart))
  await recordEvent(cartId, 'recovery_sent', { step })
}

Keep the template driven by the cart's current contents, not the snapshot from when it abandoned, so a shopper who removed an item does not get emailed about it. The timed-step and cancellation logic is the same discipline as any reliable scheduled work, and the suppression check is what keeps you out of trouble.

Only enter a cart into recovery once you have a consented, deliverable email, and check a single suppression list on every send. An unsubscribe, a hard bounce or a spam complaint adds the address to that list immediately, and the sender must honour it. Marketing-email consent rules differ by market (the EU, UK, UAE, US and others set different requirements); this is general information, so confirm what applies to your audience with your adviser. The engineering job is to make consent and suppression a property the sender cannot bypass, not a checkbox stored somewhere the sender never reads.

How do you test abandoned-cart recovery?

The expensive bugs are "emailed a converted cart" and "emailed an unsubscribe," so test those directly.

import { describe, it, expect } from 'vitest'
import { sendRecoveryStep } from './recovery'

describe('abandoned-cart recovery', () => {
  it('does not email a cart that has since converted', async () => {
    const cart = await seedCart({ status: 'abandoned', email: 'a@example.com' })
    await placeOrder(cart.id)                      // order_placed -> converted
    const sent = await sendRecoveryStep(cart.id, 1)
    expect(sent).toBe(undefined)                   // suppressed by status
    expect(await emailsSentTo('a@example.com')).toBe(0)
  })

  it('does not email a suppressed address', async () => {
    const cart = await seedCart({ status: 'abandoned', email: 'b@example.com' })
    await suppress('b@example.com')                // unsubscribed
    await sendRecoveryStep(cart.id, 1)
    expect(await emailsSentTo('b@example.com')).toBe(0)
  })
})

Add a test that a browsing cart (items but no checkout intent) never enters the sequence, and one that an email provider webhook (bounce or complaint) adds the address to the suppression list.

Buy, build or hire?

OptionChoose this whenThe catch
An email platform's cart app (Klaviyo, Omnisend and similar)A standard store on a supported platform, with its tracking and consent modelRecovery follows the app's event data; custom carts, local gateways and your own suppression rules may not map cleanly
A simple cron that scans cartsLow volume and you accept occasional wrong sendsSends on stale data: emails converted carts and misses the just-abandoned ones
Custom, event-driven buildCustom checkout, guest carts, or you need recovery and consent you can prove and measureYou own the event model, the suppression list and the tests that keep sends correct

How long does it take to build yourself, and what is the risk?

For an experienced developer adding event-driven recovery to an existing store, our estimate is 2 to 4 weeks, including the sequence, suppression and tests. The main risk is sending on stale data: a job that does not re-check at send time will email converted carts and unsubscribes, which costs deliverability and trust. Consent rules differ by market; this is general information, so confirm the ones that apply with your adviser.

Why RAITHub for this

  • Event-driven systems in production. RAITHub builds on event and audit models, such as the append-only audit log design used across its platforms, so recovery reacts to what happened rather than scanning stale state.
  • Reliable scheduled work. PropDesk runs five daily automation jobs; the same discipline (idempotent, cancellable, re-checked) drives a recovery sequence that does not misfire.
  • Measurable. Because the cart history is events, recovery can be reported honestly: carts emailed and carts recovered, not a vanity number.

When you don't need us

  • Your email platform's cart app already fits. A standard store on a supported platform may need only configuration.
  • Volume is low. A careful, re-checking job may be enough if you accept the limits.
  • You only need the model. The event schema and the send-time check above are a fair start for your own developer.

How RAITHub would build this

  • Cart event stream: add, remove, checkout-started, order-placed and email-captured events, with cart state derived from them.
  • Abandonment detector: a per-cart delayed job on inactivity, cancelled on conversion or opt-out.
  • Recovery sequence: timed steps that re-check status and suppression before every send, with templates driven by current contents.
  • Consent and reporting: one suppression list the sender must honour, plus honest recovery metrics.

Timeline: adding this to an existing store is typically 2 to 4 weeks as focused work; inside a new store MVP it fits the 4 to 6 week fixed-scope range. See SaaS development and the ecommerce industry page. A related build is surviving a flash sale.

You receive: the event model, the recovery and suppression logic, tests gated in CI, handover docs, and full IP in your name under NDA.

Next step: book the free 15-minute technical audit with your checkout flow and consent model, and we will follow up with a written fixed quote.

Frequently asked questions

Why does my abandoned-cart email go to people who already bought?

Because the sender reads a stale snapshot and does not re-check at send time. Drive recovery from cart events so an order-placed event cancels the sequence immediately, and re-check the cart's status right before every send.

When is a cart actually abandoned?

When it shows intent (reached checkout or added a meaningful item), has a consented deliverable email, and has had no activity for a set window such as 30 to 60 minutes with no order placed. A cart that merely has items is browsing, not abandoned.

How many recovery emails should the sequence send?

A common pattern is three: a reminder after the inactivity window, a second after about a day, and a last after about three days. Each must be cancelled the moment the cart converts or the shopper opts out, and each re-checks suppression before sending.

How do I keep recovery emails legal?

Only enter carts with a consented, deliverable address, and honour a single suppression list on every send, updated instantly on unsubscribe, bounce or complaint. Marketing-email consent rules differ by market, so confirm the ones that apply to your audience with your adviser; this is general information.

Should I build this or use my email platform's cart app?

Use the app for a standard store on a supported platform. Build custom when you have a custom checkout, guest carts, local payment gateways, or you need recovery and consent you can prove and measure against your own event data.

How do I measure whether recovery actually works?

Because the cart history is events, you can count abandoned carts that were emailed and how many then placed an order, rather than reporting opens or clicks alone. That attribution is only trustworthy when conversion is read from the order-placed event, not inferred.

abandoned cart recoveryecommerceevent drivenemail automationconsentconversion

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.