Digital product businesses lose 70%+ of potential revenue to cart abandonment. Unlike physical e-commerce, you can recover these without inventory concerns — just the right technical setup.
Here's the exact stack we use at Whoff Agents to recover abandoned checkouts for digital downloads and SaaS products.
The Core Problem
Someone clicks your Stripe checkout link, gets to the payment page, then disappears. No purchase. No email. No way to follow up.
Most developers accept this as unavoidable. It's not.
Step 1: Capture Email Before Payment
The fundamental problem is email capture. Stripe's hosted checkout only gives you email after payment. You need it before.
Two approaches:
Option A: Pre-checkout email gate
Before redirecting to Stripe, collect email first:
// Express route
app.post(/checkout/init, async (req, res) => {
const { email, productId } = req.body;
// Save intent to DB
await db.checkoutIntents.create({
email,
productId,
createdAt: new Date(),
status: initiated
});
// Create Stripe session with pre-filled email
const session = await stripe.checkout.sessions.create({
customer_email: email,
line_items: [{ price: PRICE_ID, quantity: 1 }],
mode: 'payment',
success_url: `${BASE_URL}/thank-you?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
metadata: { email, productId }
});
res.json({ url: session.url });
});
Option B: Stripe Payment Links with prefill
If you're using Stripe Payment Links, append ?prefilled_email=user@example.com to pre-populate. Works for link-based flows.
Step 2: Detect Abandonment via Stripe Webhooks
Stripe fires checkout.session.expired 24 hours after a session is created (if unpaid). Listen for it:
app.post(/webhooks/stripe, express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'checkout.session.expired') {
const session = event.data.object;
const email = session.customer_email || session.metadata?.email;
if (email) {
await triggerAbandonmentSequence(email, session);
}
}
if (event.type === 'checkout.session.completed') {
// Cancel any pending abandonment sequences
await cancelAbandonmentSequence(event.data.object.customer_email);
}
res.json({ received: true });
});
Important: Always cancel the abandonment sequence when a purchase completes. Nobody wants a recovery email after they already bought.
Step 3: The Recovery Email Sequence
Timing matters. Our sequence:
| Timing | Goal | |
|---|---|---|
| Email 1 | 1 hour after expiry | Soft reminder, remove friction |
| Email 2 | 24 hours | Address objections |
| Email 3 | 72 hours | Urgency or offer |
async function triggerAbandonmentSequence(email, session) {
const productName = session.metadata?.productName || 'your order';
// Email 1 — 1 hour
await scheduleEmail({
to: email,
template: 'abandonment-1',
data: { productName, checkoutUrl: generateFreshCheckoutUrl(email) },
sendAt: Date.now() + (60 * 60 * 1000)
});
// Email 2 — 24 hours
await scheduleEmail({
to: email,
template: 'abandonment-2',
data: { productName },
sendAt: Date.now() + (24 * 60 * 60 * 1000)
});
// Email 3 — 72 hours
await scheduleEmail({
to: email,
template: 'abandonment-3',
data: { productName },
sendAt: Date.now() + (72 * 60 * 60 * 1000)
});
}
Step 4: Generate Fresh Checkout Links
Do NOT send people back to the expired Stripe session URL. Create a new session:
async function generateFreshCheckoutUrl(email) {
const session = await stripe.checkout.sessions.create({
customer_email: email,
line_items: [{ price: PRICE_ID, quantity: 1 }],
mode: 'payment',
success_url: `${BASE_URL}/thank-you?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
// 48-hour expiry gives them time
expires_at: Math.floor(Date.now() / 1000) + (48 * 60 * 60)
});
return session.url;
}
Step 5: Email Copy That Converts
Email 1 — Remove Friction:
Subject: Did something go wrong?
Hey,
You were almost through checkout for [Product Name] —
looked like something interrupted you.
Here's a fresh link if you'd like to complete it:
[CHECKOUT LINK]
Any questions before you buy? Just reply.
— Will
Email 2 — Address Objections:
Subject: Common questions about [Product Name]
Following up on your almost-purchase.
The most common reason people hesitate:
"Is this worth it for my situation?"
[Answer the top 2-3 objections specific to your product]
Still have questions? Reply and I'll answer personally.
[CHECKOUT LINK]
Email 3 — Close or Release:
Subject: Last note on this
Last email about [Product Name].
If the timing was wrong or it's not the right fit —
totally understood. I'll stop following up.
If you're still interested:
[CHECKOUT LINK]
Either way, good luck with what you're building.
What to Measure
- Abandonment rate: Sessions expired / Sessions created
- Recovery rate: Recovered purchases / Abandonment emails sent
- Email open rate by sequence position: Tells you where you're losing them
- Revenue recovered: Direct ROI of the system
Typical recovery rates: 5-15% for digital products. On a $99 product with 50 abandonments/month, even 10% recovery = $495/month from a system you build once.
The Stack We Use
At Whoff Agents, our entire fulfillment stack runs autonomously:
- Stripe — payment processing + webhooks
- Resend — transactional email delivery
- Express — webhook handler
- Node-cron — email scheduling
The whole system runs as part of our multi-agent pipeline, meaning no human touches the recovery flow. A customer abandons → webhook fires → emails schedule → recovery happens automatically.
If you're building similar automation and want the exact agent coordination patterns we use, check out the PAX Protocol Starter Kit.
Quick Implementation Checklist
- [ ] Email gate before Stripe redirect (or prefill via URL param)
- [ ]
checkout.session.expiredwebhook handler - [ ]
checkout.session.completedwebhook cancels pending sequences - [ ] 3-email sequence: 1hr / 24hr / 72hr
- [ ] Fresh checkout URLs in every email (not expired session URLs)
- [ ] Unsubscribe link in every email (legal requirement + trust)
- [ ] Track recovery rate in your dashboard
Build it once. Let it run.
Built something similar? Drop your recovery rate in the comments — curious what others are seeing.