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.
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.
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 WordPress | Brevo's WooCommerce plugin | API relay (this guide) | |
|---|---|---|---|
| Where carts live | Custom tables in your WordPress database | Brevo, via its tracking script and contact sync | A SQLite file next to the relay |
| Who sends and when | WP-Cron or Action Scheduler on your web server | Brevo's automation workflows | The relay's own scheduler, through Brevo's API |
| Examples and cost | AutomateWoo (by Woo) $159 a year; MailPoet and CartFlows' plugin have free tiers | Plugin free; Brevo plan for sending | A small server or an existing one; Brevo plan and SMS credits |
| Control | Settings screens | Visual workflow editor | All 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
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-customerroute, which fireswoocommerce_store_api_cart_update_customer_from_request. Checkboxes and other contact fields go to a PATCH of thecheckoutroute. Since WooCommerce 10.8 that route no longer creates a draft order on every change; it fireswoocommerce_store_api_checkout_update_draftinstead, and its inline documentation names abandoned-cart trackers as the intended use. - Classic checkout (the
[woocommerce_checkout]shortcode) posts the whole form toupdate_order_reviewwhen an address field changes, firingwoocommerce_checkout_update_order_reviewwith the form data. The email field doesn't trigger it by default. Adding theupdate_totals_on_changeclass 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"]} × {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
- 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.
- Set
RELAY_SECRET(the same value asCART_RELAY_SECRET),BREVO_KEY,SENDER_EMAILon a domain verified in Brevo,PUBLIC_URL,STORE_NAME, and for SMS aSMS_SENDERof up to 11 characters.EMAIL_AFTER_MINandSMS_AFTER_MINchange the timing;EMAIL_TEMPLATE_IDswitches from the built-in HTML to a Brevo template that receives the same values as params. - 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-resetheader 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.
7. Consent: GDPR, TCPA and quiet hours
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-UnsubscribeandList-Unsubscribe-Postheaders 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:
| Scenario | Result |
|---|---|
| Email and address entered, no box ticked | Nothing sent to the relay |
| Email box ticked (checkout PATCH) | Snapshot with email, items, total, currency, country and state; no phone |
| SMS box ticked | Phone added, digits only: (212) 555-0142 became 2125550142 |
| Same data sent again | Not re-sent |
| Restore link opened in a new browser | Redirect, cart rebuilt with 2 × the product |
| Restore link with one character changed | Redirect, empty cart |
| Order placed (cash on delivery, processing) | Relay marked the cart converted |
Classic checkout, SMS box only, UK number 07700 900123 | Snapshot with phone and no email; relay sends to 447700900123 |
And the relay against the mock Brevo API, with the clock passed in:
| Scenario | Result |
|---|---|
| 30 minutes / 61 minutes after the last change | Nothing / one email with the short link, item list and one-click unsubscribe headers |
| Order paid before the hour | No email |
| Text due at 01:00 New York time | Held; sent at the next tick inside the window (12:01 in New York, 9:01 in Los Angeles) |
| Alaska number at 08:01 local | Held until 10:00 local |
| Brevo returns 500, 503, then 201 | Sent once after 2 retries |
429 with x-sib-ratelimit-reset: 7 | Waited 7 s, then sent |
| 400 | No retry; error recorded; skipped after 3 failed rounds |
| Connection dropped 5 times / Brevo unreachable | 4 backoff waits (1, 2, 4, 8 s), then recorded as a failure |
| 4 scheduler ticks at once | One email |
| One-click unsubscribe / forged unsubscribe link | No email afterwards / 403 |
| Second cart for the same email within a week; cart older than 72 hours | No reminder |
| Bad signature / body over 64 KB | 401 / 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.
| Plan | Price per month | Notes |
|---|---|---|
| Free | $0 | 300 emails a day |
| Starter | From about $9 | From 5,000 emails a month |
| Standard | From about $18 | Adds automation features and more seats |
| Professional | From about $499 | High-volume senders |
| SMS | Prepaid credits | Priced 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
| Tool | Role | Why it fits |
|---|---|---|
| Email and SMS API, templates, automation | One API key for the reminder email and text, a free plan of 300 emails a day, and a workflow editor if you outgrow code. | |
| SMS API and two-way texting | SMS-first, with a shared inbox for replies; pay-as-you-go. | |
| Marketing email and automation | Newsletters 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.
- AutomateWoo: Woo's own automation extension with abandoned-cart emails and SMS through Twilio; $159 a year.
- MailPoet: newsletters and WooCommerce automations inside WordPress, including abandoned-cart emails.
- Additional checkout fields API and the Store API docs: the hooks and routes the plugin uses.
- Brevo API rate limits: per-endpoint limits and the rate-limit headers.
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
DIALfor 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.
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.