Menu
All schedule 12 min read

Abandoned Cart Emails: The Sequence That Recovers Revenue

Faraz Frank

Faraz Frank

November 5, 2026

Abandoned Cart Emails

WooCommerce will send a customer their order confirmation, their invoice, and their password reset. It will not send them a reminder that they left without ordering, because that email does not exist in core.

Open WooCommerce → Settings → Emails and read the list. Order confirmations, cancelled orders, failed orders, on-hold, processing, completed, refunded, customer invoices, customer notes, account details, password resets. Every one of them fires after an order exists. There is no abandoned cart email, and no toggle that adds one.

That absence is the whole design problem. A cart in WooCommerce is not a record you can query. It lives in a session row keyed to a cookie, and when the cookie goes, the cart goes with it. The only durable artifact that carries both a product list and an email address is an order — usually one sitting in pending or failed.

So the sequence is built on orders, not carts. That single decision determines what you can send, when you can send it, and whether your numbers mean anything.

A Cart You Can Email Requires an Address

Guest sessions do get stored. WooCommerce keeps them in the wp_woocommerce_sessions table, and the serialized value contains the cart contents. What it does not reliably contain is an email address, because the shopper has not been asked for one yet.

An anonymous session with three items is not a recovery target. It is a bounce-rate risk.

The practical rule: only email a cart that reached checkout. That is the moment WooCommerce collects billing details, and it is also the moment intent is highest. Everything before that is browsing.

There is a wrinkle in modern WooCommerce. Block-based checkouts can create a draft order through the Store API before payment is submitted, stored under a checkout-draft status. Those are the freshest abandonment signal available, and they are explicitly excluded from wc_get_orders() queries using the 'any' status shortcut. If you build on blocks, query checkout-draft directly or you will miss the most recent carts.

Query Candidates With the Order API, Not SQL

The instinct is to write a WP_Query against wp_posts. Do not. Since WooCommerce 8.2, new installs store orders in dedicated tables under High-Performance Order Storage, and the HPOS documentation explains why the posts table is no longer authoritative. WooCommerce’s own order querying guide says plugin developers should avoid direct SQL because the storage layer can change under them.

wc_get_orders() handles both storage modes. It also supports timestamp comparisons, which is what a recovery job needs:

$cutoff = time() - HOUR_IN_SECONDS;

$candidates = wc_get_orders(
    array(
        'status'       => array( 'wc-pending', 'wc-failed' ),
        'date_created' => '<' . $cutoff,
        'created_via'  => 'checkout',
        'limit'        => 50,
        'orderby'      => 'date',
        'order'        => 'ASC',
        'return'       => 'ids',
    )
);

Four things in that argument list are doing real work.

date_created with a <TIMESTAMP value selects orders older than the cutoff, in UTC. The one-hour offset is the wait before the first email, not a magic number — one hour is long enough that a shopper who was interrupted has finished, and short enough that the products are still in their head.

created_via => 'checkout' filters out orders created by the REST API, by subscriptions renewing, or by an admin manually. A renewal order that is pending is a payment problem, not an abandoned cart, and emailing it will confuse a long-standing customer.

limit caps the batch. A recovery job that tries to process ten thousand orders in one request will time out halfway through and send duplicate emails when it retries.

status excludes on-hold, which is not abandonment — it usually means the order is waiting on a bank transfer the customer already initiated.

Now the guard that most implementations skip. An email address can have a pending order and a completed order at the same time, because the shopper retried. Before sending, check whether that customer already bought:

$already_bought = wc_get_orders(
    array(
        'customer' => $order->get_billing_email(),
        'status'   => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ),
        'limit'    => 1,
        'return'   => 'ids',
    )
);

if ( $already_bought ) {
    $order->update_meta_data( '_ac_suppressed', 'bought_elsewhere' );
    $order->save();
    return;
}

Sending “you left something behind” to someone who ordered twenty minutes ago is the fastest way to teach a customer to ignore your email address.

Schedule at Checkout, Decide at Send Time

The trigger point is the order creation, not the send. Register the first email when the order is created, then re-check the order’s state when the job actually runs.

add_action(
    'woocommerce_checkout_order_processed',
    function ( $order_id ) {
        as_schedule_single_action(
            time() + HOUR_IN_SECONDS,
            'ac_recovery_send',
            array( 'order_id' => $order_id, 'step' => 1 ),
            'ac-recovery'
        );
    }
);

as_schedule_single_action() is Action Scheduler, which ships with WooCommerce. It survives a page request ending, retries on failure, and gives you a log of what ran. WP-Cron does not.

The job itself has to assume everything changed in the last hour:

add_action(
    'ac_recovery_send',
    function ( $order_id, $step ) {
        $order = wc_get_order( $order_id );

        if ( ! $order ) {
            return;
        }

        if ( $order->is_paid() || $order->has_status( array( 'processing', 'completed', 'cancelled', 'refunded' ) ) ) {
            return;
        }

        if ( $order->get_meta( "_ac_sent_{$step}" ) ) {
            return;
        }

        // Build and send the message here.

        $order->update_meta_data( "_ac_sent_{$step}", time() );
        $order->save();

        if ( $step < 3 ) {
            $offsets = array( 2 => DAY_IN_SECONDS, 3 => 3 * DAY_IN_SECONDS );
            $created = $order->get_date_created()->getTimestamp();
            $next    = max( $created + $offsets[ $step + 1 ], time() + MINUTE_IN_SECONDS );

            as_schedule_single_action(
                $next,
                'ac_recovery_send',
                array( 'order_id' => $order_id, 'step' => $step + 1 ),
                'ac-recovery'
            );
        }
    },
    10,
    2
);

The _ac_sent_{$step} meta is the idempotency lock. Action Scheduler can retry a failed action, and a retry that sends a second copy of the same email looks like a bug to the customer.

Chain the next email from inside the job rather than scheduling all three up front. If the customer completes the order after email one, the chain stops on its own — the status check at the top of the job returns early.

A Failed Payment Is Not an Abandoned Cart

The two order statuses in the query above are not the same problem, and they should not receive the same email.

A pending order means checkout completed and payment never did. The customer may have closed the tab, hit a declined card, or been redirected somewhere that lost them.

A failed order means the gateway answered and said no. That is a broken transaction, not a wandering attention span. The email should say what happened and offer a payment link, in the plainest language you have:

Your payment for order 1042 did not go through, so nothing has been charged. You can finish the order here — the items are still reserved.

That message addresses the actual problem, which a discount does not. Keep it separate from the marketing sequence, send it within fifteen minutes, and do not attach a coupon to it. A discount on a declined card reads as a bribe for a technical failure.

Check your gateway’s settings before you assume the status is accurate. Some gateways leave failed attempts in pending indefinitely, which means every one of them enters the recovery sequence as if the shopper wandered off.

Three Emails, Three Different Jobs

A sequence works when each message has a distinct reason to exist. Three emails is enough to cover the three reasons people leave without buying.

One hour: the reminder. No discount. The product name, a thumbnail, the price, one button back to the cart. Subject line that states the fact rather than manufacturing urgency: “Your cart is still here” or the product name plus “saved”. Send it from a person’s name, not “The Team”, and set a reply-to address someone reads.

Twenty-four hours: the objection. This is where you answer what stopped them. Shipping cost and delivery window, the return policy, one review from a customer who bought the same item. If you discount at all, do it here, once, with an expiry. Discounting in email one trains your repeat customers to abandon on purpose.

Seventy-two hours: the close. Say the cart is about to be cleared, give the exact date, and include the opt-out in the body rather than only in the footer. Then actually clear it or stop emailing. A fourth email to someone who ignored three is how a domain gets marked as spam.

Treat the intervals as a hypothesis. A store selling $4 consumables and a store selling $4,000 furniture have different decision windows, and the only way to know yours is to move the intervals and compare recovered revenue per eligible cart.

The Recovery Link Has to Rebuild the Cart

This is where most DIY sequences quietly fail. The email links to /cart/, the shopper clicks it on a different device or after their session expired, and the cart is empty. They assume the items sold out and leave.

The cart is session state. A URL alone does not restore it.

You have two options. The shortcut is WooCommerce’s add-to-cart query string, which works for simple products:

/?add-to-cart=1042&quantity=1

That is relative to the store’s home URL, and it is fine for a single simple product. It breaks on variable products, on bundles, on anything with custom cart item data, and it collapses a five-item cart into one item.

The durable option is a token. WooCommerce already generates one per order — the order key used on guest order links — so build a URL that carries it and let an endpoint rebuild the cart from the order’s line items:

add_action( 'template_redirect', function () {
    if ( empty( $_GET['ac_recover'] ) || empty( $_GET['order'] ) ) {
        return;
    }

    $order = wc_get_order( absint( $_GET['order'] ) );

    if ( ! $order || ! hash_equals( $order->get_order_key(), sanitize_text_field( wp_unslash( $_GET['ac_recover'] ) ) ) ) {
        wp_safe_redirect( wc_get_cart_url() );
        exit;
    }

    WC()->cart->empty_cart();

    foreach ( $order->get_items() as $item ) {
        WC()->cart->add_to_cart(
            $item->get_product_id(),
            $item->get_quantity(),
            $item->get_variation_id()
        );
    }

    wp_safe_redirect( wc_get_checkout_url() );
    exit;
} );

get_order_key() is already a per-order random string that WooCommerce uses for guest order links, so you do not need a new column. hash_equals() prevents timing comparison leaks. Sending the shopper to checkout rather than the cart page removes one click from the path.

Where that link lands is theme territory. The custom theme conversion piece covers how much of the cart and checkout layout a theme controls, which is the difference between a rebuilt cart that looks normal and one that looks broken.

Test this with a logged-out browser on a different network. Cookies from your admin session will hide the failure.

Discounts Are the Most Expensive Line Item

A ten percent code on a thirty percent margin gives away a third of the gross profit on that order — including on the orders that would have converted without a code at all.

The arithmetic is worth running before you set a code:

  • Eligible carts per month: 400
  • Baseline recovery with no email: 20 orders
  • Recovery with emails, no discount: 60 orders
  • Recovery with emails and a 10% code: 70 orders

The ten extra orders came at the cost of ten percent off all seventy. If the average order is $80 with $24 of margin, the discount costs $8 per recovered order. On a $24 margin that is a third of the profit, bought for ten additional orders out of four hundred.

Discounts are not wrong. They are just a paid lever, and most stores pull it before testing whether the reminder alone works. Run the sequence without a code for a month, then add the code to email two only, then compare.

Consent, Opt-Outs, and the Legal Floor

An abandoned cart email is a commercial message. In the United States, the CAN-SPAM Act sets the requirements, and 15 U.S.C. § 7704 is the section that covers them: accurate header information, a subject line that is not deceptive, a clear identification that the message is an advertisement, a valid physical postal address, and a functioning opt-out that is honored within ten business days.

The opt-out has to work without a login, without a survey, and without a “reason for leaving” field. One click, recorded, honored.

If you sell into the EU or the UK, the bar is higher. Consent is the default requirement for marketing email to individuals, and the narrow exception for existing customers applies to people who already bought from you — not to people who filled in a checkout form and left. Treating a submitted form as consent is the interpretation that gets stores into trouble.

Practical implementation:

  • Send only to addresses entered at checkout by the person completing it.
  • Record the timestamp and the source of that address on the order.
  • Put the unsubscribe link in the message body, not only in a header or footer image.
  • Honor the opt-out across every list, not just this sequence.
  • Keep the suppression record after the order is deleted.

None of that is legal advice, and none of it is optional. A recovery sequence that ignores consent is a spam pipeline with a checkout form attached.

Measure Against a Holdout or You Are Guessing

The number every dashboard shows — “recovered revenue” — is not a measurement. It does not tell you what would have happened without the emails, and a meaningful share of those customers were going to come back anyway.

The fix is a holdout. Assign a random slice of eligible carts to receive nothing, count them the same way, and compare.

add_action(
    'woocommerce_checkout_create_order',
    function ( $order ) {
        if ( 0 === random_int( 0, 9 ) ) {
            $order->update_meta_data( '_ac_holdout', 'yes' );
        }
    }
);

Then exclude holdout orders from the send job and compare revenue per eligible cart in each group:

incremental revenue per eligible cart =
  ( revenue_treatment / eligible_treatment )
  - ( revenue_holdout  / eligible_holdout )

A ten percent holdout catches a large effect and misses a small one. If the difference comes back near zero, the sequence is a branding exercise, not a revenue channel, and you should know that before you spend a year maintaining it.

Segment the result by first-time versus returning customers. A returning customer who abandons is often waiting on a restock or comparing a price, and the reminder behaves differently for them.

Deliverability Decides Whether Any of This Works

A sequence that lands in spam recovers nothing, and WordPress is a poor place to send bulk mail from. PHP’s mail() on a shared host has no authentication, no bounce handling, and an IP reputation shared with every other site on the box.

Send through an authenticated provider instead. You need three DNS records before the first email:

  • SPF, which authorizes the sending service in your domain’s TXT record.
  • DKIM, which signs each message so the receiver can verify it was not altered.
  • DMARC, which tells receivers what to do when the first two fail.

The domain email setup and DNS check walks through the records. Verify them with a DNS lookup before you send anything, not after the first hundred messages bounce.

Choosing the provider is a separate decision from authenticating it. The email marketing platform comparison covers which services handle bounce processing, suppression lists, and per-message triggers without a separate automation tool.

Send marketing mail from a subdomain rather than the root domain. A reputation problem on the marketing subdomain then does not take order confirmations down with it — and order confirmations are the emails you cannot afford to lose.

Warm up slowly if the domain is new. A few hundred messages a day for the first week, then increase. A sudden jump from zero to five thousand is the pattern receivers are trained to distrust.

Before You Turn It On

A short checklist, in the order it matters:

  1. Confirm your gateway writes failed and pending accurately, and pick which one triggers which message.
  2. Add the _ac_sent_{$step} lock before the first send, not after the first duplicate.
  3. Test the recovery link logged out, on cellular data, with an expired session.
  4. Verify SPF, DKIM, and DMARC with a real DNS query.
  5. Put the postal address and the opt-out in the template, and confirm the opt-out works without a login.
  6. Assign the holdout before launch so month one already has a baseline.
  7. Set the send job to stop on paid, cancelled, and refunded orders.

If checkout itself is the bottleneck — slow, error-prone, losing people before the order is created — recovery email is treating a symptom. The checkout and order processing guide covers the upstream fixes, and the sales funnel walkthrough shows where this sequence sits in the larger path. If you are migrating custom order code to HPOS, read the migration notes before touching the query layer, because a recovery job built on wp_posts will return nothing on a store that has moved.

Share: tw in
Faraz Frank

About Faraz Frank

Author at WP Frank. Writing about WordPress development, design, and best practices.

View all posts by Faraz Frank →
edit_note

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.