Canonical: all shop pages now assign og_url (reusing the existing og:url assign), which the layout renders as <link rel="canonical">. Collection pages strip the sort param so ?sort=price_asc doesn't create a duplicate canonical. robots.txt: dynamic controller disallows /admin/, /api/, /users/, /webhooks/, /checkout/. Removed robots.txt from static_paths so it goes through the router instead of Plug.Static. sitemap.xml: auto-generated from all visible products + categories + static pages, served as application/xml. 8 tests. Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
Abandoned cart recovery
Status: Planned Tasks: #75–77 in PROGRESS.md Tier: 4 (Growth & content)
Goal
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:
- 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
- You're marketing your own similar products/services — same shop, same type of products they were trying to buy
- The customer was given a clear opportunity to opt out at the time their details were collected, and in every subsequent message
- 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 cartamount_total— cart total at time of checkoutexpires_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)
↓
Store AbandonedCart record:
- customer_email
- line_items (JSON snapshot)
- cart_total
- stripe_session_id
- expired_at
↓
Enqueue AbandonedCartEmailWorker (Oban, delay ~1 hour)
↓
Worker sends single plain-text email
↓
Mark AbandonedCart as emailed (emailed_at timestamp)
Schema
create table(:abandoned_carts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :customer_email, :string, null: false
add :stripe_session_id, :string, null: false
add :line_items, :map, null: false # JSON snapshot of cart contents
add :cart_total, :integer # in minor units
add :expired_at, :utc_datetime, null: false
add :emailed_at, :utc_datetime # nil until email sent
timestamps()
end
create unique_index(:abandoned_carts, [:stripe_session_id])
create index(:abandoned_carts, [:customer_email])
create index(:abandoned_carts, [:inserted_at]) # for pruning
create table(:email_suppressions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :string, null: false
add :reason, :string # "unsubscribed", "bounced", etc.
add :suppressed_at, :utc_datetime, null: false
timestamps()
end
create unique_index(:email_suppressions, [:email])
The email
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.
def perform(_job) do
cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
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:
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_atfield. 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_cartsandemail_suppressionstables lib/berrypod/orders/abandoned_cart.ex— schemalib/berrypod/orders/email_suppression.ex— schemalib/berrypod/orders.ex—create_abandoned_cart/1,check_suppression/1lib/berrypod/workers/abandoned_cart_email_worker.ex— Oban joblib/berrypod/workers/abandoned_cart_prune_worker.ex— Oban cronlib/berrypod/notifier/order_notifier.ex—send_cart_recovery/1lib/berrypod_web/controllers/stripe_webhook_controller.ex— handlecheckout.session.expiredlib/berrypod_web/controllers/checkout_controller.ex— addcustom_textto sessionlib/berrypod_web/controllers/unsubscribe_controller.ex— new, handles/unsubscribe/:token- Router —
/unsubscribe/:tokenroute - Config — add
AbandonedCartPruneWorkerto Oban crontab
Tests
- Webhook handler: email present → creates record, no email → discards, suppressed → discards, paid order exists → discards
- Email worker: sends email, marks as emailed, doesn't send twice
- Prune worker: deletes records older than 30 days, keeps recent ones
- Unsubscribe: valid token → suppressed, invalid token → error, already suppressed → idempotent
- Suppression check: suppressed email → blocked from all sends