DEV Community

Alexander Storgaard
Alexander Storgaard

Posted on

How we built an automated churn recovery system with Next.js, Stripe Connect, and AI (keepmrr.org)

This is a technical breakdown of how we built KeepMRR — an automated churn recovery tool for SaaS founders. I'll cover the architecture, the interesting engineering decisions, and the parts that were harder than expected.
The stack: Next.js 15 App Router, Supabase, Stripe Connect, Resend, OpenRouter (for AI), and pg_cron for job scheduling.

The core problem we're solving
When a customer cancels a Stripe subscription, most SaaS products do nothing. We wanted to automate the entire churn response pipeline:
Customer cancels on Stripe

Exit survey email sent automatically

Customer fills in survey

AI analyses the response

Win-back email sequence triggered

Customer reactivates

Remaining emails cancelled, event marked recovered
The interesting engineering challenges are: multi-tenant Stripe Connect, reliable background job processing without a queue service, and structured AI output for churn analysis.

Architecture overview
Next.js App Router (Vercel)
├── app/api/webhooks/stripe — Stripe Connect events
├── app/api/webhooks/resend — Email open/click tracking
├── app/api/webhooks/billing — KeepMRR's own billing
├── app/api/cron/process-email-queue — pg_cron target
└── app/survey/[token] — Public survey page

Supabase
├── workspaces — Multi-tenant isolation
├── customers — Synced from Stripe
├── churn_events — One per cancellation
├── email_queue — Background job queue
├── survey_responses — Survey submissions
├── winback_sequences — Sequence editor output
└── winback_sends — Per-email tracking

External
├── Stripe Connect — Customer churn detection
├── Resend — Email sending + webhooks
└── OpenRouter — AI churn analysis

Multi-tenant with Stripe Connect
The interesting architectural challenge is that KeepMRR is a platform — each founder connects their own Stripe account, and we need to listen for events across all of them.
Stripe Connect handles this with the stripe-account header on webhook events. When a connected account fires an event, the header identifies which account it came from:
javascriptexport async function POST(req) {
const signature = req.headers.get('stripe-signature')
const connectedAccountId = req.headers.get('stripe-account')
const body = await req.text()

// For connected account events, use the Connect webhook secret
const webhookSecret = connectedAccountId
? process.env.STRIPE_CONNECT_WEBHOOK_SECRET
: process.env.STRIPE_WEBHOOK_SECRET

const event = stripe.webhooks.constructEvent(
body, signature, webhookSecret
)

// Route to correct workspace by stripe_account_id
const { data: workspace } = await adminSupabase
.from('workspaces')
.select('*')
.eq('stripe_account_id', connectedAccountId)
.single()

switch (event.type) {
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object, workspace)
break
// ...
}
}
Every database row has a workspace_id foreign key. All queries filter by workspace. Row Level Security in Supabase enforces this at the database level as a safety net.

Background job processing with pg_cron
We needed emails to send at specific times — 30 minutes after cancellation for the survey, variable delays for win-back steps. The options were:

Vercel cron jobs — free plan only allows once per day
External queue service (BullMQ, Inngest, Trigger.dev) — additional complexity and cost
pg_cron — runs inside Supabase, completely free, calls our API endpoint

We went with pg_cron. The setup is two SQL commands:
sql-- Enable the extensions
create extension if not exists pg_cron;
create extension if not exists pg_net;

-- Call our API every minute
select cron.schedule(
'process-email-queue',
'* * * * *',
$$
select net.http_get(
url := 'https://www.keepmrr.org/api/cron/process-email-queue',
headers := '{"Authorization": "Bearer "}'::jsonb
)
$$
);
The email_queue table stores pending jobs:
sqlcreate table email_queue (
id uuid primary key default gen_random_uuid(),
workspace_id uuid references workspaces(id),
type text, -- 'exit_survey' | 'winback_check' | 'winback_step'
payload jsonb,
send_at timestamptz,
status text default 'pending',
attempts int default 0,
last_error text,
sent_at timestamptz,
resend_message_id text
);
The cron endpoint processes due jobs:
javascriptexport async function GET(req) {
// Verify this is our cron calling
const authHeader = req.headers.get('Authorization')
if (authHeader !== Bearer ${process.env.CRON_SECRET}) {
return new Response('Unauthorized', { status: 401 })
}

// Fetch pending jobs due to run
const { data: jobs } = await supabase
.from('email_queue')
.select('*')
.eq('status', 'pending')
.lte('send_at', new Date().toISOString())
.lt('attempts', 3)
.order('send_at', { ascending: true })
.limit(10)

for (const job of jobs) {
try {
// Mark as processing to prevent double-processing
await supabase
.from('email_queue')
.update({ status: 'processing', attempts: job.attempts + 1 })
.eq('id', job.id)

  if (job.type === 'exit_survey') {
    await processExitSurveyEmail(job)
  } else if (job.type === 'winback_check') {
    await processWinbackCheck(job)
  } else if (job.type === 'winback_step') {
    await processWinbackStep(job)
  }

  await supabase
    .from('email_queue')
    .update({ status: 'sent', sent_at: new Date().toISOString() })
    .eq('id', job.id)

} catch (err) {
  await supabase
    .from('email_queue')
    .update({ status: 'pending', last_error: err.message })
    .eq('id', job.id)
}
Enter fullscreen mode Exit fullscreen mode

}
}
This has been rock solid. pg_cron runs every minute, the send_at timestamp controls when each email fires, and the attempts counter prevents infinite retries on broken jobs.

JWT-secured survey pages
The survey link in the cancellation email needs to be:

Unique per customer
Expiring (we use 48 hours)
Unforgeable (customers shouldn't be able to fill in surveys for other customers)

We use JWTs signed with a secret for this:
javascriptimport { SignJWT, jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.SURVEY_JWT_SECRET)

export async function generateSurveyToken({
workspaceId, customerId, churnEventId
}) {
return new SignJWT({ workspaceId, customerId, churnEventId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('48h')
.setIssuedAt()
.sign(secret)
}

export async function verifySurveyToken(token) {
try {
const { payload } = await jwtVerify(token, secret)
return payload
} catch (err) {
return null
}
}
The survey page at /survey/[token] is a Next.js 15 server component that awaits params (important — params is a Promise in Next.js 15):
javascriptexport default async function SurveyPage({ params }) {
const { token } = await params // must await in Next.js 15
const payload = await verifySurveyToken(token)

if (!payload) return

// Fetch workspace config, render survey form
}

AI churn analysis with structured output
When a customer submits a survey, we want structured data back from the AI — not a paragraph of text. We prompt the model to return JSON only and parse it:
javascriptconst prompt = `You are a SaaS churn analysis expert.

CUSTOMER: ${customer.name}, ${customer.plan_name},
$${(customer.mrr/100).toFixed(2)}/mo,
${tenureMonths} months as customer

EXIT SURVEY RESPONSE:
Primary reason: ${churnReason}
Additional: ${JSON.stringify(customAnswers)}

Respond with ONLY valid JSON, no markdown:
{
"reason_category": "pricing|feature_gap|competitor|low_usage|unknown",
"recoverability_score": <1-10>,
"summary": "<2-3 sentence analysis>",
"recommended_action": "",
"immediate_outreach":
}`

const response = await fetch(
'https://openrouter.ai/api/v1/chat/completions',
{
method: 'POST',
headers: {
'Authorization': Bearer ${process.env.OPENROUTER_API_KEY},
'HTTP-Referer': 'https://www.keepmrr.org',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'anthropic/claude-haiku-4-5',
temperature: 0.3,
max_tokens: 500,
messages: [
{
role: 'system',
content: 'You are a SaaS churn expert. Always respond with valid JSON only.'
},
{ role: 'user', content: prompt }
]
})
}
)

const data = await response.json()
const rawText = data.choices?.[0]?.message?.content

// Strip any accidental markdown code fences
const cleaned = rawText.replace(/


/g, '').trim()
const analysis = JSON.parse(cleaned)
Setting temperature: 0.3 makes the output more deterministic and less likely to hallucinate or add extra text around the JSON.

Email open and click tracking
Resend supports webhooks for email events. We save the Resend message ID when sending and use it to match incoming webhook events:
javascript// When sending a win-back email:
const result = await resend.emails.send({ ... })
const messageId = result?.data?.id

await supabase
  .from('winback_sends')
  .update({ resend_message_id: messageId })
  .eq('id', winbackSendId)
javascript// Resend webhook handler:
export async function POST(req) {
  const body = await req.text()
  // Verify svix signature...

  const event = JSON.parse(body)
  const emailId = event.data?.email_id

  switch (event.type) {
    case 'email.opened':
      await supabase
        .from('winback_sends')
        .update({ opened_at: new Date().toISOString() })
        .eq('resend_message_id', emailId)
      break

    case 'email.clicked':
      await supabase
        .from('winback_sends')
        .update({ clicked_at: new Date().toISOString() })
        .eq('resend_message_id', emailId)
      break

    case 'email.complained':
      // Cancel remaining emails for this customer
      break
  }
}

Things that were harder than expected
Stripe Connect webhook routing. The stripe-account header is only set when the event comes from a connected account. For events on your own account (like billing), the header is null. We have separate webhook endpoints and secrets for each.
Next.js 15 breaking change with params. In Next.js 15, route params became async. params.token returns undefined — you must await params first. This broke our survey pages in a subtle way that showed as "token expired" errors.
pg_cron and pg_net. pg_cron is straightforward but pg_net (needed for HTTP calls) must be enabled separately and installed in the extensions schema, not public. The error message when you get this wrong is not helpful.
Resend message ID location. The Resend SDK returns { data: { id: "xxx" }, error: null } — the ID is at result.data.id, not result.id. This caused silent failures where emails sent successfully but we couldn't track them.
Danish locale formatting. toLocaleString() without a locale argument uses the system locale. On a Danish developer machine 1500 formats as 1.500. Always pass 'en-US' explicitly: toLocaleString('en-US').

What's next
The core pipeline is working. What we're building next:

Mobile responsive design for the dashboard
Rate limiting on public API routes
A public changelog at keepmrr.org/changelog
PostHog for product analytics

If you're building something similar or have questions about any of the implementation details — ask in the comments. I reply to everything.
The product is live at keepmrr.org if you want to see it in action. 28 days free, Stripe Connect takes about two minutes to set up.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)