Features
Abandoned cart recovery
Chasing a cart that was left behind, with a link that restores it and an optional discount.
A customer adds items, types their phone number at checkout, and leaves. An hour later they get a WhatsApp message with a link that puts the cart back exactly as it was, and optionally a discount code. Use it when you can see carts being built and abandoned and you have the phone number to chase them with.
What has to be true first
- A registered number
- As with every event. An unregistered number fails with Meta error 133010.
- The cart_recovery template approved
- Submit it from E-commerce Notifications. The Meta template name is
cart_recovery. - The event switched on
cart_recoveryis off by default in the event catalog.- The feature switched on
- Separately from the event. WooCommerce:
cart_enabled, default off. Shopify: Abandoned cart recovery on the Features tab, default off. Both switches are required. - A phone number and consent
- The cart must have captured a phone number, and the customer must satisfy the marketing opt-in rule.
How it works
The cart is recorded on woocommerce_add_to_cart, woocommerce_cart_updated and woocommerce_checkout_update_order_review, that last one is why a phone typed into the checkout form is captured before the order exists.
The row is keyed on the WooCommerce session id, so guests are captured too. Three fields are read from the checkout POST: billing_phone, billing_email, billing_first_name, falling back to the customer object and then to user meta. The phone is normalised to E.164 or stored empty, never half-normalised.
A cron job runs every five minutes and picks up to 50 rows that are still pending, have a phone, and whose last update is older than the configured delay.
Emptying the cart marks the row expired rather than abandoned. Rows older than 30 days are expired hourly.
The delay
Timing settings
| Field | Required | Type | Description |
|---|---|---|---|
| cart_delay_minutes | No | WooCommerce | Minutes of inactivity before a cart counts as abandoned. Field: First reminder after. Range 5 to 10,080. Default: 60 |
| cart_max_reminders | No | WooCommerce | 1 or 2. The help text is blunt about why: more than two reliably earns blocks. Default: 1 |
| cart_second_delay | No | WooCommerce | Minutes after the first reminder. Only used when cart_max_reminders is 2, so by default it never applies.Default: 1440 |
| delaySeconds | No | Shopify | Set per event on the Notifications tab and shown in minutes, capped at 7 days. Zero means "use the default hour". Default: 3600 seconds |
The recovery link
Each cart row gets a 64-character token, and the link is the store's cart page with that token attached:
https://yourstore.com/cart/?wac_recover=<64-character token>Opening it empties whatever is in the current cart, re-adds every saved line item, pre-fills the billing phone and email, and redirects to checkout. The token is compared in constant time and must be exactly 64 characters.
The link keeps working on repeat clicks. It is not one-shot, and the handler stays registered even if you later switch cart recovery off, so links already in customers' hands do not break. An unknown or cleaned-up token shows That cart link has expired.
Only the product id, variation id, quantity and variation data are stored per line, not prices, which are recalculated.
The discount code
In WooCommerce the field is Recovery discount code (cart_discount_code, empty by default) and its help text says "Leave blank to offer none". In Shopify the value is typed into the cart_recovery event's discountCode variable on the Notifications tab.
Left blank, both send a literal - in that position, because every catalog variable maps to a placeholder and the template will not send with a missing parameter.
🛒 Hi Alex, you left some items in your cart!
Finish your order here: https://example.com/cart/abc123
Use code - for a discount at checkout.
Your cart is saved for a limited time.
Reply STOP to unsubscribe
12:04The variables
cart_recovery
| Field | Required | Type | Description |
|---|---|---|---|
| customerName | Yes | string | Customer name. Example: Alex |
| cartRecoveryLink | Yes | string | Direct link back to the saved cart. Example: https://example.com/cart/abc123 |
| discountCode | Yes | string | Incentive code. Send a dash if there is none. Example: SAVE10 |
WooCommerce maps these by default to {customer_first_name}, {cart_url} and {discount_code}.
What the customer receives
🛒 Hi {{1}}, you left some items in your cart!
Finish your order here: {{2}}
Use code {{3}} for a discount at checkout.
Your cart is saved for a limited time.🛒 Hi Alex, you left some items in your cart!
Finish your order here: https://example.com/cart/abc123
Use code SAVE10 for a discount at checkout.
Your cart is saved for a limited time.
Reply STOP to unsubscribe
12:04Because it is a marketing template, Meta requires an opt-out affordance, so the approved template carries the footer Reply STOP to unsubscribe. A customer who replies STOP is added to the opt-out register and receives nothing further.
How "recovered" is detected
Three hooks converge on the same handler: woocommerce_checkout_order_processed, woocommerce_store_api_checkout_order_processed (block checkout) and woocommerce_thankyou. All run at priority 5, before the notification hooks.
Three things are marked recovered:
- the row matching the current session;
- the row id remembered in the session when a recovery link was used;
- every pending row whose stored phone matches the order's billing phone. This is the cross-device match, and it is what stops a customer who abandoned on their phone and bought on a laptop being chased anyway.
A recovered row can be reopened by a new cart change, but only once an hour has passed, a grace period so the thank-you page does not immediately re-open it.
The guest problem, honestly
This is the tension nobody documents, so here it is plainly.
In WooCommerce, the marketing opt-in check reads the customer's stored choice from the order's meta, then the order's user meta, then the user record. An abandoned cart has no order. A guest has no user record either. So for a guest, the stored choice is empty.
With consent_marketing_requires_optin on, which is the default, an empty stored choice means the send is denied:
Marketing messages need an explicit opt-in and this customer has not given one.The row is then marked as done so it is never retried. Out of the box, with default settings, guest cart recovery sends nothing at all. Logged-in customers who ticked the checkout box do get chased; guests do not.
What you can do about it
- Leave it as it is. The safest option, and the one that keeps your number's quality rating healthy. Recovery then works only for customers who actively opted in.
- Make the checkout opt-in prominent and pre-ticked.
consent_checkboxis on by default andconsent_defaultis "yes", so the box is already offered and pre-ticked. Editing the label to say what the customer actually gets, order updates and occasional offers, converts better than the default wording and is the honest route. - Turn off
consent_marketing_requires_optin. This is the switch that makes guest recovery work. It means you are relying on implied consent from the phone number given at checkout. That is a decision about your own risk appetite and your jurisdiction, not a technical setting, and it is on by default for a reason. The opt-out register still applies either way: a customer who replied STOP is never messaged again, in any mode.
Shopify is different: the app reads no Shopify consent field at all, not accepts_marketing, not sms_marketing_consent. Its only suppression list is the opt-out register built from inbound STOP replies, so guests and logged-in customers are treated identically. The responsibility for having permission is entirely yours.

Deduplication and limits
- One row per session
- WooCommerce upserts on the session key rather than inserting, so a cart edited ten times is one row.
- Dedupe key
- WooCommerce:
cart_recovery:<rowId>:<reminderNumber>. Shopify:cart_recovery:<shopId>:<checkoutToken>:<remindersSent>. Both are enforced by a unique index. - Reminders per cart
- WooCommerce allows 1 or 2, default 1. Shopify sends exactly one. The cap is fixed at 1 in code.
- Guest phone capture ceiling
- WooCommerce accepts at most 10 phone-carrying cart inserts per IP address per hour. Past that the row is still stored, but with the phone blanked.
- Retry behaviour
- The row advances whether the send succeeded or not, so an undeliverable cart is not retried every five minutes forever.
When it does not fire
- The feature or the event is off. Both switches are required, and both default to off.
- The
cart_recoverytemplate is not approved. - No phone number was captured. The sweep only selects rows with a non-empty phone. Turning off "Capture phone early" means almost no cart ever qualifies.
- The phone would not normalise to E.164. It is stored empty rather than guessed at.
- Marketing opt-in is required and the customer has not given one. The default, and the reason guest recovery sends nothing.
- The number is on the opt-out register. Beats every mode and every category.
- The cart was emptied. The row is expired rather than chased.
- The customer already bought. Including on another device, via the phone-number match.
- The reminder cap is reached. One by default.
- The row is more than 30 days old (WooCommerce). It is expired by the hourly sweep.
- Shopify produced no
abandoned_checkout_url. No row is created, so there is nothing to send.
Cost
cart_recovery is MARKETING, the expensive category. Under the shipped default rate card that is 8 credits to an Indian number and 18 elsewhere, against 1 and 6 for a utility message. A single recovery message costs roughly what eight order confirmations do, so the recovered revenue per message needs to justify it. See Credits and billing.
