Recover genuinely lost sales from customers who started checkout but didn't complete it — without invading their privacy, spamming them, or relying on dark patterns.
## The privacy line
Most abandoned cart implementations are invasive:
- Track anonymous visitors via cookies before any consent
- Email anyone who added to cart, regardless of engagement
- Send multiple escalating follow-ups ("Last chance!", "We miss you!")
- Use tracking pixels to know if the email was opened
- Hold the data indefinitely
**Berrypod's approach is narrower and more honest.** The key distinction is *consent signal*: we only contact customers who explicitly reached the Stripe Checkout page and entered their email. That's a strong signal of genuine purchase intent — they were mid-payment, not just browsing.
Anonymous cart sessions with no email are never touched. We don't know who they are and we don't try to find out.
## Legal basis
### UK — PECR soft opt-in (Regulation 22)
The strongest legal ground. PECR allows sending unsolicited marketing email without prior explicit consent if:
1. You obtained the email address **in the course of a sale or negotiation of a sale** — the ICO has confirmed this covers incomplete checkouts where the customer started but didn't finish
2. You're marketing your **own similar products/services** — same shop, same type of products they were trying to buy
3. The customer was given a **clear opportunity to opt out** at the time their details were collected, and in every subsequent message
4. They have **not opted out**
A single abandoned cart reminder fits squarely. The ICO has specifically addressed abandoned cart emails in its guidance on PECR.
### EU — Legitimate interests (GDPR Article 6(1)(f))
For EU customers (or UK shops with EU-based customers post-Brexit), the soft opt-in rule doesn't apply directly — it's a UK PECR provision. The equivalent ePrivacy Directive is implemented differently across member states.
The lawful basis would be **legitimate interests**, provided:
- The email is clearly transactional-adjacent (not general marketing — it's specifically about the items they tried to buy)
- A **Legitimate Interests Assessment (LIA)** is documented: the shop owner's interest in recovering a genuine purchase is weighed against the customer's right to privacy. A single non-pushy reminder, easy to opt out of, with prompt data deletion, tips the balance in favour of legitimate interests.
- Proper opt-out, no tracking, data deletion
The shop owner should document this LIA in their privacy policy.
### What makes it non-compliant (and what we avoid)
- Multiple emails / drip sequences
- Tracking pixels to know if the email was opened or the link clicked
- Storing the abandoned cart data indefinitely
- No real unsubscribe mechanism
- Emailing anonymous cart sessions (no email captured)
- No mention in privacy policy
## How it works
### The trigger: `checkout.session.expired`
Stripe fires this webhook when a Checkout session expires without payment. Sessions expire after 24 hours by default. The expired session object contains:
-`customer_details.email` — if the customer entered their email on the Stripe page (may be absent if they abandoned before that step)
-`line_items` — what was in their cart
-`amount_total` — cart total at time of checkout
-`expires_at` — when it expired
We already handle `checkout.session.completed` in `stripe_webhook_controller.ex`. This is a sibling handler.
### Flow
```
checkout.session.expired webhook fires
↓
Extract customer_details.email from session
↓
If no email → discard (customer never identified themselves)
↓
Check suppression list → if unsubscribed → discard
↓
Check for existing paid order with same stripe_session_id → if found → discard
(handles race: session expired but payment webhook fired first)
Plain text. No tracking pixels. No HTML. Consistent with the existing order confirmation and shipping notification emails.
```
Subject: You left something behind
Hi,
You recently started a checkout at [shop name] but didn't complete it.
Your cart had:
- Classic Tee (M, Black) × 1 — £25.00
- Tote Bag × 2 — £18.00
Total: £43.00
If you'd like to complete your order, head to [shop URL] and add these items again.
We're only sending this once.
—
[shop name]
Don't want to hear from us? Unsubscribe: [unsubscribe link]
```
Key points:
- "We're only sending this once" — sets expectations, builds trust
- No urgency language ("Act now!", "Limited stock!")
- No tracking link for the cart — just link to the shop homepage. Cart is session-based and can't be restored via a link anyway. Just show what they had.
- Plain text, no images, no tracking pixels
- Unsubscribe link at the bottom — signed token, one click
### Unsubscribe
Unsubscribe link is a signed token: `Phoenix.Token.sign(endpoint, "email-unsub", email)`.
`/unsubscribe/:token` route:
- Verify the token
- Insert into `email_suppressions`
- Show a simple "You've been unsubscribed" page
- Suppression is checked before every email send (recovery, newsletter, order updates — all of them)
### 30-day data pruning
New Oban cron job: `AbandonedCartPruneWorker` runs nightly.
Repo.delete_all(from a in AbandonedCart, where: a.inserted_at < ^cutoff)
:ok
end
```
After 30 days, the abandoned cart record is gone. If someone comes back and buys after 30 days, that's fine — it's a fresh order, not connected to the old abandoned cart.
### Stripe Checkout footer notice
Stripe allows custom text on the Checkout page footer via the `custom_text` parameter on the Checkout session. Add a brief note:
```elixir
custom_text: %{
after_submit: %{
message: "If your payment doesn't complete, we may send you one follow-up email. You can unsubscribe at any time."
}
}
```
This is the collection-time notice required for PECR soft opt-in, and satisfies the GDPR transparency requirement for legitimate interests.
### Privacy policy
The shop's privacy policy template (currently a static page) should include a paragraph covering:
- What data is collected during incomplete checkout (email, cart contents)
- That a single recovery email may be sent
- How to unsubscribe
- That data is deleted after 30 days
This is already in the template pages that come with Berrypod — the privacy page just needs this section added.
## What it deliberately can't do
- **Track anonymous sessions** — our cart is session-based. If no email was captured, we have no identity to contact. This is a feature, not a limitation.
- **Send multiple emails** — the schema has a single `emailed_at` field. The worker checks it before sending and won't send again.
- **Track email opens or link clicks** — plain text emails, no tracking pixels, no redirected links
- **Target general browsers** — only customers who got as far as entering their email on Stripe. Someone who viewed products and added to cart but never clicked Checkout is never contacted.
## Admin dashboard
A small card on the admin dashboard (or `/admin/orders`):
- Abandoned carts in last 30 days: X
- Emails sent: Y
- Estimated recovery rate (orders placed within 48h of an abandoned cart from the same email): Z%
This gives the shop owner visibility without being creepy about it. The metric is useful for understanding checkout friction — a high abandonment rate at Stripe suggests a pricing, trust, or UX issue.
## Files to create/modify
- Migration — `abandoned_carts` and `email_suppressions` tables