Abandonment Recovery Email System - Complete Documentation¶
System Version: 1.0
Date Implemented: November 2024
Status: Production Ready ✅
Table of Contents¶
- System Overview
- Architecture
- Database Schema
- Edge Functions
- Customer.io Integration
- Modal Pre-Fill System
- Email Templates
- Testing Guide
- Troubleshooting
- Future Enhancements
System Overview¶
Purpose¶
Recover abandoned calculator completions by sending personalized follow-up emails with pre-filled checkout experience.
Key Features¶
- Two-email sequence: 1-hour and 48-hour follow-ups
- Personalization: Dog name, feeding plan details, pricing
- Pre-fill magic: Email, postcode, and address auto-populate in modal
- £20 discount: Incentive for completion
- One-time use: Prevents discount abuse
Success Metrics¶
- Open rates (target: 40%+)
- Click-through rates (target: 15%+)
- Conversion rates (target: 8%+)
- Revenue recovery per email
Architecture¶
High-Level Flow¶
User completes calculator
↓
Record saved to calculator_discounts table
↓
Hourly cron triggers send-abandonment-events edge function
↓
Function queries for candidates (1-2h old, 48-50h old)
↓
Events sent to Customer.io (modal_1h, modal_48h)
↓
Customer.io sends emails with token URL
↓
User clicks email CTA → Product page with token
↓
Modal opens → Fields pre-filled from database
↓
3 clicks → Checkout with subscription + discount
↓
Order completed → Discount marked as used
Components¶
- Database (
raw_ops.calculator_discounts) - Stores calculator completions
-
Tracks email sends and discount usage
-
Edge Functions
send-abandonment-events: Identifies candidates, sends to Customer.iocalculator-apply-discount: Creates/validates discount codes-
shopify-webhook: Marks discounts as used on order completion -
Customer.io
- Receives events via API
- Sends HTML emails
-
Tracks opens/clicks
-
Shopify Liquid
- Product page personalization
- Calculator modal with pre-fill logic
Database Schema¶
Table: raw_ops.calculator_discounts¶
CREATE TABLE raw_ops.calculator_discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL, -- UUID for lookups
email TEXT NOT NULL,
dog_name TEXT,
postcode TEXT NOT NULL,
address TEXT NOT NULL,
box_size TEXT NOT NULL,
daily_grams INTEGER NOT NULL,
calculated_mer INTEGER NOT NULL,
delivery_weeks INTEGER NOT NULL,
discount_code TEXT UNIQUE NOT NULL, -- Human-readable (CALC20-XXXXX)
regular_price INTEGER NOT NULL,
discounted_price INTEGER NOT NULL,
pet_count INTEGER DEFAULT 1,
household_daily_grams INTEGER,
used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
abandonment_1h_sent_at TIMESTAMPTZ,
abandonment_48h_sent_at TIMESTAMPTZ
);
-- Indexes for performance
CREATE INDEX idx_calculator_discounts_created_at ON raw_ops.calculator_discounts(created_at);
CREATE INDEX idx_calculator_discounts_email ON raw_ops.calculator_discounts(email);
CREATE INDEX idx_calculator_discounts_token ON raw_ops.calculator_discounts(token);
CREATE INDEX idx_calculator_discounts_used ON raw_ops.calculator_discounts(used);
Key Fields¶
- token: UUID for database lookups (e.g.,
550e8400-e29b-41d4-a716-446655440000) - discount_code: Human-readable code shown to customer (e.g.,
CALC20-ABC12345) - abandonment_*_sent_at: Timestamps prevent duplicate sends
Edge Functions¶
1. send-abandonment-events¶
Purpose: Identifies abandonment candidates and sends events to Customer.io
Location: supabase/functions/send-abandonment-events/index.ts
Trigger: Hourly cron (every hour at :00)
Logic:
// Query for candidates using database function
const { data: candidates } = await supabase
.rpc('fn_get_abandonment_candidates');
// Send events to Customer.io
for (const candidate of candidates) {
const eventPayload = {
name: candidate.abandonment_type, // 'modal_1h' or 'modal_48h'
data: {
dog_name: candidate.dog_name,
has_dog_name: !!candidate.dog_name,
box_size: candidate.box_size,
daily_grams: candidate.daily_grams,
daily_calories: candidate.calculated_mer,
delivery_weeks: candidate.delivery_weeks,
token: candidate.token, // UUID, not discount_code!
discount_code: candidate.discount_code,
regular_price: candidate.regular_price,
discounted_price: candidate.discounted_price,
savings: candidate.regular_price - candidate.discounted_price
}
};
await fetch(`https://track.customer.io/api/v1/customers/${candidate.email}/events`, {
method: 'POST',
headers: {
'Authorization': `Basic ${btoa(SITE_ID:API_KEY)}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventPayload)
});
// Mark as sent
await supabase
.from('calculator_discounts')
.update({
[`abandonment_${candidate.abandonment_type.replace('modal_', '')}_sent_at`]: new Date().toISOString()
})
.eq('id', candidate.calculator_discount_id);
}
Database Function: fn_get_abandonment_candidates()
CREATE OR REPLACE FUNCTION raw_ops.fn_get_abandonment_candidates()
RETURNS TABLE (
email TEXT,
dog_name TEXT,
box_size TEXT,
daily_grams INTEGER,
calculated_mer INTEGER,
delivery_weeks INTEGER,
token TEXT,
discount_code TEXT,
regular_price INTEGER,
discounted_price INTEGER,
hours_since_created NUMERIC,
abandonment_type TEXT,
calculator_discount_id UUID
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
-- 1-hour candidates
SELECT
cd.email,
cd.dog_name,
cd.box_size,
cd.daily_grams,
cd.calculated_mer,
cd.delivery_weeks,
cd.token,
cd.discount_code,
cd.regular_price,
cd.discounted_price,
EXTRACT(EPOCH FROM (NOW() - cd.created_at)) / 3600 AS hours_since_created,
'modal_1h'::TEXT AS abandonment_type,
cd.id AS calculator_discount_id
FROM raw_ops.calculator_discounts cd
WHERE cd.used = FALSE
AND cd.abandonment_1h_sent_at IS NULL
AND cd.created_at >= NOW() - INTERVAL '2 hours'
AND cd.created_at <= NOW() - INTERVAL '1 hour'
UNION ALL
-- 48-hour candidates
SELECT
cd.email,
cd.dog_name,
cd.box_size,
cd.daily_grams,
cd.calculated_mer,
cd.delivery_weeks,
cd.token,
cd.discount_code,
cd.regular_price,
cd.discounted_price,
EXTRACT(EPOCH FROM (NOW() - cd.created_at)) / 3600 AS hours_since_created,
'modal_48h'::TEXT AS abandonment_type,
cd.id AS calculator_discount_id
FROM raw_ops.calculator_discounts cd
WHERE cd.used = FALSE
AND cd.abandonment_48h_sent_at IS NULL
AND cd.abandonment_1h_sent_at IS NOT NULL
AND cd.created_at >= NOW() - INTERVAL '50 hours'
AND cd.created_at <= NOW() - INTERVAL '48 hours';
END;
$$;
Cron Configuration:
-- In Supabase Dashboard → Database → Cron Jobs
SELECT cron.schedule(
'send-abandonment-events',
'0 * * * *', -- Every hour at :00
$$
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/send-abandonment-events',
headers := '{"Authorization": "Bearer YOUR_SERVICE_ROLE_KEY"}'::jsonb
) AS request_id;
$$
);
2. shopify-webhook (Updated)¶
Purpose: Marks discount as used when order completes
Addition to existing function:
// After order is created, check if calculator discount was used
const discountCodes = payload.discount_codes || [];
const calcDiscount = discountCodes.find(d => d.code?.startsWith('CALC20-'));
if (calcDiscount) {
// Mark discount as used
const { data: discountData } = await supabase
.from('calculator_discounts')
.update({
used: true,
used_at: new Date().toISOString()
})
.eq('discount_code', calcDiscount.code)
.select('email, dog_name, box_size, daily_grams, calculated_mer, discount_code, regular_price, discounted_price')
.single();
// Send order_completed event to Customer.io (exits abandonment sequence)
const eventPayload = {
name: 'order_completed',
data: {
dog_name: discountData.dog_name,
box_size: discountData.box_size,
daily_grams: discountData.daily_grams,
daily_calories: discountData.calculated_mer,
discount_code: discountData.discount_code,
order_number: payload.order_number,
order_total: payload.total_price
}
};
await fetch(`https://track.customer.io/api/v1/customers/${discountData.email}/events`, {
method: 'POST',
headers: {
'Authorization': `Basic ${btoa(CUSTOMERIO_SITE_ID + ':' + CUSTOMERIO_API_KEY)}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(eventPayload)
});
}
Environment Variables Required:
Customer.io Integration¶
Credentials¶
Site ID: 8633d5bc763716f848d1
API Key: b08777c3150e4cf96485
Authentication: Basic Auth (Base64 encoded SITE_ID:API_KEY)
Campaign Setup¶
Campaign 1: 1-Hour Abandonment
- Trigger: Event
modal_1h - Goal: Event
order_completed - Frequency: Once per person
- Email: Send HTML template (email1-customerio.html)
- Exit: When
order_completedevent received
Campaign 2: 48-Hour Abandonment
- Trigger: Event
modal_48h - Goal: Event
order_completed - Frequency: Once per person
- Email: Send HTML template (email2-customerio.html)
- Exit: When
order_completedevent received
Event Payload Structure¶
{
"name": "modal_1h",
"data": {
"dog_name": "Luna",
"has_dog_name": true,
"box_size": "12kg",
"daily_grams": 375,
"daily_calories": 710,
"delivery_weeks": 4,
"token": "550e8400-e29b-41d4-a716-446655440000",
"discount_code": "CALC20-ABC12345",
"regular_price": 109,
"discounted_price": 89,
"savings": 20
}
}
Customer.io Variable Syntax¶
{{ event.dog_name }}
{{ event.has_dog_name }}
{{ event.box_size }}
{{ event.daily_grams }}
{{ event.daily_calories }}
{{ event.delivery_weeks }}
{{ event.token }}
{{ event.discount_code }}
{{ event.regular_price }}
{{ event.discounted_price }}
{{ event.savings }}
CRITICAL: Use {{ event.token }} (UUID) in URLs, NOT {{ event.discount_code }}
Modal Pre-Fill System¶
Product Page URL Structure¶
As of v4.0, the product page URL is token-only. All calculator data is stored server-side in the calculator_data JSONB column on calculator_discounts and fetched by the product page via the calculator-get-session edge function.
From Email CTA:
https://www.protocolraw.co.uk/products/protocol-raw-complete-{{ event.box_size }}-box?token={{ event.token }}&claimed=true&discount={{ event.discount_code }}
Example:
https://www.protocolraw.co.uk/products/protocol-raw-complete-12kg-box?token=550e8400-e29b-41d4-a716-446655440000&claimed=true&discount=CALC20-ABC12345
The claimed=true and discount params tell the product page to show the "Complete Your Order" state. All other plan data (box, grams, weeks, dog name, pets) is fetched from the session.
Modal JavaScript (snippets/calculator-checkout-modal.liquid)¶
As of v4.0, the checkout modal reads plan data from window.__protocolSession (set by the product page after it fetches session data from calculator-get-session). The modal no longer reads calculator outputs from URL params or makes its own session fetch.
Session Resolution (lazy):
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
let _sessionResolved = false;
let boxSize, dailyGrams, weeks, dogName;
function resolveSession() {
if (_sessionResolved) return;
const session = window.__protocolSession || {};
boxSize = session.box || urlParams.get('box');
dailyGrams = session.dailyGrams || urlParams.get('daily_grams') || urlParams.get('household_grams');
weeks = session.weeks || urlParams.get('weeks');
dogName = session.dogName || null;
_sessionResolved = true;
}
resolveSession() is called at the start of every handler that uses plan variables: modal open observer, showStep(), verify plan handler, waitlist join handler. The URL param fallbacks are a safety net for old bookmarked URLs.
Pre-Load Logic (user data for form pre-fill):
// Get URL params
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
// Pre-load user data if token exists
let userDataLoaded = false;
let preloadedData = null;
if (token) {
console.log('🔠Token found, pre-loading user data...');
fetch(`https://znfjpllsiuyezqlneqzr.supabase.co/rest/v1/calculator_discounts?token=eq.${token}&select=email,address,postcode`, {
headers: {
'apikey': 'YOUR_ANON_KEY',
'Authorization': 'Bearer YOUR_ANON_KEY',
'Accept-Profile': 'raw_ops' // CRITICAL: Specify schema
}
})
.then(res => res.json())
.then(data => {
if (data && data.length > 0) {
preloadedData = data[0];
userDataLoaded = true;
console.log('✅ User data pre-loaded:', {
email: preloadedData.email,
postcode: preloadedData.postcode,
hasAddress: !!preloadedData.address
});
// If modal is already open, fill fields now
if (modal.style.display === 'block') {
fillFieldsFromPreloadedData();
}
}
})
.catch(err => {
console.error('⌠Error pre-loading token data:', err);
});
}
Fill Fields Function:
function fillFieldsFromPreloadedData() {
if (!preloadedData) return;
console.log('📠Filling fields with preloaded data');
// Fill email
if (preloadedData.email) {
const emailInput = document.getElementById('calc-email');
emailInput.value = preloadedData.email;
document.getElementById('continue-email').disabled = false;
formData.email = preloadedData.email;
}
// Fill postcode
if (preloadedData.postcode) {
const postcodeInput = document.getElementById('calc-postcode');
postcodeInput.value = preloadedData.postcode;
document.getElementById('continue-postcode').disabled = false;
formData.postcode = preloadedData.postcode;
}
// Fill address
if (preloadedData.address) {
const addressInput = document.getElementById('calc-address');
addressInput.value = preloadedData.address;
document.getElementById('verify-plan').disabled = false;
formData.address = preloadedData.address;
}
}
Modal Open Detection:
// Watch for modal being shown
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
if (modal.style.display === 'block' && userDataLoaded && preloadedData) {
console.log('🎯 Modal opened with preloaded data - filling fields');
fillFieldsFromPreloadedData();
}
}
});
});
observer.observe(modal, { attributes: true });
Email Templates¶
Email 1: 1-Hour Follow-Up¶
Subject: {% if event.has_dog_name %}{{ event.dog_name }}'s{% else %}Your{% endif %} feeding plan
Preheader: {{ event.daily_grams }}g per day. {{ event.daily_calories }} kcal. Your code is ready.
Key Content: - Personalized with dog name - Specific feeding amounts (grams + calories) - Proof-forward messaging (lab testing, QR codes) - Discount code visibility - CTA: "Start Verified Plan - £20 Off"
Design: - Protocol Raw brand colors (Espresso, Burnt Sienna, Cream) - Montserrat Bold for headings - Inter Regular for body - Responsive mobile breakpoints
File: /mnt/user-data/outputs/email1-customerio.html
Email 2: 48-Hour Follow-Up¶
Subject: A question about {% if event.has_dog_name %}{{ event.dog_name }}'s{% else %}your{% endif %} feeding plan
Preheader: Your discount is still active.
Key Content: - Addresses objections (safety, transition) - UKAS-accredited lab testing - 10-day transition schedule - Discount still active - CTA: "Complete your order"
Tone: Calm, systematic, not pushy
File: /mnt/user-data/outputs/email2-customerio.html
Testing Guide¶
Manual Testing¶
1. Create Test Record
INSERT INTO raw_ops.calculator_discounts (
token, email, dog_name, postcode, address,
box_size, daily_grams, calculated_mer, delivery_weeks,
discount_code, regular_price, discounted_price,
pet_count, household_daily_grams, used, created_at
) VALUES (
gen_random_uuid()::text,
'test@protocolraw.co.uk',
'Luna',
'SW1A1AA',
'10 Downing Street, London',
'12kg',
375,
710,
4,
'CALC20-' || UPPER(SUBSTR(md5(random()::text), 1, 8)),
109,
89,
1,
375,
false,
NOW() - INTERVAL '1 hour 30 minutes' -- For 1h email
)
RETURNING token, discount_code;
2. Trigger Edge Function Manually
curl -X POST \
https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/send-abandonment-events \
-H "Authorization: Bearer YOUR_SERVICE_ROLE_KEY"
3. Check Customer.io
- Go to People → Search for email
- View Activity → Should see
modal_1hevent - Check if email campaign triggered
4. Test URL
https://www.protocolraw.co.uk/products/protocol-raw-complete-12kg-box?token=YOUR-UUID-TOKEN&claimed=true&discount=CALC20-TESTCODE
5. Verify Pre-Fill
- Open browser console (F12)
- Look for:
✅ User data pre-loaded: - Click "Start Verified Plan"
- Modal should open with fields pre-filled
6. Complete Flow
- Click Continue → Continue → Verify Plan
- Should see success screen with discount code
- Click "Continue to Checkout"
- Should redirect with email + discount pre-applied
Automated Testing¶
Test Scenarios:
- No token in URL → Modal works normally (no pre-fill)
- Invalid token → Modal works normally (no pre-fill, logs error)
- Valid token → Fields pre-fill correctly
- Used discount → Shows "Welcome Back!" (no discount)
- Order completion → Discount marked as used,
order_completedevent sent
Troubleshooting¶
Common Issues¶
1. "No data found for token"
Cause: Using discount code instead of UUID token in URL
Fix: Ensure email uses {{ event.token }} not {{ event.discount_code }}
Check:
SELECT token, discount_code
FROM raw_ops.calculator_discounts
WHERE discount_code = 'CALC20-ABC12345';
2. "404 Not Found" on Supabase fetch
Cause: Missing Accept-Profile: raw_ops header
Fix: Add header to fetch request:
3. Modal doesn't progress past email step
Cause: Syntax error in JavaScript (missing comma, bracket, etc.)
Fix: Check browser console for Uncaught SyntaxError
4. Fields not pre-filling
Causes:
- Token not in URL
- Session fetch failing (check Network tab for calculator-get-session call)
- window.__protocolSession not set (product page fetch failed)
- Modal opening before session resolves (MutationObserver calls resolveSession())
Debug:
console.log('Token:', token); // Should be UUID
console.log('Session:', window.__protocolSession); // Should have box, dailyGrams, weeks, etc.
console.log('Preloaded data:', preloadedData); // Should have email/postcode/address
console.log('User data loaded:', userDataLoaded); // Should be true
5. Emails not sending
Causes: - Cron not running - Customer.io campaigns not active - Wrong event name
Check: 1. Supabase cron logs 2. Customer.io People → Activity 3. Event name matches campaign trigger exactly
6. Discount not applying at checkout
Causes: - Discount code not created in Shopify - Wrong selling plan (subscription vs one-time)
Check:
Then check Shopify Admin → Discounts
Future Enhancements¶
Short-Term (Next 3 Months)¶
- A/B Testing
- Subject line variants
- Send time optimization
-
CTA button copy
-
Enhanced Personalization
- Reference specific calculator inputs
- Breed-specific messaging
-
Regional messaging
-
Third Email
- Day 7 final reminder
- Social proof / testimonials
- Urgency without pressure
Medium-Term (3-6 Months)¶
- SMS Integration
- 1-hour SMS reminder
-
Higher urgency, shorter message
-
Retargeting Pixels
- Facebook/Instagram ads
- Google Display ads
-
Based on abandonment segment
-
Win-Back Campaigns
- Re-engage after 30 days
- New offer or messaging angle
Long-Term (6-12 Months)¶
- Machine Learning
- Optimal send time prediction
- Churn prediction
-
Personalized discount amounts
-
Progressive Profiling
- Capture additional data over time
- Reduce initial friction
-
Build richer profiles
-
Multi-Pet Households
- Separate abandonment logic
- Household-level vs pet-level offers
Key Metrics to Track¶
Email Performance¶
- Open Rate: Target 40%+
- Click-Through Rate: Target 15%+
- Conversion Rate: Target 8%+
- Revenue per Email: Target £25+
System Health¶
- Cron Success Rate: 100%
- Customer.io API Success Rate: 99.9%+
- Modal Pre-Fill Success Rate: 95%+
- Checkout Completion Rate: 60%+
Business Impact¶
- Total Revenue Recovered: £/month
- Average Order Value: Recovered vs organic
- Lifetime Value: Recovered customers vs organic
- Cost per Acquisition: Including email costs
Contact & Ownership¶
System Owner: Anton (Founder)
Implementation Date: November 2024
Last Updated: March 2026
Status: Production
Related Documentation: - Calculator System Documentation - Customer.io Campaign Setup Guide - Shopify Discount Code Management - Modal Pre-Fill Technical Spec
Change Log¶
v1.1 - March 2026¶
- Updated product page URL structure to token-only (v4.0 calculator URL cleanup)
- Modal now reads plan data from
window.__protocolSessioninstead of URL params - Updated email CTA URL format to include
claimed=true&discount=CODE - Updated troubleshooting for session-based flow
v1.0 - November 2024¶
- Initial implementation
- Two-email sequence (1h, 48h)
- Modal pre-fill system
- Customer.io integration
- Order completion tracking
End of Documentation