How I used n8n, Supabase, and Stripe webhooks to turn a $10K/month revenue leak into a $28K/month recovery engine
The Problem That Kills Growth in Fintech Startups (Credit Startup Case Study)
Most credit card companies suffer the same problem every month. Every month, 5-10% of recurring payments fail. That's roughly 15-20 failed payments per month.
Before automation:
- They manually retried failed payments once
- Recovery rate: 8-12%
- Time spent: 10+ hours/month
- Lost revenue: ~$10,000/month
The worst part? They are retrying everyone the same way. Expired card? Retry in 24 hours. Insufficient funds? Retry in 24 hours. Fraud flag? You guessed it—retry in 24 hours.
This is like using the same medication for every illness. It doesn't work.
Why Most Companies Get This Wrong
After talking to dozens of SaaS and fintech founders, I learned most companies either:
- Let Stripe (payment provider) handle it (Stripe retries once, then gives up)
- Manual follow-up (expensive, doesn't scale)
- Blanket retry strategy (retry everyone on the same schedule)
The fundamental problem: Different failure reasons need different recovery strategies.
Here's what I discovered from analyzing 6 months of failed payments:
| Failure Reason | Best Retry Strategy | Recovery Rate |
|---|---|---|
| Expired Card | Notify immediately, retry in 24hrs | 45-50% |
| Insufficient Funds | Retry 3x over 7 days (after payday) | 35-40% |
| Fraud Flag | Manual review, don't auto-retry | 10-15% |
| Generic Decline | Retry once in 3 days | 20-25% |
The insight: Smart retry timing + personalized customer communication = 3x better recovery.
What I Built
An automated payment recovery system that:
- Intercepts Stripe payment failures via webhooks
- Categorizes failure reasons into actionable buckets
- Applies intelligent retry logic based on failure type
- Sends personalized customer emails (no generic "your payment failed" messages)
- Tracks recovery rates by category
- Alerts my team when manual intervention is needed
Tech stack:
- n8n: Workflow automation (the brain)
- Supabase: Database (storing failures, tracking retries)
- Stripe: Payment processing + webhooks
- SendGrid: Email notifications
Cost: $47/month to run (Supabase free tier + n8n cloud starter + SendGrid)
Let me show you exactly how I built it.
Architecture Overview
Stripe Payment Fails
↓
[n8n Webhook] ← Receives Stripe event
↓
[Categorize Failure] ← expired_card vs insufficient_funds vs fraud_flag
↓
[Store in Supabase] ← Save failure + schedule retry
↓
┌────┴────┬─────────┐
↓ ↓ ↓
[Expired] [Insufficient] [Fraud]
Card Funds Flag
↓ ↓ ↓
Notify + Retry 3x Alert +
Retry 1x over 7d Manual
in 24hrs Review
↓
[Retry Scheduler] ← Hourly cron checks for due retries
↓
[Attempt Retry via Stripe API]
↓
[Update Stats & Notify Customer]
Step 1: Database Schema (Supabase)
First, I needed to track failed payments and retry attempts. Here's the schema:
-- Main table: failed payments
CREATE TABLE failed_payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Stripe identifiers
stripe_payment_intent_id TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT NOT NULL,
-- Customer info
customer_email TEXT NOT NULL,
customer_name TEXT,
-- Payment details
amount INTEGER NOT NULL, -- cents
currency TEXT DEFAULT 'usd',
-- Failure details
failure_code TEXT NOT NULL,
failure_message TEXT,
failure_category TEXT NOT NULL, -- 'expired_card', 'insufficient_funds', 'fraud_flag', 'other'
-- Retry tracking
retry_count INTEGER DEFAULT 0,
max_retries INTEGER NOT NULL,
next_retry_at TIMESTAMP WITH TIME ZONE,
-- Status
status TEXT DEFAULT 'pending', -- 'pending', 'recovered', 'failed', 'manual_review'
recovered_at TIMESTAMP WITH TIME ZONE,
metadata JSONB
);
-- Track each retry attempt
CREATE TABLE retry_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
failed_payment_id UUID REFERENCES failed_payments(id),
retry_number INTEGER NOT NULL,
success BOOLEAN,
stripe_charge_id TEXT,
failure_reason TEXT
);
-- Index for the retry scheduler query
CREATE INDEX idx_failed_payments_next_retry
ON failed_payments(next_retry_at)
WHERE status = 'pending';
Why this structure?
-
failure_categorylets me apply different retry strategies -
next_retry_atmakes the retry scheduler query fast -
retry_attemptsgives me detailed analytics on what's working
Step 2: Stripe Webhook Handler (n8n)
First n8n workflow: "1-Webhook-Receiver"
Node 1: Webhook Trigger
// n8n Webhook Node configuration
{
"method": "POST",
"path": "/stripe-payment-failed",
"authentication": "headerAuth",
"responseMode": "lastNode"
}
Node 2: Verify Stripe Signature
Critical for security. You must verify the webhook is actually from Stripe:
// Code Node in n8n
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const sig = $input.item.json.headers['stripe-signature'];
const rawBody = $input.item.json.body;
try {
const event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret);
// Only process payment failure events
if (event.type !== 'payment_intent.payment_failed') {
return { json: { skipped: true, reason: 'Not a payment failure' } };
}
return { json: event.data.object };
} catch (err) {
throw new Error('Invalid webhook signature: ' + err.message);
}
In Stripe Dashboard:
- Go to Developers → Webhooks → Add endpoint
- URL:
https://your-n8n.com/webhook/stripe-payment-failed - Events:
payment_intent.payment_failed - Copy the signing secret → Add to n8n env variables
Step 3: Categorization Logic
This is where the magic happens. Different failure codes = different strategies.
Node 3: Extract Payment Data
// Set Node
{
"payment_intent_id": "{{ $json.id }}",
"customer_id": "{{ $json.customer }}",
"amount": "{{ $json.amount }}",
"failure_code": "{{ $json.last_payment_error.code }}",
"failure_message": "{{ $json.last_payment_error.message }}"
}
Node 4: Categorize Failure (Switch Node)
Here's how I map Stripe failure codes to categories:
// Switch Node routing logic
// Branch 1: Expired Card
if (failureCode === 'expired_card' ||
failureCode === 'card_declined' && message.includes('expired')) {
return 'expired_card';
}
// Branch 2: Insufficient Funds
if (failureCode === 'insufficient_funds' ||
failureCode === 'balance_insufficient') {
return 'insufficient_funds';
}
// Branch 3: Fraud Flag
if (failureCode === 'do_not_honor' ||
failureCode === 'fraudulent' ||
failureCode === 'card_velocity_exceeded') {
return 'fraud_flag';
}
// Branch 4: Other
return 'other';
Node 5: Set Retry Strategy
Each category gets a different strategy:
// Code Node - Set retry parameters based on category
const strategies = {
expired_card: {
max_retries: 1,
delays: [24], // hours
next_retry: new Date(Date.now() + 24 * 60 * 60 * 1000)
},
insufficient_funds: {
max_retries: 3,
delays: [48, 120, 168], // 2 days, 5 days, 7 days
next_retry: new Date(Date.now() + 48 * 60 * 60 * 1000)
},
fraud_flag: {
max_retries: 0,
delays: [],
next_retry: null,
status: 'manual_review'
},
other: {
max_retries: 1,
delays: [72], // 3 days
next_retry: new Date(Date.now() + 72 * 60 * 60 * 1000)
}
};
const category = $input.item.json.category;
const strategy = strategies[category];
return {
json: {
...strategy,
failure_category: category
}
};
Why these timings?
- Expired card (24hrs): Customer needs time to update, but not too long
- Insufficient funds (2, 5, 7 days): Aligned with typical pay cycles (1st and 15th of month)
- Fraud flag (no retry): Could make things worse, needs human review
- Other (3 days): Generic delay for temporary issues
Step 4: Store & Notify
Node 6: Insert into Supabase
// Supabase Node - Insert operation
{
"table": "failed_payments",
"data": {
"stripe_payment_intent_id": "{{ $json.payment_intent_id }}",
"stripe_customer_id": "{{ $json.customer_id }}",
"customer_email": "{{ $json.customer_email }}",
"amount": "{{ $json.amount }}",
"currency": "{{ $json.currency }}",
"failure_code": "{{ $json.failure_code }}",
"failure_message": "{{ $json.failure_message }}",
"failure_category": "{{ $json.failure_category }}",
"max_retries": "{{ $json.max_retries }}",
"next_retry_at": "{{ $json.next_retry }}",
"status": "{{ $json.status || 'pending' }}"
}
}
Node 7: Send Personalized Email
This is crucial. Generic "your payment failed" emails don't work.
Here are the templates I use:
For Expired Card:
Subject: Quick Fix: Update Your Card
Hi {{ name }},
We tried to charge your card ending in {{ last4 }} for ${{ amount }},
but it looks like it's expired.
The fix takes 30 seconds:
👉 [Update Payment Method]
We'll automatically retry in 24 hours once you update it.
Need help? Just reply to this email.
- The {{ company }} Team
For Insufficient Funds:
Subject: Payment Issue - We'll Retry Soon
Hi {{ name }},
Your payment of ${{ amount }} didn't go through due to insufficient funds.
No worries—we get it. We'll automatically try again in 2 days.
Want to retry sooner?
👉 [Retry Now]
Or update your payment method:
👉 [Update Payment Method]
- The {{ company }} Team
For Fraud Flag:
Subject: Security Hold on Your Payment
Hi {{ name }},
Your payment of ${{ amount }} was flagged by our security systems
for your protection.
Please verify this transaction:
👉 [Verify Transaction]
Or contact us directly: [support email]
- The {{ company }} Team
The email node in n8n:
// Email/SendGrid Node
{
"to": "{{ $json.customer_email }}",
"subject": "{{ $json.email_subject }}",
"html": "{{ $json.email_body }}",
"from": "billing@yourcompany.com"
}
Step 5: The Retry Scheduler
Second n8n workflow: "2-Retry-Scheduler"
This runs every hour and attempts retries for payments that are due.
Node 1: Cron Trigger
// Cron: Every hour
0 * * * *
Node 2: Query Supabase for Due Retries
SELECT * FROM failed_payments
WHERE status = 'pending'
AND next_retry_at <= NOW()
AND retry_count < max_retries
ORDER BY amount DESC -- Prioritize high-value payments
LIMIT 50
Node 3: Loop Through & Retry
// HTTP Request Node - Stripe API
{
"method": "POST",
"url": "https://api.stripe.com/v1/payment_intents/{{ $json.stripe_payment_intent_id }}/confirm",
"auth": {
"type": "bearer",
"token": "{{ $env.STRIPE_SECRET_KEY }}"
},
"body": {
"payment_method": "{{ $json.stripe_payment_method_id }}"
}
}
Node 4: Handle Result
// Code Node - Process retry outcome
const wasSuccessful = $json.status === 'succeeded';
if (wasSuccessful) {
// Update as recovered
return {
json: {
status: 'recovered',
recovered_at: new Date(),
action: 'send_success_email'
}
};
} else {
// Check if more retries left
const currentRetry = $input.item.json.retry_count;
const maxRetries = $input.item.json.max_retries;
if (currentRetry + 1 < maxRetries) {
// Schedule next retry
const nextDelay = $input.item.json.delays[currentRetry + 1];
return {
json: {
retry_count: currentRetry + 1,
next_retry_at: new Date(Date.now() + nextDelay * 60 * 60 * 1000),
status: 'pending'
}
};
} else {
// No more retries
return {
json: {
status: 'failed',
action: 'send_final_notice'
}
};
}
}
Node 5: Update Supabase & Notify
// Supabase Update Node
{
"table": "failed_payments",
"filter": {
"id": "{{ $json.id }}"
},
"data": {
"status": "{{ $json.status }}",
"retry_count": "{{ $json.retry_count }}",
"next_retry_at": "{{ $json.next_retry_at }}",
"recovered_at": "{{ $json.recovered_at }}"
}
}
If successful, send:
Subject: Payment Successful! 🎉
Hi {{ name }},
Great news! We successfully processed your payment of ${{ amount }}.
Your account is all set.
Thanks for being a customer!
- The {{ company }} Team
If permanently failed, send:
Subject: Urgent: Payment Action Required
Hi {{ name }},
We've tried multiple times to process your payment of ${{ amount }}
but haven't been successful.
To avoid service interruption, please:
👉 [Update Payment Method]
Or contact us: [support email/phone]
We're here to help!
- The {{ company }} Team
Step 6: Analytics & Monitoring
Third workflow: "3-Daily-Report"
Recovery Dashboard Query
I created a Supabase view for easy analytics:
CREATE VIEW recovery_dashboard AS
SELECT
DATE_TRUNC('day', created_at) as date,
failure_category,
COUNT(*) as total_failures,
COUNT(*) FILTER (WHERE status = 'recovered') as recovered,
COUNT(*) FILTER (WHERE status = 'failed') as permanent_failed,
SUM(amount) FILTER (WHERE status = 'recovered') as amount_recovered,
SUM(amount) FILTER (WHERE status = 'failed') as amount_lost,
ROUND(
(COUNT(*) FILTER (WHERE status = 'recovered')::DECIMAL / COUNT(*)) * 100,
2
) as recovery_rate_percent
FROM failed_payments
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE_TRUNC('day', created_at), failure_category
ORDER BY date DESC;
Daily Email Report
Every morning at 9am, I get this email:
=== Payment Recovery Report - 2024-11-23 ===
OVERALL:
Total Failures: 18
Recovered: 11 (61%)
Lost: 5 (28%)
Still Pending: 2
Amount Recovered: $4,850
Amount Lost: $1,200
BY CATEGORY:
Expired Card:
Failures: 7 | Recovered: 5 (71%) | Lost: 2
Insufficient Funds:
Failures: 8 | Recovered: 5 (63%) | Lost: 2
Fraud Flags:
Failures: 2 | Manual Review: 2
Other:
Failures: 1 | Recovered: 1 (100%)
ACTIONS NEEDED:
- 2 fraud flags awaiting review
- 2 payments awaiting final retry
View Dashboard: [link]
Looking at your n8n workflows, I can see you've built a much more modular system than what I described in the article. Let me update the article to accurately reflect what you actually built:
n8n Architecture Overview
I built this as 5 separate n8n workflows that work together:
┌─────────────────────────────────────────────────────────────┐
│ 1. STRIPE WEBHOOK HANDLER │
│ Receives → Verifies → Routes to Processing │
└────────────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. CATEGORIZATION & STORAGE │
│ Categorizes → Sets Retry Strategy → Stores in Supabase │
└────────────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. CUSTOMER EMAILS │
│ Routes by Category → Sends Personalized Email │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. THE RETRY SCHEDULER (Runs Hourly) │
│ Checks Due Retries → Attempts Payment → Updates Status │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. DAILY REPORT & ALERTS (Runs at 9am) │
│ Calculates Stats → Sends Report → Alerts if Issues │
└─────────────────────────────────────────────────────────────┘
Why separate workflows?
- Easier to debug: Each workflow handles one responsibility
- Better error handling: If one fails, others keep working
- Easier to modify: Can update email templates without touching payment logic
- Cleaner logs: Can see exactly which workflow has issues
Workflow 1: Stripe Webhook Handler
What it does: Receives Stripe webhooks, verifies signatures, and routes to the processing workflow.
Key nodes:
- Webhook - Stripe Payment Failed (POST trigger)
- Verify Signature (Code node - validates webhook is from Stripe)
- Is Payment Intent Failed? (IF node - filters only payment failures)
- Extract Payment Data (Set node)
- Get Customer Details (HTTP Request to Stripe API)
- Merge Data (combines payment + customer info)
- Trigger Processing Workflow (Webhook to workflow #2)
- Respond to Stripe (200 OK)
Critical code - Verify Signature node:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const sig = $input.item.json.headers['stripe-signature'];
const rawBody = $input.item.json.body;
try {
const event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
return { json: event };
} catch (err) {
throw new Error('Invalid signature: ' + err.message);
}
Workflow 2: Categorization & Storage
What it does: Categorizes the failure, sets retry strategy, stores in Supabase, and triggers email.
Key nodes:
- Webhook - Receive Payment Data (receives from workflow #1)
-
Categorize Failure (Switch node with Rules mode)
- Routes:
expired_card,insufficient_funds,fraud_flag,other
- Routes:
-
[Category] Strategy (4 separate Code nodes, one per category)
- Sets:
max_retries,next_retry_at,retry_delays
- Sets:
-
Insert to Supabase (Supabase node - creates row in
failed_payments) - Trigger Email Notification (Webhook to workflow #3)
- Is Fraud Flag? (IF node)
- Alert Team (Slack) (if fraud detected)
- Respond Success
Categorization logic (Switch node):
// Routes based on Stripe failure codes
Routes:
1. expired_card:
- failure_code = 'expired_card'
- OR failure_code = 'card_declined' AND message contains 'expired'
2. insufficient_funds:
- failure_code = 'insufficient_funds'
- OR failure_code = 'balance_insufficient'
3. fraud_flag:
- failure_code = 'do_not_honor'
- OR failure_code = 'fraudulent'
- OR failure_code = 'card_velocity_exceeded'
4. other (fallback):
- Everything else
Strategy examples:
Expired Card Strategy node:
return {
json: {
failure_category: 'expired_card',
max_retries: 1,
retry_delays: [24], // hours
next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
status: 'pending'
}
};
Insufficient Funds Strategy node:
return {
json: {
failure_category: 'insufficient_funds',
max_retries: 3,
retry_delays: [48, 120, 168], // 2, 5, 7 days
next_retry_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
status: 'pending'
}
};
Workflow 3: Customer Emails
What it does: Receives category info and sends the appropriate personalized email.
Key nodes:
- Webhook - Email Trigger (POST from workflow #2)
-
Route by Category (Switch node with Rules mode)
- 4 routes based on
failure_category
- 4 routes based on
- [Category] Email (4 separate Code nodes building email templates)
- Send a message (Gmail node - sends the email)
- Respond Success
Email template examples:
Expired Card Email node:
return {
json: {
to: $json.customer_email,
subject: 'Quick Fix: Update Your Card',
html: `
<p>Hi ${$json.customer_name},</p>
<p>We tried to charge your card ending in ${$json.card_last4} for
$${($json.amount / 100).toFixed(2)}, but it looks like it's expired.</p>
<p>The fix takes 30 seconds:</p>
<p><a href="${$json.update_payment_url}" style="background: #007bff;
color: white; padding: 12px 24px; text-decoration: none;
border-radius: 4px; display: inline-block;">
Update Payment Method
</a></p>
<p>We'll automatically retry in 24 hours once you update it.</p>
<p>Need help? Just reply to this email.</p>
<p>- The ${process.env.COMPANY_NAME} Team</p>
`
}
};
Insufficient Funds Email node:
return {
json: {
to: $json.customer_email,
subject: 'Payment Issue - We\'ll Retry Soon',
html: `
<p>Hi ${$json.customer_name},</p>
<p>Your payment of $${($json.amount / 100).toFixed(2)} didn't go
through due to insufficient funds.</p>
<p>No worries—we get it. We'll automatically try again in 2 days.</p>
<p>Want to retry sooner?</p>
<p><a href="${$json.retry_now_url}">Retry Now</a></p>
<p>Or update your payment method:</p>
<p><a href="${$json.update_payment_url}">Update Payment Method</a></p>
<p>- The ${process.env.COMPANY_NAME} Team</p>
`
}
};
Workflow 4: The Retry Scheduler
What it does: Runs hourly, finds payments due for retry, attempts the retry via Stripe, and handles the result.
This is the most complex workflow with branching logic for success/failure and retry scheduling.
Main flow:
-
Schedule - Every Hour (Cron trigger:
0 * * * *) - Get Payments Due for Retry (Supabase query)
- Loop Through Payments (Split in Batches)
- Attempt Stripe Retry (HTTP Request to Stripe API)
- Retry Successful? (IF node)
Success branch:
6a. Prepare Success Log → Log Success Attempt (Supabase insert to retry_attempts)
7a. Set Recovery Data → Mark as Recovered (Supabase update to failed_payments)
8a. Prepare Success Email → Send Success Email (Gmail)
9a. Update Recovery Stats (Supabase upsert to recovery_stats)
Failure branch:
6b. Prepare Failed Log → Log Failed Attempt (Supabase insert)
7b. More Retries Available? (IF node)
If yes:
8b. Calculate Next Retry → Schedule Next Retry (Supabase update)
If no:
9b. Set Failed Status → Mark as Permanently Failed (Supabase update)
10b. Prepare Final Failure Email → Send Final Failure Email (Gmail)
11b. Update Failed Stats (Supabase upsert)
12b. Alert Team (Slack)
Critical queries:
Get Payments Due for Retry:
SELECT * FROM failed_payments
WHERE status = 'pending'
AND next_retry_at <= NOW()
AND retry_count < max_retries
ORDER BY amount DESC -- Prioritize high-value payments
LIMIT 50
Attempt Stripe Retry (HTTP Request node):
{
"method": "POST",
"url": "https://api.stripe.com/v1/payment_intents/{{$json.stripe_payment_intent_id}}/confirm",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "stripeApi",
"body": {
"payment_method": "{{$json.stripe_payment_method_id}}"
}
}
Calculate Next Retry (Code node):
const currentRetry = $json.retry_count;
const delays = JSON.parse($json.retry_delays); // [48, 120, 168]
const nextDelayHours = delays[currentRetry + 1];
const nextRetryAt = new Date(Date.now() + nextDelayHours * 60 * 60 * 1000);
return {
json: {
retry_count: currentRetry + 1,
next_retry_at: nextRetryAt.toISOString()
}
};
Workflow 5: Daily Report & Alerts
What it does: Runs every morning at 9am, calculates yesterday's stats, sends a report, and alerts if issues detected.
Main flow:
-
Schedule - Daily 9am (Cron:
0 9 * * *) -
Get Yesterday Stats (Supabase query from
recovery_dashboardview) - Calculate Overall Metrics (Code node)
- Get Pending Retries (Supabase query)
- Format Report (Code node)
- Send Report Email (Gmail)
-
Recovery Rate Too Low? (IF node)
- If yes → Alert Low Recovery Rate (Slack)
-
High Amount At Risk? (IF node)
- If yes → Alert High Risk Amount (Slack)
Calculate Overall Metrics (Code node):
const stats = $input.all().map(item => item.json);
const totalFailures = stats.reduce((sum, s) => sum + s.total_failures, 0);
const totalRecovered = stats.reduce((sum, s) => sum + s.total_recovered, 0);
const totalFailed = stats.reduce((sum, s) => sum + s.total_permanent_failed, 0);
const amountRecovered = stats.reduce((sum, s) => sum + s.amount_recovered, 0) / 100;
const amountLost = stats.reduce((sum, s) => sum + s.amount_lost, 0) / 100;
const overallRecoveryRate = totalFailures > 0
? (totalRecovered / totalFailures * 100).toFixed(2)
: 0;
return {
json: {
date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
totalFailures,
totalRecovered,
totalFailed,
amountRecovered,
amountLost,
overallRecoveryRate,
byCategory: stats
}
};
Alert conditions:
- Low Recovery Rate: If overall recovery rate < 20%
- High Amount At Risk: If pending payments total > $10,000
The Complete System in Action
Here's how a failed payment flows through all 5 workflows:
1. Payment fails in Stripe
↓
2. Stripe sends webhook to Workflow #1
↓
3. Workflow #1 validates + triggers Workflow #2
↓
4. Workflow #2 categorizes as "insufficient_funds"
- Sets max_retries: 3
- Sets next_retry_at: 2 days from now
- Stores in Supabase
- Triggers Workflow #3
↓
5. Workflow #3 sends "Insufficient Funds" email
↓
6. 48 hours later, Workflow #4 runs (hourly check)
- Finds this payment is due for retry
- Attempts charge via Stripe
- Payment succeeds!
- Updates status to "recovered"
- Sends success email
- Updates stats
↓
7. Next morning, Workflow #5 runs
- Includes this recovery in daily report
- Shows improved recovery rate
Results After 3 Months
Before automation:
- Recovery rate: 8-12%
- Time spent: 10+ hours/month
- Monthly recovery: ~$1,200
After automation:
- Recovery rate: 28-35% (depending on category)
- Time spent: 30 minutes/month (just reviewing fraud flags)
- Monthly recovery: ~$4,500-5,500
ROI:
- Build time: 12 hours
- Monthly cost: $47 (Supabase + n8n + SendGrid)
- Monthly value: $3,300-4,300 more recovered
- Paid for itself in: Week 1
Key Lessons Learned
1. Timing is Everything
The difference between 24-hour retry and 48-hour retry for insufficient funds: 15 percentage points.
Why? Because 48 hours better aligns with payday cycles.
2. Personalized Communication Matters
Generic "payment failed" emails get 12% open rates.
My category-specific emails get 45-60% open rates.
The key: Tell them exactly what to do ("Update your expired card" vs "Your payment failed").
3. Some Failures Need Human Review
I tried auto-retrying fraud flags. Bad idea.
Our Stripe account got a warning for "excessive declines." Now fraud flags go straight to manual review.
4. Don't Retry Immediately
Stripe's default is to retry failed payments within hours. This doesn't work.
Customers need time to:
- Update their card
- Get paid
- Resolve the issue
My sweet spot: 24-48 hours minimum before first retry.
5. Track Everything
The analytics are crucial for optimization. I discovered:
- Expired card failures have the highest recovery rate (71%)
- Insufficient funds takes an average of 2.3 retries to succeed
- Fraud flags require manual review 100% of the time
This data lets me continuously refine the retry strategies.
What I'd Do Differently
Add SMS notifications for high-value failures (>$500). Email open rates are ~50%, SMS are 98%.
Implement machine learning to predict which failures are most likely to recover. Focus retry efforts there first.
A/B test retry timing more aggressively. I picked these timings based on gut + industry research, but empirical testing would be better.
Build a customer portal where customers can see failed payments and update payment methods themselves (vs relying on Stripe customer portal).
Add churn prediction by analyzing patterns. If someone has 3+ failed payments in a row, they might be churning. Flag for retention team.
The Code
Full n8n workflows + Supabase schema: [GitHub repo link]
You can clone and customize for your business. MIT licensed.
Final Thoughts
This was one of those rare projects where:
- The problem was costing us real money ($10K/month)
- The solution was technical but not complex
- The ROI was immediate and measurable
If you're running a subscription business and not doing smart payment recovery, you're leaving money on the table.
The best part? This whole system runs on autopilot. I check in once a week to review fraud flags and adjust retry strategies based on the data.
Questions? Drop them in the comments. I'm happy to help if you're building something similar.
P.S. — I'm building more financial automation systems like this for my credit card company. Follow me for more breakdowns of what I build.
Tags: #n8n #stripe #automation #fintech #payments #saas #nodejs #webhooks #postgresql





Top comments (0)