Founder & Lead Engineer, RAITHub
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.
| Failure | Why it happens | The fix |
|---|---|---|
| Emails a converted cart | The job reads a stale snapshot and does not re-check at send time | Cancel the sequence on the order-placed event; re-check right before sending |
| Re-emails an unsubscribe | Consent and suppression live apart from the sender | One 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 contact | Guest carts have no verified email | Only 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.
How do you respect consent and the law?
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?
| Option | Choose this when | The catch |
|---|---|---|
| An email platform's cart app (Klaviyo, Omnisend and similar) | A standard store on a supported platform, with its tracking and consent model | Recovery follows the app's event data; custom carts, local gateways and your own suppression rules may not map cleanly |
| A simple cron that scans carts | Low volume and you accept occasional wrong sends | Sends on stale data: emails converted carts and misses the just-abandoned ones |
| Custom, event-driven build | Custom checkout, guest carts, or you need recovery and consent you can prove and measure | You 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.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.