David Lewin David Lewin
E-Commerce, Research & Ads

Abandoned cart SMS and email through an API: Brevo vs WooCommerce plugins

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

WooCommerce core has no abandoned-cart feature, and most recovery plugins store every cart in the WordPress database and send reminders from WP-Cron. A lighter design keeps WordPress to one job: when a shopper who ticked a consent box enters an email or phone number at checkout, send a signed cart snapshot to a small external service, and tell it when the order is paid. The service keeps the carts in SQLite, sends one email after an hour and one SMS after a day through Brevo's API, retries network errors, rate limits and server errors with backoff, texts only during daytime hours in the shopper's time zones and honors one-click unsubscribes. Our tested mu-plugin and Python relay do this with no extra packages. Brevo covers email and SMS in one API with a free email plan; Textmagic is an SMS-first alternative and MailerLite a cheaper marketing tool.

Most shoppers who reach checkout and leave don't come back on their own. One well-timed reminder, an email an hour later and maybe a text the next day, brings some of them back. WooCommerce doesn't do this out of the box, and the usual fix is a plugin that tracks every cart in your database and sends from WP-Cron. This guide builds it differently: WordPress only hands over a cart when the shopper has agreed to reminders, and a small service outside WordPress does the waiting, sending, retrying and record-keeping through Brevo's API.

How this guide was made

We tested the capture plugin in a real WordPress 7.1.3 and WooCommerce 11.2.1 store (PHP 8.4, MariaDB), driving the checkout block through the same Store API requests the block makes, and the classic checkout through its update request. The relay was tested against a mock of Brevo's API built from Brevo's API reference, with failures injected on purpose; we didn't send through Brevo's live API, and we have no recovery-rate figures. Brevo, Textmagic and MailerLite plans come from their pages and from third-party pricing summaries where noted, checked in October 2026. The consent section is a summary, not legal advice.

1. The short answer

Recovery plugin in WordPressBrevo's WooCommerce pluginAPI relay (this guide)
Where carts liveCustom tables in your WordPress databaseBrevo, via its tracking script and contact syncA SQLite file next to the relay
Who sends and whenWP-Cron or Action Scheduler on your web serverBrevo's automation workflowsThe relay's own scheduler, through Brevo's API
Examples and costAutomateWoo (by Woo) $159 a year; MailPoet and CartFlows' plugin have free tiersPlugin free; Brevo plan for sendingA small server or an existing one; Brevo plan and SMS credits
ControlSettings screensVisual workflow editorAll rules in code: consent, timing, quiet hours, retries

Brevo's own plugin is the fastest way to get reminders running and is a good choice if you're happy with its workflow editor. The relay is for stores that want WordPress to stay lean, already run a small service elsewhere, or need rules the editor can't express, such as quiet hours computed per shopper.

2. The abandonment logic

From checkout to reminder
From checkout to reminderShopper at checkout → cart-capture.php → Relay with SQLite → Order paid / After 60 minutes / After 24 hoursShopper at checkout: types an email or phone, ticks a reminder boxShopper at checkouttypes an email or phone, ticks a reminder boxcart-capture.php: sends a signed cart snapshot, only with consent, only when something changedcart-capture.phpsends a signed cart snapshot, only with consent, only whensomething changedRelay with SQLite: saves the cart; every new snapshot restarts the clockRelay with SQLitesaves the cart; every new snapshot restarts the clockOrder paid: relay marks the cart converted, nothing is sentOrder paidrelay marks thecart converted,nothing is sentAfter 60 minutes: one email through Brevo, if email consentAfter 60 minutesone email throughBrevo, if emailconsentAfter 24 hours: one text through Brevo, if SMS consent, 9:00 to 20:00 localAfter 24 hoursone text throughBrevo, if SMSconsent, 9:00 to20:00 local
Each cart gets at most one email and one text, and each contact at most one of each per week across carts. The reminder links to a short URL on the relay, which redirects to a signed link that rebuilds the cart in any browser.

3. Where WooCommerce exposes the cart

There's no "cart abandoned" event in WooCommerce. There are moments when the shopper's contact details reach the server, and the plugin listens to those:

  • Block checkout (the default for new stores since WooCommerce 8.3) sends address and email changes to the Store API's cart/update-customer route, which fires woocommerce_store_api_cart_update_customer_from_request. Checkboxes and other contact fields go to a PATCH of the checkout route. Since WooCommerce 10.8 that route no longer creates a draft order on every change; it fires woocommerce_store_api_checkout_update_draft instead, and its inline documentation names abandoned-cart trackers as the intended use.
  • Classic checkout (the [woocommerce_checkout] shortcode) posts the whole form to update_order_review when an address field changes, firing woocommerce_checkout_update_order_review with the form data. The email field doesn't trigger it by default. Adding the update_totals_on_change class to the email and phone fields and to the consent boxes makes WooCommerce's own script post them too.
  • Paid is a status change to processing, completed or on-hold (bank transfer orders wait in on-hold).

The consent boxes are registered with WooCommerce's additional checkout fields API for the block checkout and as ordinary checkout fields for the classic one, so the shopper's choice is also saved on the order, which gives you a record of consent.

4. The capture plugin

Save it as wp-content/mu-plugins/cart-capture.php and set CART_RELAY_URL and CART_RELAY_SECRET in wp-config.php. Without consent and a valid email or phone, nothing leaves the store. It adds no tables, options or cron jobs; the only thing it stores is a hash in the WooCommerce session, so it doesn't send the same snapshot twice.

<?php
/**
 * Plugin Name: Cart capture for abandoned-cart reminders
 * Description: When a shopper who ticked a reminder box enters an email or phone number at checkout, sends a small
 * signed snapshot of the cart to an external relay, and a "converted" event once the order is paid. The relay decides
 * when to remind; WordPress stores nothing. Also rebuilds the cart from the signed link in a reminder.
 *
 * wp-config.php: CART_RELAY_URL ('https://relay.example.com/events'), CART_RELAY_SECRET (long random string)
 */

use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\Blocks\Package;

function cartcap_consents() {
	return [
		'email' => 'Email me a reminder if I leave items in my cart',
		'sms'   => 'Text me one cart reminder. Msg & data rates may apply; reply STOP to opt out.',
	];
}

// Block checkout (the default since WooCommerce 8.3): consent boxes via the additional checkout fields API.
add_action( 'woocommerce_init', function () {
	foreach ( cartcap_consents() as $id => $label ) {
		woocommerce_register_additional_checkout_field( [ 'id' => "cartcap/$id", 'label' => $label, 'location' => 'contact', 'type' => 'checkbox' ] );
	}
} );

// Fires when the shopper changes their address/email (update-customer) and when a checkbox changes (checkout PATCH).
$cartcap_block = function () {
	$c      = WC()->customer;
	$fields = Package::container()->get( CheckoutFields::class );
	cartcap_send_cart( $c->get_billing_email(), $c->get_billing_phone(), $c->get_billing_country(), $c->get_billing_state(),
		$c->get_billing_first_name(), (bool) $fields->get_field_from_object( 'cartcap/email', $c, 'other' ),
		(bool) $fields->get_field_from_object( 'cartcap/sms', $c, 'other' ) );
};
add_action( 'woocommerce_store_api_cart_update_customer_from_request', $cartcap_block );
add_action( 'woocommerce_store_api_checkout_update_draft', $cartcap_block );

// Classic [woocommerce_checkout]: plain checkboxes, and "update_totals_on_change" makes WooCommerce's own checkout
// script post the form (update_order_review) when the email, phone or a box changes.
add_filter( 'woocommerce_checkout_fields', function ( $f ) {
	foreach ( [ 'billing_email', 'billing_phone' ] as $k ) {
		if ( isset( $f['billing'][ $k ] ) ) {
			$f['billing'][ $k ]['class'][] = 'update_totals_on_change';
		}
	}
	foreach ( cartcap_consents() as $id => $label ) {
		$f['billing']["cartcap_$id"] = [ 'type' => 'checkbox', 'label' => $label, 'class' => [ 'update_totals_on_change' ], 'priority' => 125 ];
	}
	return $f;
} );
add_action( 'woocommerce_checkout_update_order_review', function ( $posted ) {
	parse_str( $posted, $p );
	cartcap_send_cart( $p['billing_email'] ?? '', $p['billing_phone'] ?? '', $p['billing_country'] ?? '', $p['billing_state'] ?? '',
		$p['billing_first_name'] ?? '', ! empty( $p['cartcap_email'] ), ! empty( $p['cartcap_sms'] ) );
} );

function cartcap_send_cart( $email, $phone, $country, $state, $name, $email_ok, $sms_ok ) {
	$email = $email_ok && is_email( $email ) ? $email : '';
	$phone = $sms_ok ? preg_replace( '/[^\d+]/', '', $phone ) : '';
	if ( ! WC()->cart || WC()->cart->is_empty() || ( ! $email && ! $phone ) ) {
		return; // no consent, nothing to remind about: nothing leaves the store
	}
	$items = [];
	foreach ( WC()->cart->get_cart() as $line ) {
		$items[] = [ 'id' => $line['variation_id'] ?: $line['product_id'], 'qty' => $line['quantity'],
			'name' => $line['data']->get_name(), 'price' => wc_format_decimal( wc_get_price_to_display( $line['data'] ), 2 ) ];
	}
	$snap = [ 'event' => 'cart', 'cart' => WC()->session->get_customer_id(), 'email' => $email, 'phone' => $phone,
		'country' => $country, 'state' => $state, 'name' => $name, 'items' => $items, 'currency' => get_woocommerce_currency(),
		'total' => WC()->cart->get_total( 'edit' ),
		'restore_url' => add_query_arg( 'cartcap', cartcap_sign( array_map( fn( $i ) => [ $i['id'], $i['qty'] ], $items ) ), wc_get_checkout_url() ) ];
	$hash = md5( wp_json_encode( array_diff_key( $snap, [ 'restore_url' => 1 ] ) ) );
	if ( WC()->session->get( 'cartcap_sent' ) !== $hash ) { // checkout fires these hooks on every keystroke pause
		WC()->session->set( 'cartcap_sent', $hash );
		cartcap_post( $snap );
	}
}

add_action( 'woocommerce_order_status_changed', function ( $id, $from, $to, $order ) {
	$paid = [ 'processing', 'completed', 'on-hold' ]; // on-hold: bank transfer and cheque orders waiting for payment
	if ( in_array( $to, $paid, true ) && ! in_array( $from, $paid, true ) ) {
		cartcap_post( [ 'event' => 'converted', 'cart' => WC()->session ? WC()->session->get_customer_id() : '',
			'email' => $order->get_billing_email(), 'phone' => preg_replace( '/[^\d+]/', '', $order->get_billing_phone() ) ] );
	}
}, 10, 4 );

function cartcap_post( $data ) {
	$body = wp_json_encode( $data );
	// Non-blocking, so checkout doesn't wait for the relay's answer; it still waits for the connection, hence the short
	// timeout. If the relay is down, that snapshot is lost, not the sale.
	wp_remote_post( CART_RELAY_URL, [ 'body' => $body, 'blocking' => false, 'timeout' => 0.5, 'headers' => [
		'Content-Type' => 'application/json', 'X-Signature' => hash_hmac( 'sha256', $body, CART_RELAY_SECRET ) ] ] );
}

// Restore link: the items are in the link itself, signed and valid for 14 days, so there is no lookup table.
function cartcap_sign( $items ) {
	$p = rtrim( strtr( base64_encode( wp_json_encode( [ $items, time() + 14 * DAY_IN_SECONDS ] ) ), '+/', '-_' ), '=' );
	return $p . '.' . hash_hmac( 'sha256', $p, CART_RELAY_SECRET );
}

add_action( 'wp_loaded', function () {
	if ( empty( $_GET['cartcap'] ) || ! WC()->cart ) {
		return;
	}
	[ $p, $sig ] = array_pad( explode( '.', sanitize_text_field( wp_unslash( $_GET['cartcap'] ) ), 2 ), 2, '' );
	$data        = json_decode( base64_decode( strtr( $p, '-_', '+/' ) ), true );
	if ( hash_equals( hash_hmac( 'sha256', $p, CART_RELAY_SECRET ), $sig ) && is_array( $data ) && $data[1] > time()
		&& WC()->cart->is_empty() ) { // a newer cart in this browser wins over the reminder
		foreach ( $data[0] as [ $id, $qty ] ) {
			WC()->cart->add_to_cart( (int) $id, (int) $qty );
		}
	}
	wp_safe_redirect( remove_query_arg( 'cartcap' ) );
	exit;
}, 20 );

The restore link carries the cart's product IDs and quantities, signed with the shared secret and valid for 14 days, so restoring a cart needs no lookup. If the browser already has a cart, that newer cart wins and the link just opens checkout.

5. The relay

The relay is one Python file using only the standard library: an HTTP server for the store's events, the short links and unsubscribes, and a scheduler thread that checks for due reminders every minute. SQLite in WAL mode holds the carts. A single file on disk is plenty for one store's carts and survives restarts; Redis would add a moving part without adding anything here. If you'd rather have no long-running process at all, call tick() from cron every minute and run the HTTP part behind any small web server.

"""Abandoned-cart relay: receives signed cart snapshots from the store (cart-capture.php), keeps them in SQLite, and
sends one reminder email and one reminder SMS through Brevo's API if the order isn't paid in time. Retries network
errors, 429 and 5xx with backoff; honors opt-outs; texts only between 9:00 and 20:00 in every time zone of the
shopper's country (state for Alaska/Hawaii). Python 3.9+, standard library only (on Windows: pip install tzdata).

  RELAY_SECRET=... BREVO_KEY=... [email protected] PUBLIC_URL=https://relay.example.com python cart_relay.py
"""
import hashlib, hmac, html, json, os, random, re, secrets, sqlite3, threading, time, urllib.error, urllib.parse, urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from time import sleep
from zoneinfo import ZoneInfo

env = os.environ.get
SECRET = env('RELAY_SECRET', '').encode()
BREVO = env('BREVO_API', 'https://api.brevo.com')
STORE = env('STORE_NAME', 'Our store')
PUBLIC = env('PUBLIC_URL', 'http://127.0.0.1:8891')
EMAIL_AFTER = float(env('EMAIL_AFTER_MIN', 60)) * 60
SMS_AFTER = float(env('SMS_AFTER_MIN', 24 * 60)) * 60
MAX_AGE = 72 * 3600     # older carts are never reminded
PER_CONTACT = 7 * 86400  # at most one reminder of each kind per contact per week, across carts
ZONES = {'US': ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles'],
         'CA': ['America/Halifax', 'America/Toronto', 'America/Winnipeg', 'America/Edmonton', 'America/Vancouver'],
         'AU': ['Australia/Sydney', 'Australia/Adelaide', 'Australia/Brisbane', 'Australia/Perth'],
         'GB': ['Europe/London'], 'IE': ['Europe/Dublin'], 'DE': ['Europe/Berlin'], 'FR': ['Europe/Paris'],
         'NL': ['Europe/Amsterdam'], 'ES': ['Europe/Madrid'], 'IT': ['Europe/Rome'], 'IL': ['Asia/Jerusalem']}
STATE_ZONES = {('US', 'AK'): ['America/Anchorage'], ('US', 'HI'): ['Pacific/Honolulu']}
DIAL = {'US': '1', 'CA': '1', 'AU': '61', 'GB': '44', 'IE': '353', 'DE': '49', 'FR': '33', 'NL': '31', 'ES': '34',
        'IT': '39', 'IL': '972'}
_local = threading.local()


def db():
    if not hasattr(_local, 'c'):
        c = _local.c = sqlite3.connect(env('RELAY_DB', 'carts.sqlite3'), timeout=10, isolation_level=None)
        c.row_factory = sqlite3.Row
        c.execute('PRAGMA journal_mode=WAL')
        c.executescript("""
        CREATE TABLE IF NOT EXISTS carts (cart TEXT PRIMARY KEY, email TEXT, phone TEXT, country TEXT, state TEXT,
          name TEXT, items TEXT, total TEXT, currency TEXT, restore_url TEXT, short TEXT UNIQUE, updated REAL,
          status TEXT DEFAULT 'open', emailed REAL, texted REAL, fails INTEGER DEFAULT 0, last_error TEXT);
        CREATE TABLE IF NOT EXISTS optout (contact TEXT PRIMARY KEY, at REAL);""")
    return _local.c


def sign(s):
    return hmac.new(SECRET, s.encode(), hashlib.sha256).hexdigest()


def e164(phone, country):
    """+1 555 123 4567 / (555) 123-4567 / 07700 900123 -> digits with country code, as Brevo's SMS API takes them."""
    phone = re.sub(r'[^\d+]', '', phone)
    if phone.startswith('+'):
        return phone[1:]
    if phone.startswith('00'):
        return phone[2:]
    code = DIAL.get(country)
    if not code:
        return None  # unknown country and no +: don't guess
    if code == '1':
        return phone if len(phone) == 11 and phone[0] == '1' else '1' + phone
    return code + phone.lstrip('0')


def sms_window_open(country, state, now):
    zones = STATE_ZONES.get((country, state)) or ZONES.get(country) or [env('STORE_TZ', 'UTC')]
    return all(9 <= datetime.fromtimestamp(now, ZoneInfo(z)).hour < 20 for z in zones)


def record(ev, now):
    c = db()
    if ev['event'] == 'converted':
        c.execute("UPDATE carts SET status='converted' WHERE status='open' AND (cart=? OR (email!='' AND email=?) "
                  "OR (phone!='' AND phone=?))", (ev.get('cart', ''), ev.get('email', ''), ev.get('phone', '')))
        return
    # A new snapshot of a cart that was already reminded or bought starts over; the per-contact limit still applies.
    c.execute("""INSERT INTO carts (cart, email, phone, country, state, name, items, total, currency, restore_url, short, updated)
      VALUES (:cart, :email, :phone, :country, :state, :name, :items, :total, :currency, :restore_url, :short, :now)
      ON CONFLICT(cart) DO UPDATE SET email=excluded.email, phone=excluded.phone, country=excluded.country,
        state=excluded.state, name=excluded.name, items=excluded.items, total=excluded.total, currency=excluded.currency,
        restore_url=excluded.restore_url, updated=excluded.updated,
        emailed=CASE WHEN status='open' THEN emailed END, texted=CASE WHEN status='open' THEN texted END,
        fails=CASE WHEN status='open' THEN fails ELSE 0 END, status=CASE WHEN status='optout' THEN 'optout' ELSE 'open' END""",
              {**{k: ev.get(k, '') for k in ('cart', 'email', 'phone', 'country', 'state', 'name', 'total', 'currency', 'restore_url')},
               'items': json.dumps(ev.get('items', [])), 'short': secrets.token_urlsafe(6), 'now': now})


def brevo(path, payload):
    """POST to Brevo. Network errors, 429 and 5xx are retried with exponential backoff (honoring Brevo's
    x-sib-ratelimit-reset); other 4xx raise at once, since sending the same request again can't fix them."""
    for attempt in range(5):
        req = urllib.request.Request(BREVO + path, json.dumps(payload).encode(), method='POST', headers={
            'api-key': env('BREVO_KEY', ''), 'Content-Type': 'application/json', 'Accept': 'application/json'})
        try:
            with urllib.request.urlopen(req, timeout=15) as r:
                return json.loads(r.read() or b'{}')
        except urllib.error.HTTPError as e:  # before URLError: HTTPError is a subclass of it
            if e.code != 429 and e.code < 500:
                raise RuntimeError(f'Brevo {e.code}: {e.read()[:200].decode(errors="replace")}') from None
            wait, err = float(e.headers.get('x-sib-ratelimit-reset') or e.headers.get('Retry-After') or 0), f'Brevo {e.code}'
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            wait, err = 0, f'network: {getattr(e, "reason", e)}'
        if attempt == 4:
            raise RuntimeError(f'{err} after 5 attempts')
        sleep(min(60, max(wait, 2 ** attempt)) + random.random())


def optout_url(contact):
    return f'{PUBLIC}/optout?' + urllib.parse.urlencode({'c': contact, 's': sign(contact)})


def send_email(c):
    items, link, out = json.loads(c['items']), f'{PUBLIC}/r/{c["short"]}', optout_url(c['email'])
    rows = ''.join(f'<li>{i["qty"]} &times; {html.escape(i["name"])}</li>' for i in items)
    body = {'sender': {'email': env('SENDER_EMAIL'), 'name': STORE}, 'to': [{'email': c['email'], 'name': c['name'] or c['email']}],
            'tags': ['abandoned-cart'], 'headers': {'List-Unsubscribe': f'<{out}>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'}}
    if env('EMAIL_TEMPLATE_ID'):  # a Brevo template, which gets the same values as params
        body |= {'templateId': int(env('EMAIL_TEMPLATE_ID')), 'params': {'name': c['name'], 'items': items, 'total': c['total'],
                                                                         'currency': c['currency'], 'link': link, 'optout': out}}
    else:
        body |= {'subject': 'You left something in your cart',
                 'htmlContent': f'<p>Hi {html.escape(c["name"] or "there")},</p><p>Your cart is saved:</p><ul>{rows}</ul>'
                                f'<p><a href="{link}">Return to your cart</a></p>'
                                f'<p style="font-size:12px">Don\'t want reminders? <a href="{out}">Unsubscribe</a>.</p>'}
    brevo('/v3/smtp/email', body)


def send_sms(c, number):
    n = sum(i['qty'] for i in json.loads(c['items']))
    brevo('/v3/transactionalSMS/send', {'sender': env('SMS_SENDER', STORE[:11]), 'recipient': number, 'type': 'marketing',
                                         'tag': 'abandoned-cart', 'content': f'{STORE}: you left {n} item{"s" * (n != 1)} in your cart. '
                                                                             f'Finish here: {PUBLIC}/r/{c["short"]} Reply STOP to opt out'})


def run_step(c, col, send, now):
    # Claim the step first, so two ticks (or two relay processes) can't both send it.
    if db().execute(f'UPDATE carts SET {col}=? WHERE cart=? AND {col} IS NULL', (now, c['cart'])).rowcount != 1:
        return
    try:
        send()
    except Exception as e:  # give the step back; after 3 failures the cart is skipped and the error kept
        db().execute(f'UPDATE carts SET {col}=NULL, fails=fails+1, last_error=? WHERE cart=?', (str(e)[:300], c['cart']))


def tick(now=None):
    now, c = now or time.time(), db()
    base = ("SELECT * FROM carts WHERE status='open' AND fails<3 AND updated BETWEEN ? AND ? AND {col} IS NULL AND {f}!='' "
            "AND {f} NOT IN (SELECT contact FROM optout) AND NOT EXISTS (SELECT 1 FROM carts o WHERE o.{f}=carts.{f} AND o.{col}>?)")
    for row in c.execute(base.format(col='emailed', f='email'), (now - MAX_AGE, now - EMAIL_AFTER, now - PER_CONTACT)).fetchall():
        run_step(row, 'emailed', lambda: send_email(row), now)
    for row in c.execute(base.format(col='texted', f='phone'), (now - MAX_AGE, now - SMS_AFTER, now - PER_CONTACT)).fetchall():
        number = e164(row['phone'], row['country'])
        if number and sms_window_open(row['country'], row['state'], now):  # outside the window: next tick tries again
            run_step(row, 'texted', lambda: send_sms(row, number), now)


class Handler(BaseHTTPRequestHandler):
    def reply(self, code, body=b'', ctype='text/plain', location=None):
        self.send_response(code)
        self.send_header('Content-Type', ctype)
        if location:
            self.send_header('Location', location)
        self.end_headers()
        self.wfile.write(body)

    def optout(self, q):
        contact = q.get('c', [''])[0]
        if not hmac.compare_digest(sign(contact), q.get('s', [''])[0]):
            return self.reply(403, b'bad link')
        db().execute('INSERT OR IGNORE INTO optout VALUES (?, ?)', (contact, time.time()))
        db().execute("UPDATE carts SET status='optout' WHERE email=? OR phone=?", (contact, contact))
        self.reply(200, b'<p>You will not get cart reminders from us again.</p>', 'text/html')

    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        if u.path == '/optout':
            return self.optout(urllib.parse.parse_qs(u.query))
        row = db().execute('SELECT restore_url FROM carts WHERE short=?', (u.path[3:],)).fetchone() if u.path.startswith('/r/') else None
        self.reply(302, location=row['restore_url']) if row else self.reply(404, b'not found')

    def do_POST(self):
        u = urllib.parse.urlparse(self.path)
        n = int(self.headers.get('Content-Length') or 0)
        if n > 65536:
            return self.reply(413)
        raw = self.rfile.read(n)
        if u.path == '/optout':  # RFC 8058 one-click unsubscribe from the mail client
            return self.optout(urllib.parse.parse_qs(u.query))
        if u.path != '/events' or not hmac.compare_digest(hmac.new(SECRET, raw, hashlib.sha256).hexdigest(),
                                                          self.headers.get('X-Signature', '')):
            return self.reply(401)
        record(json.loads(raw), time.time())
        self.reply(204)

    def log_message(self, *a):
        pass


def scheduler():
    while True:
        try:
            tick()
        except Exception as e:
            print('tick failed:', e, flush=True)
        sleep(60)


if __name__ == '__main__':
    assert SECRET, 'set RELAY_SECRET'
    threading.Thread(target=scheduler, daemon=True).start()
    port = int(env('PORT', 8891))
    print(f'relay on :{port}', flush=True)
    ThreadingHTTPServer(('0.0.0.0', port), Handler).serve_forever()

Running it

  1. Put it on any small server reachable over HTTPS from the store and from shoppers' phones (the short links and unsubscribe URLs point at it). Caddy or nginx in front handles TLS.
  2. Set RELAY_SECRET (the same value as CART_RELAY_SECRET), BREVO_KEY, SENDER_EMAIL on a domain verified in Brevo, PUBLIC_URL, STORE_NAME, and for SMS a SMS_SENDER of up to 11 characters. EMAIL_AFTER_MIN and SMS_AFTER_MIN change the timing; EMAIL_TEMPLATE_ID switches from the built-in HTML to a Brevo template that receives the same values as params.
  3. Run it under systemd or in a container with the SQLite file on a volume. It's the same shape of service as the webhook relays and workers in our Railway vs VPS guide.

6. Retries without double sends

Brevo's API returns 201 for an accepted email or text. Everything else falls into two groups, and the relay treats them differently:

  • Worth retrying: network errors, timeouts, 429 (rate limited) and 5xx. The relay retries up to 5 times with exponential backoff (1, 2, 4, 8 seconds plus jitter), and on a 429 waits at least as long as Brevo's x-sib-ratelimit-reset header says. Brevo documents 1,000 requests per second for the transactional email endpoint, so a store will rarely hit it.
  • Not worth retrying: other 4xx, such as an invalid address, a missing parameter or no SMS credits. Sending the same request again can't fix them, so the relay records the error at once.

Before sending, the relay claims the step in the database (UPDATE ... WHERE emailed IS NULL), so two scheduler ticks, or two copies of the relay, can't both send the same reminder. If sending fails after the retries, the claim is released and the failure counted; after three failed rounds the cart is skipped and the last error kept for you to read. One gap remains: if Brevo accepts a request but the answer is lost to a timeout, the retry sends a second copy. Brevo's API reference shows an IdempotencyKey header and a duplicate_request error but doesn't document how they work, so we didn't rely on them.

A cart reminder is marketing, not a transactional message: it asks someone to buy. That puts it under marketing rules in most places.

  • Email in the EU and UK: the ePrivacy rules (PECR in the UK) require consent for marketing email, with a narrow "soft opt-in" for existing customers, which some regulators extend to someone in the middle of a purchase. An unticked box that the shopper ticks is the clear option, and it's what the plugin uses.
  • Texts in the US: the TCPA requires prior express written consent for marketing texts sent with automated systems, a clear way to opt out (reply STOP), and no messages before 8:00 or after 21:00 at the recipient's location. Some states are stricter; Florida's law, for example, ends the window at 20:00 and limits messages on the same subject.
  • Quiet hours in code: the relay sends texts only when it's between 9:00 and 20:00 in every time zone of the shopper's country (for the US, all four mainland zones, and Alaska or Hawaii by billing state). That's inside the federal and Florida windows. For countries it has no zones for, it uses your store's time zone.
  • Unsubscribe: each email carries a signed unsubscribe link and the List-Unsubscribe and List-Unsubscribe-Post headers for one-click unsubscribe, which the relay handles. For texts, people reply STOP to Brevo, which processes the opt-out on its side; check in your account that those numbers are also blocked for API sends, or pass them to the relay's unsubscribe.
  • Keep it to one or two messages. One email and one text per cart, and one of each per contact per week, is well inside what shoppers tolerate and what the rules allow.

8. Test results

In the real WooCommerce store, through the Store API and the classic checkout:

ScenarioResult
Email and address entered, no box tickedNothing sent to the relay
Email box ticked (checkout PATCH)Snapshot with email, items, total, currency, country and state; no phone
SMS box tickedPhone added, digits only: (212) 555-0142 became 2125550142
Same data sent againNot re-sent
Restore link opened in a new browserRedirect, cart rebuilt with 2 × the product
Restore link with one character changedRedirect, empty cart
Order placed (cash on delivery, processing)Relay marked the cart converted
Classic checkout, SMS box only, UK number 07700 900123Snapshot with phone and no email; relay sends to 447700900123

And the relay against the mock Brevo API, with the clock passed in:

ScenarioResult
30 minutes / 61 minutes after the last changeNothing / one email with the short link, item list and one-click unsubscribe headers
Order paid before the hourNo email
Text due at 01:00 New York timeHeld; sent at the next tick inside the window (12:01 in New York, 9:01 in Los Angeles)
Alaska number at 08:01 localHeld until 10:00 local
Brevo returns 500, 503, then 201Sent once after 2 retries
429 with x-sib-ratelimit-reset: 7Waited 7 s, then sent
400No retry; error recorded; skipped after 3 failed rounds
Connection dropped 5 times / Brevo unreachable4 backoff waits (1, 2, 4, 8 s), then recorded as a failure
4 scheduler ticks at onceOne email
One-click unsubscribe / forged unsubscribe linkNo email afterwards / 403
Second cart for the same email within a week; cart older than 72 hoursNo reminder
Bad signature / body over 64 KB401 / 413

One measured caveat: "non-blocking" requests in WordPress return without waiting for the relay's answer, but they do wait for the connection. In our setup (PHP's built-in server on Windows, which is slow and noisy), the checkout's update request took about 0.5 to 0.7 seconds at the median with or without the plugin. With the relay's address unreachable and the original 1-second timeout, it grew by about 0.4 seconds. We cut the timeout to 0.5 seconds. Keep the relay on a reliable host, close to the store.

9. Brevo: what it brings

Brevo sends email and SMS through one API and one key: POST /v3/smtp/email for email (templates, params, tags, custom headers) and POST /v3/transactionalSMS/send for texts, where marketing texts are marked type: marketing. The older /v3/transactionalSMS/sms route is deprecated. Beyond the API you get contact lists, templates with a visual editor, and automation workflows, including an abandoned-cart workflow through its official WooCommerce plugin, if you later want to move the logic out of code.

PlanPrice per monthNotes
Free$0300 emails a day
StarterFrom about $9From 5,000 emails a month
StandardFrom about $18Adds automation features and more seats
ProfessionalFrom about $499High-volume senders
SMSPrepaid creditsPriced per destination country, bought separately from the email plan

Brevo's pricing page didn't render for our tools, so these figures come from several third-party summaries from mid-2026 that agree with each other; check the current page for your volume and currency.

10. Textmagic for SMS

Textmagic is an SMS-first service: a REST API (POST https://rest.textmagic.com/api/v2/messages, authenticated with the X-TM-Username and X-TM-Key headers), plus a web inbox for two-way conversations, so a customer who replies to a reminder reaches a person. Pricing is pay-as-you-go per message; third-party reviews in 2026 put US texts at about 4.9 cents each at low volume, with number and US 10DLC registration fees on top. If texting is a channel you'll grow (order updates, delivery questions, reminders), it's worth comparing with Brevo's SMS. Swapping it into the relay means changing only send_sms(): brevo()'s retry rules apply unchanged to any HTTP API.

11. MailerLite for marketing email

MailerLite is a simpler, cheaper tool if what you mainly need is newsletters and a few automations: a free plan up to 500 subscribers and paid plans from about $10 a month, with a WooCommerce integration. It has no SMS. If the store's email is mostly a monthly newsletter plus the cart reminder, MailerLite can carry both and the relay can stay email-only.

12. Our opinion: trade-offs

  • Small store, no developer: Brevo's WooCommerce plugin or a recovery plugin with a free tier. It works in an afternoon.
  • A developer on hand and a busy or heavily cached store: the relay. WordPress does almost nothing extra, the rules are code you can test, and switching provider means rewriting two short functions.
  • Email first, SMS second. Email costs next to nothing per message; texts cost money, need stricter consent and annoy people faster. Send the text only if the email didn't work, as the relay does.
  • Don't add a discount by default. Shoppers learn to abandon carts to get one. Try a reminder without one first.

Related: the same idea for Shopify with n8n, keeping transactional email deliverable and scoring orders for fraud.

13. Tools

Top picks

ToolRoleWhy it fits
BrevoEmail and SMS API, templates, automationOne API key for the reminder email and text, a free plan of 300 emails a day, and a workflow editor if you outgrow code.
TextmagicSMS API and two-way textingSMS-first, with a shared inbox for replies; pay-as-you-go.
MailerLiteMarketing email and automationNewsletters plus simple automations at a low price, free up to 500 subscribers.

Alternatives: free tools and references

We don't earn anything from these links.

14. Pitfalls

  • Tracking without consent. Capturing an email as it's typed, before the shopper agrees to anything, is what gets cart tracking into trouble in the EU. The plugin sends nothing until a box is ticked.
  • Numbers without a country code. WooCommerce phone fields are free text. The relay adds the country code for the countries it knows and skips the rest rather than guessing; extend DIAL for your markets.
  • Converted orders still getting reminders. The relay matches the paid order by cart, email or phone. An order placed by phone or by a different email won't match, so keep the delay at an hour or more.
  • Full-page caching on checkout. Checkout must never be cached, and the restore link (?cartcap=) must reach PHP; exclude it in your cache rules.
  • Secrets in two places. The store and the relay share one secret, which signs both the events and the restore links. Rotate it in both at once; old reminder links stop working, which is fine.
  • Forgetting the relay is public. The short links and unsubscribe URL have to be reachable by shoppers. Events are signed, unsubscribes are signed, and the body size is capped; keep it that way.
Bottom line

Keep WordPress to capturing consented carts, and let a small service decide when to remind. Our mu-plugin sends signed cart snapshots from both checkouts and a conversion event when the order is paid; the Python relay sends one email after an hour and one text after a day through Brevo, retries only what can succeed on a retry, never sends the same reminder twice from parallel runs, and keeps texts to daytime in the shopper's time zones. Brevo covers both channels with a free email plan; Textmagic is the SMS specialist and MailerLite the cheaper option for newsletters.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.