<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alexander Storgaard</title>
    <description>The latest articles on DEV Community by Alexander Storgaard (@claritykey).</description>
    <link>https://dev.to/claritykey</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3813511%2Fc8f28aee-7e97-48f2-ac20-ff72348419c8.png</url>
      <title>DEV Community: Alexander Storgaard</title>
      <link>https://dev.to/claritykey</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/claritykey"/>
    <language>en</language>
    <item>
      <title>How we built an automated churn recovery system with Next.js, Stripe Connect, and AI (keepmrr.org)</title>
      <dc:creator>Alexander Storgaard</dc:creator>
      <pubDate>Wed, 25 Mar 2026 14:52:28 +0000</pubDate>
      <link>https://dev.to/claritykey/how-we-built-an-automated-churn-recovery-system-with-nextjs-stripe-connect-and-ai-keepmrrorg-jc7</link>
      <guid>https://dev.to/claritykey/how-we-built-an-automated-churn-recovery-system-with-nextjs-stripe-connect-and-ai-keepmrrorg-jc7</guid>
      <description>&lt;p&gt;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.&lt;br&gt;
The stack: Next.js 15 App Router, Supabase, Stripe Connect, Resend, OpenRouter (for AI), and pg_cron for job scheduling.&lt;/p&gt;

&lt;p&gt;The core problem we're solving&lt;br&gt;
When a customer cancels a Stripe subscription, most SaaS products do nothing. We wanted to automate the entire churn response pipeline:&lt;br&gt;
Customer cancels on Stripe&lt;br&gt;
        ↓&lt;br&gt;
Exit survey email sent automatically&lt;br&gt;
        ↓&lt;br&gt;
Customer fills in survey&lt;br&gt;
        ↓&lt;br&gt;
AI analyses the response&lt;br&gt;
        ↓&lt;br&gt;
Win-back email sequence triggered&lt;br&gt;
        ↓&lt;br&gt;
Customer reactivates&lt;br&gt;
        ↓&lt;br&gt;
Remaining emails cancelled, event marked recovered&lt;br&gt;
The interesting engineering challenges are: multi-tenant Stripe Connect, reliable background job processing without a queue service, and structured AI output for churn analysis.&lt;/p&gt;

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

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

&lt;p&gt;External&lt;br&gt;
  ├── Stripe Connect             — Customer churn detection&lt;br&gt;
  ├── Resend                     — Email sending + webhooks&lt;br&gt;
  └── OpenRouter                 — AI churn analysis&lt;/p&gt;

&lt;p&gt;Multi-tenant with Stripe Connect&lt;br&gt;
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.&lt;br&gt;
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:&lt;br&gt;
javascriptexport async function POST(req) {&lt;br&gt;
  const signature = req.headers.get('stripe-signature')&lt;br&gt;
  const connectedAccountId = req.headers.get('stripe-account')&lt;br&gt;
  const body = await req.text()&lt;/p&gt;

&lt;p&gt;// For connected account events, use the Connect webhook secret&lt;br&gt;
  const webhookSecret = connectedAccountId&lt;br&gt;
    ? process.env.STRIPE_CONNECT_WEBHOOK_SECRET&lt;br&gt;
    : process.env.STRIPE_WEBHOOK_SECRET&lt;/p&gt;

&lt;p&gt;const event = stripe.webhooks.constructEvent(&lt;br&gt;
    body, signature, webhookSecret&lt;br&gt;
  )&lt;/p&gt;

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

&lt;p&gt;switch (event.type) {&lt;br&gt;
    case 'customer.subscription.deleted':&lt;br&gt;
      await handleSubscriptionDeleted(event.data.object, workspace)&lt;br&gt;
      break&lt;br&gt;
    // ...&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
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.&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;for (const job of jobs) {&lt;br&gt;
    try {&lt;br&gt;
      // Mark as processing to prevent double-processing&lt;br&gt;
      await supabase&lt;br&gt;
        .from('email_queue')&lt;br&gt;
        .update({ status: 'processing', attempts: job.attempts + 1 })&lt;br&gt;
        .eq('id', job.id)&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  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)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
}&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;JWT-secured survey pages&lt;br&gt;
The survey link in the cancellation email needs to be:&lt;/p&gt;

&lt;p&gt;Unique per customer&lt;br&gt;
Expiring (we use 48 hours)&lt;br&gt;
Unforgeable (customers shouldn't be able to fill in surveys for other customers)&lt;/p&gt;

&lt;p&gt;We use JWTs signed with a secret for this:&lt;br&gt;
javascriptimport { SignJWT, jwtVerify } from 'jose'&lt;/p&gt;

&lt;p&gt;const secret = new TextEncoder().encode(process.env.SURVEY_JWT_SECRET)&lt;/p&gt;

&lt;p&gt;export async function generateSurveyToken({ &lt;br&gt;
  workspaceId, customerId, churnEventId &lt;br&gt;
}) {&lt;br&gt;
  return new SignJWT({ workspaceId, customerId, churnEventId })&lt;br&gt;
    .setProtectedHeader({ alg: 'HS256' })&lt;br&gt;
    .setExpirationTime('48h')&lt;br&gt;
    .setIssuedAt()&lt;br&gt;
    .sign(secret)&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export async function verifySurveyToken(token) {&lt;br&gt;
  try {&lt;br&gt;
    const { payload } = await jwtVerify(token, secret)&lt;br&gt;
    return payload&lt;br&gt;
  } catch (err) {&lt;br&gt;
    return null&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
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):&lt;br&gt;
javascriptexport default async function SurveyPage({ params }) {&lt;br&gt;
  const { token } = await params  // must await in Next.js 15&lt;br&gt;
  const payload = await verifySurveyToken(token)&lt;/p&gt;

&lt;p&gt;if (!payload) return &lt;/p&gt;

&lt;p&gt;// Fetch workspace config, render survey form&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;AI churn analysis with structured output&lt;br&gt;
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:&lt;br&gt;
javascriptconst prompt = `You are a SaaS churn analysis expert.&lt;/p&gt;

&lt;p&gt;CUSTOMER: ${customer.name}, ${customer.plan_name}, &lt;br&gt;
  $${(customer.mrr/100).toFixed(2)}/mo, &lt;br&gt;
  ${tenureMonths} months as customer&lt;/p&gt;

&lt;p&gt;EXIT SURVEY RESPONSE:&lt;br&gt;
Primary reason: ${churnReason}&lt;br&gt;
Additional: ${JSON.stringify(customAnswers)}&lt;/p&gt;

&lt;p&gt;Respond with ONLY valid JSON, no markdown:&lt;br&gt;
{&lt;br&gt;
  "reason_category": "pricing|feature_gap|competitor|low_usage|unknown",&lt;br&gt;
  "recoverability_score": &amp;lt;1-10&amp;gt;,&lt;br&gt;
  "summary": "&amp;lt;2-3 sentence analysis&amp;gt;",&lt;br&gt;
  "recommended_action": "",&lt;br&gt;
  "immediate_outreach": &lt;br&gt;
}`&lt;/p&gt;

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

&lt;p&gt;const data = await response.json()&lt;br&gt;
const rawText = data.choices?.[0]?.message?.content&lt;/p&gt;

&lt;p&gt;// Strip any accidental markdown code fences&lt;br&gt;
const cleaned = rawText.replace(/&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
/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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>nextjs</category>
      <category>stripe</category>
      <category>saas</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a tool to help people with dyslexic write online. made more simpel</title>
      <dc:creator>Alexander Storgaard</dc:creator>
      <pubDate>Sun, 08 Mar 2026 22:14:40 +0000</pubDate>
      <link>https://dev.to/claritykey/i-built-a-tool-to-help-people-with-dyslexic-write-online-made-more-simpel-3j3j</link>
      <guid>https://dev.to/claritykey/i-built-a-tool-to-help-people-with-dyslexic-write-online-made-more-simpel-3j3j</guid>
      <description>&lt;p&gt;The Story behind ClarityKey.org&lt;br&gt;
I’ve always noticed that for many people especially those navigating dyslexia the digital world isn't always built with them in mind. I saw how much mental energy was being spent on worrying about spelling and grammar, rather than focusing on the actual ideas. I wanted to build a tool that felt less like a "corrector" and more like a quiet partner that gives you back your confidence.&lt;/p&gt;

&lt;p&gt;The Problem&lt;br&gt;
Most writing assistants today are overwhelming. They live in distracting sidebars, they constantly bug you with red underlines while you're still thinking, or they only work inside a web browser. For someone who just wants to write an email, a report, or a message in peace, these "solutions" often become part of the problem.&lt;/p&gt;

&lt;p&gt;What ClarityKey Does&lt;br&gt;
ClarityKey AI is a calm, native Windows assistant that lives in your system tray and stays completely invisible until you need it.&lt;/p&gt;

&lt;p&gt;Zero Distractions: No popups or sidebars.&lt;br&gt;
Works Everywhere: Whether you are in Word, Outlook, Discord, or Notepad, it’s there.&lt;br&gt;
The "Magic" Shortcut: Just highlight any text and press Ctrl + C. ClarityKey instantly provides a clear, corrected version of your text using context aware AI.&lt;br&gt;
Accessibility First: Designed specifically to remove the friction of writing for dyslexic users.&lt;/p&gt;

&lt;p&gt;Check it out here&lt;br&gt;
👉 &lt;a href="https://claritykey.org/" rel="noopener noreferrer"&gt;https://claritykey.org/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We’d Love Your Feedback!&lt;br&gt;
Since we are in the early stages, your feedback is the most valuable thing we have.&lt;/p&gt;

&lt;p&gt;Does the workflow feel natural to you?&lt;br&gt;
Is there an app where you wish it worked differently?&lt;br&gt;
As a writer, what would make your life even easier?&lt;/p&gt;

&lt;p&gt;Please let us know what you think in the comments! ✍️&lt;/p&gt;

&lt;p&gt;(ps. this post was created by using Claritykey.org)&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>saas</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
