DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Abandoned Checkout Recovery for Digital Products — A Developer's Guide

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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:

Email 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)
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Email Copy That Converts

Email 1 — Remove Friction:

Enter fullscreen mode Exit fullscreen mode

Email 2 — Address Objections:

Enter fullscreen mode Exit fullscreen mode

Email 3 — Close or Release:

Enter fullscreen mode Exit fullscreen mode

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.expired webhook handler
  • [ ] checkout.session.completed webhook 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.

Top comments (0)