Code Agency
13 min readBy Fabio Tielen

Abandoned carts, owned infrastructure: recovery flows on our mailing platform

Roughly seven in ten carts never become an order, and the people who filled them are the warmest audience your shop will ever have. The three-mail sequence we build, the event pipeline that fires it, and why we run it on the same platform that sends your newsletters instead of renting the flow from a US SaaS.

Somewhere between two thirds and three quarters of the carts on your shop never become an order. That number has barely moved in a decade of studies, it holds across markets and price points, and every shop owner we meet has heard it. What surprises people is the other half of the sentence: the person who filled that cart chose products, read a description, and got far enough to be thinking about their card. They are the warmest audience your shop will ever have, and most shops never write to them.

The flows that fix it are not complicated. Three mails, a handful of timing rules, and one honest piece of plumbing. What makes them worth writing about is where they run — because the default answer is to bolt on a SaaS that takes a copy of your customer list, sends from its own IPs, and charges you per contact for the privilege of mailing people who were already on your site.

First, the mail you can only send if you have the address

Every cart recovery project starts with the same uncomfortable question: do you actually know who abandoned the cart? A guest who adds two items and closes the tab is an anonymous session. There is no mail to send.

So the sequence starts before the sequence. There are exactly three legitimate ways you end up with an address attached to a cart:

  • They're logged in. Account customers are the easy case, and on B2B shops it's most of the traffic.
  • They gave it earlier and consented. A newsletter signup, a previous order, a "notify me when back in stock" — the address exists in your list already and the session can be matched to it.
  • They typed it into checkout and then stopped. The single highest-value trigger on the whole site: someone who reached the e-mail step of checkout and didn't finish.

That last one is where the design decision lives. Ask for the e-mail address as the first checkout step, not the last, and capture it on blur rather than on submit. It's a one-line reorder of a form that roughly doubles the addressable population of the whole flow, because most drop-off happens at the shipping and payment steps that come after.

What you must not do is collect that address by stealth and mail it as if it were a marketing opt-in. An address typed into a checkout is given to you for that order. In practice this means cart recovery mail is service mail about a specific transaction, sent under legitimate interest, with the same one-click unsubscribe as everything else — not a back door onto your newsletter list. Adding them to the newsletter requires them to say yes to that separately, on the checkout page, unticked. Our platform enforces that boundary at the platform level rather than trusting the flow to behave, which is the same reason we like consent handled by the system rather than the campaign.

Guests who never reach that step are not recoverable by mail, and no tool changes that. They're an on-site problem — clearer delivery costs, visible stock, fewer surprises at step three.

Three mails, and the first one is not a discount

The sequence we build for almost every shop looks like this. The hours matter less than the shape, but the shape has been consistent across enough projects that we now start here and tune rather than the reverse.

Mail one, at about one hour: the helpful one. Their cart, the products, the images, a button that restores it in one click. No discount, no urgency, no countdown. A meaningful share of abandonment is mundane — a phone died, a meeting started, the delivery cost needed checking with someone else. For those people the only thing standing between you and the order is the four minutes it would take to find the products again. This mail is the highest-converting of the three and it costs you nothing in margin.

Mail two, at about 24 hours: the objection one. Same cart, plus the answers to the reasons people actually stop. Delivery time and cost stated plainly, the returns window, payment methods, whether there's a human on the phone. On a Belgian shop this is where you say Bancontact, not "multiple secure payment options". If you have a review or a customer photo for a product in that cart, it belongs here.

Mail three, at about 72 hours: the last-chance one, and only sometimes a discount. Stock reality if it's true — "two left" is powerful and a lie is fatal. A discount here is a business decision, not a template default, and it needs three guardrails: single-use codes tied to that cart, a floor that respects your margin, and exclusion for anyone who bought at full price in the last N days. Which brings us to the part that quietly costs shops money.

Do not train your customers to abandon. If every cart gets 10% after three days, your regulars will learn the trick within a quarter, and you will have converted a segment that was going to buy anyway onto a permanent discount. Cap discount mails per customer per period, exclude high-frequency buyers entirely, and prefer free shipping over a percentage — it's cheaper, it reads as generous, and it doesn't reset anyone's idea of what your products cost.

Three is the right number. We have seen five-mail sequences; mails four and five reliably generate unsubscribes and complaints out of proportion to the orders they add, and complaints are the one currency that costs you future delivery to everybody else.

The event pipeline is the actual engineering

Everything above is marketing copy until the shop reliably tells the mailing platform what happened. This is the part that gets skipped, and it's the part that breaks.

A cart is not an event, it's a state that keeps changing. What the platform needs is a durable record of "this identified cart was last touched at time T and contains these lines", plus a way to know it stopped mattering. On a headless storefront the cart usually lives in your own database, which makes this straightforward: write the fact in the same transaction as the cart change, and let a worker deliver it. The same transactional outbox we use everywhere, for the same reason — an event that commits separately from the state change is an event that lies eventually.

the outbox row is written by the same transaction as the cart line
BEGIN;
 
INSERT INTO cart_lines (cart_id, product_id, qty, unit_price)
VALUES ($1, $2, $3, $4)
ON CONFLICT (cart_id, product_id) DO UPDATE SET qty = excluded.qty;
 
UPDATE carts SET last_activity_at = now() WHERE id = $1;
 
INSERT INTO outbox (topic, payload)
VALUES ('cart.updated', jsonb_build_object('cart_id', $1, 'at', now()));
 
COMMIT;

No cron scanning for "carts modified since yesterday". A shop where a price fix or a removed line takes a day to reach the flow is a shop that mails people carts they no longer have — and Postgres is perfectly capable of being the queue here without another container joining the diagram.

The worker's job is to keep one contact-level record current, not to stream every keystroke:

workers/cart-sync.ts — upsert state, never append events
for (const event of await claimBatch("cart.updated", 100)) {
  const cart = await loadCart(event.payload.cart_id)
 
  // No identified contact, nothing to recover. Drop it and move on.
  if (!cart.email) {
    await ack(event)
    continue
  }
 
  await mailing.contacts.upsert({
    email: cart.email,
    attributes: {
      cart_token: cart.token,
      cart_value: cart.total,
      cart_currency: cart.currency,
      cart_items: cart.lines.map((l) => ({
        sku: l.sku,
        name: l.name,
        qty: l.qty,
        image: l.imageUrl,
      })),
      cart_updated_at: cart.updatedAt,
      cart_recovery_url: `${SHOP_URL}/cart/restore/${cart.token}`,
    },
  })
 
  await ack(event)
}

Two things in there are deliberate. The recovery URL is a single-purpose, expiring token that restores a cart and nothing else — never a session, never a login link. Cart URLs get forwarded, screenshotted and indexed; the worst outcome of a leaked one should be that a stranger sees three products. And the payload carries only what the mail renders. Prices and stock in a mail are a snapshot by definition, so the mail states what it is ("your cart as of Tuesday") and the restore page fetches the truth again — the same discipline as never trusting a derived copy for money.

The other half is the stop condition, and it's the one that generates angry mail when it's missing:

app/actions/checkout.ts — cancel the flow the moment it stops being true
await placeOrder(cart)
await mailing.flows.cancel({ flow: "cart-recovery", email: cart.email })

Order placed, cart emptied by hand, item went out of stock — every one of those has to reach the platform faster than the next mail goes out. Nothing burns trust like a "you left something behind!" arriving forty minutes after the confirmation of the order they placed. We test this path on every project by putting a real order through staging with the flow live, and we have never once regretted the ten minutes.

Where the owned platform earns its place

You could run all of this on Klaviyo or Mailchimp. Plenty of shops do. Here is what actually changes when the flow runs on the platform we operate instead.

Your customer list stays where you decided. Cart contents, purchase history and behavioural profiles are among the most commercially revealing data your business holds. Handing a live copy to a US SaaS is a processor agreement, a transfer question and a line in the privacy policy — for a mail you could have sent from your own infrastructure. When the platform runs in the EU, or on your own cluster, the GDPR conversation is a paragraph rather than a project.

Pricing doesn't punish the thing you're trying to do. Per-contact pricing is a tax on list growth: recover carts successfully, grow the list, pay more, at a rate that has nothing to do with how many mails you send. We've seen shops delete engaged contacts to stay inside a pricing tier, which is an extraordinary way to run a marketing channel.

The timing rules are yours. Vendor defaults are built for a generic global shop. Belgian B2B carts abandoned at 16:30 on a Friday should not be chased at 17:30 on a Friday — Monday morning converts better, and Sunday sends into a B2B inbox are noise. Wholesale customers with agreed terms probably shouldn't get a discount mail at all. Every one of those is a rule you want to change on a Tuesday afternoon because someone in sales noticed something, not a feature request.

The sending reputation is your own. Dedicated IPs, warmed properly, with bounce and complaint handling and suppression lists enforced by the platform, mean your delivery depends on how you mail rather than on whoever else shares your shared-pool IP this month. Cart mail is high-engagement and helps that reputation — but only if it's the same well-configured sending domain as the rest of your mail, not a second identity introduced by a tool.

And there's no third-party script on the storefront. The events come from your server, which means no pixel to load, nothing on the critical path of a page you're being graded on, and no consent banner entry for a marketing tracker — the same argument we make about not putting somebody else's script on a form.

Measure incremental revenue, not attributed revenue

Every cart recovery tool ships a dashboard claiming a large number of "recovered" euros, and every one of those numbers is inflated. The attribution rule is "this person got the mail and then bought", which credits the flow with every customer who was coming back anyway — and on a shop with returning customers, that is most of them.

The honest measurement is a holdout. Withhold the sequence from a random 5–10% of eligible carts and compare conversion in the two groups over the same window. The difference is what the flow is worth; everything above it is bookkeeping. It costs you a few orders' worth of recovery on the holdout and it is the only figure worth putting in front of whoever signs off the budget — the same reasoning we apply to scoring leads rather than counting them.

Watch three numbers alongside it, weekly, on one dashboard:

  • Complaint rate per mail in the sequence. Sustained above roughly one in a thousand, something in the sequence is wrong — usually mail three, usually its timing.
  • Unsubscribes attributed to mail three. If it's outperforming the other two on unsubscribes and underperforming on orders, delete it. We have deleted it.
  • Discount redemption split by customer type. If your regulars are the ones redeeming, you're not recovering carts, you're funding a habit.

Expect a recovery rate in the high single digits to low teens on the identified population, not the headline "we recover 30% of carts" from a vendor case study. On a shop doing €50k a month with 70% abandonment, a real 8% on identified carts is meaningful money for a flow that runs itself — and it's a defensible number, which matters more than a big one.

Where we don't build this

  • Under a few hundred carts a month. The sequence still works, but the sample is too small to tune and the time is better spent on why checkout leaks in the first place. Fix the delivery-cost surprise before you mail about it.
  • When the shop can't identify anyone. No accounts, no early e-mail capture, no existing list — the flow has nobody to write to. That's a checkout project, not a mailing project.
  • When nobody owns the content. Three mails written once and never revisited go stale in a season. Someone has to own the copy the way they own product pages.
  • On top of a broken transactional setup. If order confirmations already land in spam, marketing mail is not the thing to fix first. Get the transactional path right, then build on it.
  • When the real problem is the product page. People who abandon after reading a page with no delivery date, no stock signal and one photo are not telling you they need a reminder.

The short version

Capture the address as early as checkout legitimately allows, and treat it as service mail rather than a newsletter opt-in. Send three: a helpful one at an hour, an objection-handling one at a day, a last-chance one at three days that is only sometimes a discount and never an automatic one. Wire it with an outbox so cart state reaches the platform transactionally, upsert one contact record instead of streaming events, and cancel the flow the instant an order lands. Measure it with a holdout, and watch complaints as closely as revenue.

Then run it somewhere you control. This is the flow we build on headless commerce projects and on the custom applications around them, sending through the same platform that sends the newsletter, on infrastructure with a name attached to it. The carts were already yours. The list that recovers them should be too.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.