WooCommerce Cart Rescue
WooCommerce abandoned-cart recovery with consent-gated capture, HMAC-signed restore links, race-safe email sequences, and revenue attribution.
by thealirazadev · github.com/thealirazadev/woo-cart-rescue · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/thealirazadev/woo-cart-rescue/archive/refs/heads/main.zipA WooCommerce plugin that recovers abandoned carts. It tracks carts for logged-in customers and for guests who give explicit consent at checkout, detects abandonment after a configurable idle window, sends a scheduled recovery email sequence with signed restore links that rebuild the cart, can attach an optional single-use recovery coupon to any step, and reports recovered orders and revenue in the WooCommerce admin. Consent, data retention, and token security are first-class requirements, not afterthoughts.
Stack: WordPress plugin in PHP 8.1+, WooCommerce hooks and email classes, Action Scheduler for all timing, custom database tables with versioned migrations, vanilla JS for the checkout capture script, PHPUnit and PHPCS (WordPress Coding Standards) for quality gates.
Status: v1.1.0 implemented (phases 1-6 complete). Phase 6 adds optional auto-generated recovery coupons.
Screenshots


Install
- Copy this folder to
wp-content/plugins/woo-cart-rescueon a site with WooCommerce active. - Run
composer installfor the development tooling (not required at runtime). - Activate the plugin. Activation is blocked with an admin notice if WooCommerce is not active.
- Configure it under WooCommerce > Cart Rescue, and edit the per-step email subject and heading under WooCommerce > Settings > Emails.
Run
Local development uses @wordpress/env (Docker):
npx wp-env start # local WordPress + WooCommerce
Set the idle window and step delays to 1-2 minutes and run due actions from Tools > Scheduled Actions to exercise the timing flows quickly. Outbound mail in the dev site goes to a local mail catcher (for example Mailpit).
Test
composer install # dev tooling (exact-pinned, lockfile committed)
composer run lint # PHPCS against phpcs.xml.dist (WordPress Coding Standards)
composer run test # PHPUnit
The test suite runs in two modes. With WP_TESTS_DIR pointing at a WordPress core test library the
full integration tests run against real WordPress, WooCommerce, and Action Scheduler; CI runs them
on every push, and docs/testing.md has the exact provisioning commands (wp-env works too but is
not required). Without it, a unit subset (token signing, settings sanitization, merge-tag rendering)
runs with lightweight stubs and the integration test files no-op so the command still passes.
Design decisions
The trade-offs below are the load-bearing ones. Each records the alternative that was rejected and why, so the reasoning survives past the original author.
Consent-gated guest capture
A guest cart is only stored after the shopper enters an email and ticks an unchecked-by-default
consent box at checkout; the browser posts both to an AJAX endpoint that re-validates the nonce,
the email, and consent === "1" server-side before writing anything. Logged-in customers are
captured on cart activity under the existing account relationship. Rejected: silently capturing any
email typed into the checkout field. That would store personal data without a lawful basis and is
exactly the pattern this plugin exists to avoid.
No open or click tracking pixels
Recovery emails carry a signed restore link and an unsubscribe link and nothing else - no tracking pixel, no click-through redirector. The report surfaces outcomes that matter (carts abandoned, emails sent per step, recovered orders and revenue, recovery rate) and states plainly that opens are not tracked. Rejected: an open/click pixel. It leaks the shopper's activity to the store for a metric that does not change what the sequence does, and it undercuts the plugin's privacy posture.
Custom tables over postmeta
Cart records live in four $wpdb-prefixed tables (wcr_carts, wcr_sends, wcr_events,
wcr_optouts) created through dbDelta with a versioned migration runner. Carts are not posts,
guests have no user row, the abandonment sweep needs an indexed (status, last_activity_at) scan,
duplicate-send protection needs a unique (cart_id, step) key, and the report needs aggregate
SUM/COUNT. Postmeta offers none of that and would force fake posts and unindexed LIKE queries.
Trade-off accepted: we own schema migrations, versioned through the wcr_db_version option and an
idempotent, resumable upgrade routine.
Hashed-email opt-out table
An unsubscribe records only sha256(lowercased, trimmed email) in wcr_optouts - never a plaintext
address. The sweep, the send-time recheck, and guest capture all consult it, so a suppression
outlives the cart it came from: retention purges delete the cart row and anonymization nulls its
email, but the opt-out hash remains and keeps future carts for that address suppressed. Rejected:
storing the opt-out as a flag on the cart row. It would vanish the moment the cart was purged or
anonymized, silently resurrecting a suppressed address.
Action Scheduler for all timing
The abandonment sweep, every send, the step chaining, and the daily retention cleanup all run as
Action Scheduler actions in a dedicated group (Action Scheduler ships bundled with WooCommerce, so
this adds no dependency). It survives missed cron ticks, supports group-wide unscheduling, and has
an admin UI for inspection. Rejected: raw WP-Cron, which silently drops overdue events on
low-traffic sites - fatal for a timing-sensitive email sequence. The send row's status, not the
scheduled action, is always authoritative and is reconciled at run time.
Restore click resets the cart to active
Clicking a restore link rebuilds the session cart, marks the token used, and returns the cart row to
active with a fresh last_activity_at. A restored cart is a live shopping session again: if the
shopper completes checkout the order hook attributes and closes it; if they wander off, the sweep
re-abandons it after the idle window and the sequence resumes at the first unsent step rather than
repeating one already delivered. Rejected: leaving the cart abandoned after a restore, which would
let the in-flight sequence keep firing against someone actively shopping.
Token security and race safety
Restore/unsubscribe tokens are {send_id}.{expires}.{nonce}.{sig} where sig is an HMAC-SHA256 over
the first three parts under a per-site 32-byte secret. Only sha256(token) is stored on the send
row, so a database leak exposes no usable link; verification is timing-safe (hash_equals), enforces
expiry, and the single-use claim is an atomic UPDATE ... WHERE token_used_at IS NULL. The secret is
dedicated rather than a wp_salt() derivative so unrelated salt rotation never invalidates every
outstanding link. Sends are race-safe by construction: the worker claims a row with
UPDATE ... WHERE status = 'scheduled', then rechecks cart state as late as possible before
dispatch, and both the order-placed and unsubscribe paths flip the cart status before cancelling
sends so an in-flight worker stops itself. Rejected: distributed locks or a queue broker - the
conditional-update pattern is sufficient and adds no infrastructure.
Auto-generated recovery coupons (opt-in, per step, single-use)
Coupons are off by default. When the feature is enabled and a step's coupon toggle is on, the
race-safe send path mints a fresh WC_Coupon for that one send: a unique code, usage_limit = 1,
and an expiry set from the configured validity window. The code is stored on the send row, exposed
to the email subject, heading, and body through a {coupon_code} merge tag, and applied
automatically when the restore link is followed (an expired or missing coupon is skipped silently,
never surfacing a discount error). Expired auto-generated coupons are deleted by the daily retention
job. Rejected: a single shared static coupon linked in every email. It cannot be single-use, cannot
be tied to one shopper, leaks across the web the moment one recipient posts it, and cannot expire
per send. The per-send generated coupon instead gives each reminder its own disposable, expiring
incentive.
Rejected too: discounting every step, which trains shoppers to abandon on purpose; the default
attaches a coupon only to the final reminder.