Your First Almost-Customer — How to Recover Failed Payments for Digital Products

javascript dev.to

She was one click away.

Her name was Sarah (not her real name). She'd visited the checkout page three times over two days. On the third visit, she filled in her card details and hit "Pay." Stripe fired a payment_intent.payment_failed webhook. Her card declined. She closed the tab.

I almost never knew she existed.

That near-miss taught me more about selling digital products than any conversion optimization thread ever did. Here's what I built to make sure I never lose that customer silently again.


The problem with digital product failures

Physical products have cart abandonment emails baked into every platform. Digital products — especially self-hosted ones using Stripe — don't get that treatment by default.

When a payment fails, Stripe tells you. But if you're not listening, that signal dies in a log file.

The flow for most indie devs:

Customer → Stripe checkout → Payment fails → Nothing
Enter fullscreen mode Exit fullscreen mode

The flow it should be:

Customer → Stripe checkout → Payment fails → Webhook fires
→ Customer record created → Recovery email sequence starts
→ Customer recovers or opts out → You know either way
Enter fullscreen mode Exit fullscreen mode

The difference is a webhook handler and three emails.


Step 1: Catch the webhook

Stripe fires payment_intent.payment_failed when a charge doesn't go through. Set up an endpoint to catch it.

// server.js — Express webhook handler
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();

// Raw body required for Stripe signature verification
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,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  switch (event.type) {
    case 'payment_intent.payment_failed':
      await handlePaymentFailed(event.data.object);
      break;
    case 'payment_intent.succeeded':
      await handlePaymentSucceeded(event.data.object);
      break;
  }

  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Extract what you need

The PaymentIntent object contains the customer's email (if they entered it) and the failure reason.

async function handlePaymentFailed(paymentIntent) {
  const email = paymentIntent.receipt_email
    || paymentIntent.metadata?.customer_email;

  if (!email) {
    // Customer bailed before entering email — nothing to recover
    console.log('Payment failed: no email captured. Cannot recover.');
    return;
  }

  const failureCode = paymentIntent.last_payment_error?.code;
  const checkoutUrl = paymentIntent.metadata?.checkout_url
    || process.env.DEFAULT_CHECKOUT_URL;

  await startRecoverySequence({
    email,
    firstName: paymentIntent.metadata?.first_name || 'there',
    checkoutUrl,
    failureCode,
    paymentIntentId: paymentIntent.id,
  });
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: Pass first_name and checkout_url as metadata when you create the PaymentIntent — you control that data, so use it.

// When creating the PaymentIntent:
const paymentIntent = await stripe.paymentIntents.create({
  amount: 9700, // $97.00
  currency: 'usd',
  receipt_email: customerEmail,
  metadata: {
    customer_email: customerEmail,
    first_name: customerFirstName,
    checkout_url: 'https://yoursite.com/checkout',
    product: 'starter-kit-v1',
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 3: The recovery sequence

Three emails. Fixed timing. Zero pressure.

const schedule = require('node-schedule');

async function startRecoverySequence(customer) {
  const { email, firstName, checkoutUrl, paymentIntentId } = customer;

  // Log to DB so we can cancel if they purchase
  await db.recoveries.create({
    paymentIntentId,
    email,
    status: 'active',
    startedAt: new Date(),
  });

  // Email 1: Immediate
  await sendRecoveryEmail(1, { email, firstName, checkoutUrl });

  // Email 2: 24 hours later
  const t24h = new Date(Date.now() + 24 * 60 * 60 * 1000);
  schedule.scheduleJob(`recovery-2-${paymentIntentId}`, t24h, async () => {
    const r = await db.recoveries.findOne({ paymentIntentId });
    if (r?.status !== 'active') return; // purchased — skip
    await sendRecoveryEmail(2, { email, firstName, checkoutUrl });
  });

  // Email 3: 48 hours later
  const t48h = new Date(Date.now() + 48 * 60 * 60 * 1000);
  schedule.scheduleJob(`recovery-3-${paymentIntentId}`, t48h, async () => {
    const r = await db.recoveries.findOne({ paymentIntentId });
    if (r?.status !== 'active') return;
    await sendRecoveryEmail(3, { email, firstName, checkoutUrl });
    await db.recoveries.update({ paymentIntentId }, { status: 'exhausted' });
  });
}
Enter fullscreen mode Exit fullscreen mode

Cancel the sequence when they purchase:

async function handlePaymentSucceeded(paymentIntent) {
  const email = paymentIntent.receipt_email;

  await db.recoveries.update(
    { email, status: 'active' },
    { status: 'converted' }
  );

  await triggerOnboarding(paymentIntent);
}
Enter fullscreen mode Exit fullscreen mode

Step 4: The emails

Tone matters as much as timing. These aren't dunning notices — they're a hand on the shoulder.

const emailTemplates = {
  1: {
    subject: 'Quick heads up on your order, {{firstName}}',
    body: `Hi {{firstName}},

Looks like there was a small hiccup processing your payment — your card didn't go through.

No worries, it happens. Here's your direct checkout link to try again:

→ {{checkoutUrl}}

Let me know if you run into anything.

— Will`,
  },

  2: {
    subject: 'Still interested?',
    body: `Hi {{firstName}},

Checking in — yesterday's payment didn't go through, and I wanted to make sure you didn't lose your spot.

→ {{checkoutUrl}}

If something came up or you have questions before buying, just reply.

— Will`,
  },

  3: {
    subject: 'Different card or PayPal? (re: your order)',
    body: `Hi {{firstName}},

Last note on this. A few things that sometimes help:

- Try a different card
- Use PayPal at checkout
- Check with your bank (some flag new merchant charges)

→ {{checkoutUrl}}

If you've decided to pass, just let me know and I'll stop following up.

— Will`,
  },
};

async function sendRecoveryEmail(n, { email, firstName, checkoutUrl }) {
  const t = emailTemplates[n];
  const subject = t.subject.replace('{{firstName}}', firstName);
  const body = t.body
    .replace(/{{firstName}}/g, firstName)
    .replace(/{{checkoutUrl}}/g, checkoutUrl);

  await emailProvider.send({
    to: email,
    from: 'will@yoursite.com',
    subject,
    text: body,
  });

  console.log(`Recovery email ${n} sent to ${email}`);
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Test locally with the Stripe CLI

Don't wait for real failures to test this.

# Install and login
brew install stripe/stripe-cli/stripe
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe

# In another terminal, fire a test failure event
stripe trigger payment_intent.payment_failed
Enter fullscreen mode Exit fullscreen mode

Your handler fires. Your queue gets the record. You see exactly what Sarah would have seen.


What actually happened with Sarah

Email 1 went out within an hour of her failure. She opened it. No reply.

Email 2 went out 24 hours later. She clicked the checkout link. No purchase.

Email 3 went out 48 hours in. She replied: "My bank kept flagging it. Trying PayPal now."

She completed the purchase 10 minutes later.

$97. Three emails. One webhook.

Without the sequence, she was a failed payment in a log file. With it, she became a customer — and replied two days later to say the kit was "exactly what I needed."


The failure modes to avoid

1. No email captured before failure
If a customer bails before entering their email, you can't recover them. Capture email first in your checkout flow, before card details. Controversial UX choice — worth it.

2. Sending all three regardless of behavior
If they reply to Email 1, stop the sequence. If they buy, cancel it. Respect the signal.

3. Pressure language
"Your spot is expiring!" — delete. "Hey, still interested?" — send. Pressure makes people feel manipulated. Curiosity makes them feel helped.

4. No opt-out path
Email 3 explicitly offers an out. This isn't legal cover — it's honest. Honor it.


The full picture

payment_intent.payment_failed
  └── Extract email + metadata
  └── Create recovery record (status: active)
  └── Send Email 1 (immediate)
  └── Schedule Email 2 (t+24h, skip if status != active)
  └── Schedule Email 3 (t+48h, skip if status != active)

payment_intent.succeeded
  └── Mark recovery record (status: converted)
  └── Cancel pending emails
  └── Trigger onboarding sequence
Enter fullscreen mode Exit fullscreen mode

No SaaS required. No fancy tooling. An Express endpoint, a scheduler, three templates, and a DB row.


The reason this matters isn't revenue recovery math. It's that Sarah was trying to give you money. She wanted what you built. A bad card number shouldn't be the reason she doesn't get it.

Build the system. Catch the signal. Send the emails.

Your first almost-customer is out there right now.

Source: dev.to

arrow_back Back to Tutorials