<?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: Nic Polotnianko 🇺🇦</title>
    <description>The latest articles on DEV Community by Nic Polotnianko 🇺🇦 (@nikpolale).</description>
    <link>https://dev.to/nikpolale</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1093923%2Ffe9bfaae-0417-42b9-9e98-e5fc0a972061.png</url>
      <title>DEV Community: Nic Polotnianko 🇺🇦</title>
      <link>https://dev.to/nikpolale</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nikpolale"/>
    <language>en</language>
    <item>
      <title>The 6 best AI-native email marketing tools in 2026 (and which ones AI agents can actually run)</title>
      <dc:creator>Nic Polotnianko 🇺🇦</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:07:00 +0000</pubDate>
      <link>https://dev.to/nikpolale/the-8-best-ai-native-email-marketing-tools-in-2026-and-which-ones-ai-agents-can-actually-run-hdf</link>
      <guid>https://dev.to/nikpolale/the-8-best-ai-native-email-marketing-tools-in-2026-and-which-ones-ai-agents-can-actually-run-hdf</guid>
      <description>&lt;p&gt;Every email platform has "AI" now. Most of it is a subject-line generator bolted onto a 2015 product. That's useful, but it isn't what changed in the last year.&lt;/p&gt;

&lt;p&gt;What changed is that a few platforms now put AI at the center: you describe a goal, and the tool produces a finished multi-email flow in your brand, with timing and conditions, ready to review. And because these tools expose MCP servers, an AI agent like Claude, ChatGPT or Cursor can build segments, draft campaigns and read results for you.&lt;/p&gt;

&lt;p&gt;This list is about those tools. I scored each one on four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does it produce finished work or fragments?&lt;/strong&gt; Writing one email saves minutes. Generating a reviewed five-email onboarding sequence with timing saves days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the AI use your real data?&lt;/strong&gt; A dunning email that includes the customer's actual update-payment link beats a generic template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can an AI agent operate it?&lt;/strong&gt; Through an MCP server or an app, and can the agent only draft, or also schedule and send (with approval)?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does the AI cost at 10,000 contacts?&lt;/strong&gt; Including the plan it requires, add-ons and credit meters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Features and prices were checked against vendor docs and pricing pages in October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Generates whole flows&lt;/th&gt;
&lt;th&gt;Uses your billing/product data&lt;/th&gt;
&lt;th&gt;Agent access (MCP)&lt;/th&gt;
&lt;th&gt;Sends natively&lt;/th&gt;
&lt;th&gt;~10k contacts / month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequenzy&lt;/td&gt;
&lt;td&gt;Yes, sequences up to 10 emails&lt;/td&gt;
&lt;td&gt;Yes (Stripe, Paddle, product events)&lt;/td&gt;
&lt;td&gt;Yes, 350+ tools, sends with approval&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$19 to $49, AI on every plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Klaviyo&lt;/td&gt;
&lt;td&gt;Yes (Composer, credit-metered)&lt;/td&gt;
&lt;td&gt;Yes (ecommerce orders)&lt;/td&gt;
&lt;td&gt;Drafts only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~$150 + Composer credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer.io&lt;/td&gt;
&lt;td&gt;Yes (AI Agent, beta)&lt;/td&gt;
&lt;td&gt;Yes (your events)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ActiveCampaign&lt;/td&gt;
&lt;td&gt;Yes (AI automation builder)&lt;/td&gt;
&lt;td&gt;CRM data&lt;/td&gt;
&lt;td&gt;Yes, all plans&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~$239&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Braze&lt;/td&gt;
&lt;td&gt;Yes (Operator)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Quote (~$88k/yr median)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripo&lt;/td&gt;
&lt;td&gt;Drafts sequences, can't send&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Medium plan+)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$20 to $125 (design only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Sequenzy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; SaaS and product teams that want AI to write complete, on-brand sequences from real billing and product data, and an agent to run the account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sequenzy.com" rel="noopener noreferrer"&gt;Sequenzy&lt;/a&gt; is built around generative AI and agent control. Connect your website and it extracts your brand (logo, colors, voice) in under a minute. Describe a goal like "a trial conversion sequence for users who haven't connected their data" and it produces a complete sequence: up to 10 emails with subject lines, body copy, timing and conditions. Campaigns work the same way. Everything the AI creates is saved as a draft, and sequences are saved switched off, so nothing reaches a subscriber until you review it.&lt;/p&gt;

&lt;p&gt;What sets it apart is that the AI writes against your real data. Stripe, Paddle, Lemon Squeezy and other billing tools feed the same subscriber profile as your product events, so a generated dunning sequence can include the customer's own update-payment link, and an upgrade sequence can reference real plan names. AI segments turn a sentence like "trial users who signed up last week and never sent a campaign" into filters.&lt;/p&gt;

&lt;p&gt;The agent side is the most complete on this list: the MCP server exposes more than 350 tools, so Claude, ChatGPT, Cursor or Claude Code can build a segment, draft a campaign, start an A/B test and read results. Sends require explicit confirmation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free for 2,500 emails/month with unlimited contacts; paid from $19/mo. All AI features are included on every plan, including free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Generates whole flows, not single emails; AI grounded in billing and product data; transactional and marketing in one; agent-ready with approval steps; no per-contact pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Predictive scores (LTV, churn, next order) only run on ecommerce order data; send-time optimization covers campaigns, not sequences; newer platform with a smaller template library; generated copy still needs a human edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Klaviyo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Ecommerce brands with enough order history to use predictions.&lt;/p&gt;

&lt;p&gt;Klaviyo isn't AI-native by origin, but it has gone furthest of the incumbents. Its predictive analytics (lifetime value, churn risk, next order date) remain the benchmark, and &lt;strong&gt;Composer&lt;/strong&gt;, in public beta since June 2026, builds campaigns, flows and signup forms from a prompt. Its MCP server and Claude/ChatGPT apps can read reporting and create drafts, but not send.&lt;/p&gt;

&lt;p&gt;The catch is data and metering: predictions need at least 500 customers with orders and 180 days of history, and Composer runs on credits ($20/mo for 2,000).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free for 250 active profiles; ~$150/mo at 10,000 profiles, plus Composer credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Best predictive analytics in mid-market email, deep Shopify data, flow-building agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Predictions need order history, AI agent is metered, per-profile pricing climbs fast, built for ecommerce rather than SaaS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Customer.io
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Product and growth teams with event-driven journeys and engineers.&lt;/p&gt;

&lt;p&gt;Customer.io launched its AI Agent (beta) in April 2026: it drafts emails, builds automations, analyzes performance and runs scheduled routines, and works in Slack too. LLM Actions run a prompt inside a journey and save the result to a profile attribute, which is handy for personalization at scale. It has a hosted MCP server, a Claude connector and a ChatGPT plugin, and the AI is included on every plan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; $145/mo at 10,000 profiles on Essentials; extra AI credits $10 per 100k; no free plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; AI that works on your event data, agent included, strong developer tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No predictive scoring, no native billing triggers, technical setup, Premium starts at $1,000/mo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. ActiveCampaign
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; SMB teams that want AI inside a deep automation builder and CRM.&lt;/p&gt;

&lt;p&gt;ActiveCampaign's third-generation AI ("Wavelength", August 2026) drafts campaigns, builds segments, generates automations from a description and flags journeys that stopped converting. It was one of the first marketing platforms with an official MCP server, and agent access works on every plan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; ~$239/mo Plus at 10,000 contacts ($189 annual); predictive sending needs Pro (~$469/mo); 500 AI credits on every plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Mature automation, AI that builds automations, CRM included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Key AI on higher tiers, contact-based pricing, dense interface, transactional is a paid add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Braze
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Enterprises running email, push and in-app across millions of users.&lt;/p&gt;

&lt;p&gt;Braze's &lt;strong&gt;Operator&lt;/strong&gt; (GA September 2026) writes copy, builds complete email designs from a prompt and edits Liquid. Intelligent Timing and Channel pick when and where to message each user, Decisioning Studio uses reinforcement learning to choose offers and frequency, and Agent Console runs your own LLM agents inside journeys. Its official MCP server went GA in September 2026. Salesforce agreed to acquire Braze in October 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Quote only; Vendr reports a median contract around $88,500/year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Most complete AI decisioning stack, cross-channel, bring-your-own-model agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Enterprise price floor, months-long implementation, roadmap uncertainty after the acquisition.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Stripo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Designers who build emails in one tool and send from another.&lt;/p&gt;

&lt;p&gt;Stripo is a design platform, not a sender. Its AI Assistant generates an email or a whole sequence from your brand settings, writes subject lines, preheaders and alt text, and generates images on credits. Its MCP server (Medium plan and up) lets Claude, Cursor or Codex create emails and export HTML to your ESP.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free plan; Basic $20/mo, Medium $45/mo, Pro $125/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Strong AI design, exports to every major ESP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Doesn't send, limited image credits, MCP only from Medium.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SaaS or app with billing and product events:&lt;/strong&gt; Sequenzy. Its AI writes against native Stripe, Paddle and Lemon Squeezy subscription data plus your product events, with agent access on every plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want an AI agent to run your email day to day:&lt;/strong&gt; Sequenzy, or Customer.io if you have engineers to wire up events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecommerce with lots of order history:&lt;/strong&gt; Klaviyo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex event journeys and an engineering team:&lt;/strong&gt; Customer.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMB that wants AI plus a CRM:&lt;/strong&gt; ActiveCampaign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise, multi-channel:&lt;/strong&gt; Braze.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep your ESP, just want AI design:&lt;/strong&gt; Stripo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A note on letting agents send
&lt;/h2&gt;

&lt;p&gt;Every tool above that allows agent sending puts a confirmation step in front of live sends, and you should keep it on. A practical setup: let the agent build segments, draft campaigns and sequences, and report results freely, but require a human click for anything that reaches subscribers. AI-generated copy is good enough to start from, not good enough to ship unread.&lt;/p&gt;

&lt;p&gt;For a broader version of this comparison, including predictive features and cold-email AI, see &lt;a href="https://www.sequenzy.com/blog/best-ai-email-marketing-tools" rel="noopener noreferrer"&gt;the best AI email marketing tools&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>marketing</category>
      <category>saas</category>
      <category>email</category>
    </item>
    <item>
      <title>The 10 best email marketing tools for SaaS in 2026 (compared on billing triggers and price at 10k contacts)</title>
      <dc:creator>Nic Polotnianko 🇺🇦</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:07:00 +0000</pubDate>
      <link>https://dev.to/nikpolale/the-10-best-email-marketing-tools-for-saas-in-2026-compared-on-billing-triggers-and-price-at-10k-18pe</link>
      <guid>https://dev.to/nikpolale/the-10-best-email-marketing-tools-for-saas-in-2026-compared-on-billing-triggers-and-price-at-10k-18pe</guid>
      <description>&lt;p&gt;Most "best email marketing tools" lists are written for ecommerce stores and newsletters. SaaS needs something different. Your most valuable emails are triggered by things that happen in your billing system and your product: a trial ending, a failed payment, a user who signed up but never connected their data. A tool that can't see those events turns every lifecycle email into a custom integration project.&lt;/p&gt;

&lt;p&gt;So this list scores tools on five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Native billing triggers.&lt;/strong&gt; Can it react to Stripe, Paddle, Lemon Squeezy or Chargebee events (trial ending, payment failed, upgraded, churned) without you writing webhook glue?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product events.&lt;/strong&gt; Is there a real events API so you can trigger emails on in-app behavior?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional email.&lt;/strong&gt; Can receipts, invites and password resets go through the same tool, or do you need a second provider?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2B accounts.&lt;/strong&gt; Can it model companies with several users, not just individual contacts?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price at 10,000 contacts.&lt;/strong&gt; Many SaaS lists are full of inactive free-tier signups, and per-contact pricing punishes that.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each entry lists where the tool falls short and when another tool is the better pick. Prices and features were checked against vendor pricing pages and docs in October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Native billing triggers&lt;/th&gt;
&lt;th&gt;Transactional&lt;/th&gt;
&lt;th&gt;B2B accounts&lt;/th&gt;
&lt;th&gt;~10k contacts / month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequenzy&lt;/td&gt;
&lt;td&gt;Billing-led SaaS&lt;/td&gt;
&lt;td&gt;Stripe, Paddle, Lemon Squeezy, Chargebee, Polar + more&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$19 to $49 (billed by emails sent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer.io&lt;/td&gt;
&lt;td&gt;Complex event journeys&lt;/td&gt;
&lt;td&gt;No (webhooks/Pipelines)&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Yes (Objects)&lt;/td&gt;
&lt;td&gt;$145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loops&lt;/td&gt;
&lt;td&gt;Simple, modern SaaS email&lt;/td&gt;
&lt;td&gt;Stripe, Polar&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~$99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Userlist&lt;/td&gt;
&lt;td&gt;Account-based B2B&lt;/td&gt;
&lt;td&gt;Via Zapier&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encharge&lt;/td&gt;
&lt;td&gt;Visual flows, non-technical teams&lt;/td&gt;
&lt;td&gt;Stripe, Chargebee, Paddle, Recurly&lt;/td&gt;
&lt;td&gt;Premium only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$249&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bento&lt;/td&gt;
&lt;td&gt;Indie SaaS on a budget&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Separate package&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~$79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;Developers who want email in code&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Core product&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postmark&lt;/td&gt;
&lt;td&gt;Transactional deliverability&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Core product&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$15 (10k emails)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brevo&lt;/td&gt;
&lt;td&gt;Budget email plus SMS&lt;/td&gt;
&lt;td&gt;Not native&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Yes (CRM)&lt;/td&gt;
&lt;td&gt;~$69&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot&lt;/td&gt;
&lt;td&gt;Teams already on HubSpot CRM&lt;/td&gt;
&lt;td&gt;Stripe data sync&lt;/td&gt;
&lt;td&gt;Add-on&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~$470 (Starter)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Sequenzy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; SaaS teams whose lifecycle email runs on billing and product events.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sequenzy.com" rel="noopener noreferrer"&gt;Sequenzy&lt;/a&gt; is built specifically for software companies. Connect Stripe with OAuth (or Paddle, Lemon Squeezy, Chargebee, Polar, RevenueCat and others with an API key) and every subscription change becomes an event, a tag and an attribute: trial started, trial ending, payment failed, upgraded, downgraded, cancelled, churned, with MRR, lifetime value and plan on each profile. Sequences start from those events without code. A failed-payment event even carries the amount due and Stripe's hosted invoice link, so a dunning email can link straight to the page where the customer pays.&lt;/p&gt;

&lt;p&gt;Product events come in through the API, server SDKs (TypeScript, Python, PHP, Go), a web SDK, mobile SDKs, Segment or PostHog. Organizations give B2B products company-level data, so you can run a sequence once per company or send billing emails only to account owners. Transactional email runs on the same platform and subscriber record.&lt;/p&gt;

&lt;p&gt;AI is built in rather than bolted on: describe a goal and it drafts a complete onboarding, trial or dunning sequence in your brand, saved switched off for review. Its MCP server lets Claude, ChatGPT or Cursor build segments, draft campaigns and read results.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free for 2,500 emails/month with unlimited contacts; paid from $19/mo for 15k emails. Billed by emails sent, so dormant signups cost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Deepest native billing coverage on this list, marketing and transactional in one, B2B accounts, AI sequence generation, per-email pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Newer platform (founded 2025), smaller template library than incumbents, no HubSpot or Salesforce CRM sync, free plan carries Sequenzy branding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Customer.io
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Technical teams with complex, branching event-driven journeys.&lt;/p&gt;

&lt;p&gt;Customer.io has the most flexible workflow engine in mid-market SaaS email. Journeys branch on any event or attribute, wait for conditions and send email, push, SMS or in-app messages. Objects model accounts and workspaces for B2B. An AI Agent (beta since April 2026) drafts emails and builds automations.&lt;/p&gt;

&lt;p&gt;The catch is billing: there's no native Stripe app. You send billing events yourself through Data Pipelines, a webhook recipe or your own code. With engineers, that's flexibility; for a founder who wants dunning working today, it's a project. Startups that raised under $10M can apply for a free year.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Essentials from $100/mo (5,000 profiles), $145/mo at 10,000; Premium from $1,000/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Best-in-class journeys, multi-channel, Objects for B2B, strong API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No native billing triggers, learning curve, big jump to Premium, no free plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Loops
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Founders who want a clean, modern tool with minimal setup.&lt;/p&gt;

&lt;p&gt;Loops is the minimalist favorite of indie and early-stage SaaS. Transactional and marketing email live together, every feature is on every plan, and its native Stripe integration gained subscription, quote and checkout triggers in 2026. Polar is native too, and there's an MCP server and CLI.&lt;/p&gt;

&lt;p&gt;Loops doesn't model accounts, segmentation is simpler than Customer.io's, and billing coverage stops at Stripe and Polar.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free for 1,000 contacts and 4,000 sends; paid from $49/mo; ~$99/mo at 10,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Clean UX, fast setup, generous free tier, transactional included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No account objects, lighter segmentation, no Paddle/Chargebee/Lemon Squeezy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Userlist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS selling to companies with many users.&lt;/p&gt;

&lt;p&gt;Userlist is built around users &lt;em&gt;and&lt;/em&gt; companies, so you can email an account's admin about billing and its active users about features. It handles onboarding, trial and lifecycle flows well and includes transactional email. Billing integrations aren't native: Stripe and Chargebee connect through Zapier or your own events.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Basic $149/mo up to 10,000 users ($119 annual); Professional $349/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Excellent account model, SaaS-focused, transactional included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No native billing triggers, high entry price, no free plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Encharge
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Non-technical teams that want visual flows with billing triggers.&lt;/p&gt;

&lt;p&gt;Encharge's visual flow builder makes branching readable for marketers, and it connects natively to Stripe, Chargebee, Paddle, Recurly and Chargify. The catch is plan gating: custom events, event-based segmentation and transactional email are Premium-only, and most SaaS teams need exactly those.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Growth $249/mo at 10,000 subscribers; Premium $399/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Best visual builder for non-technical teams, broad billing coverage, Companies support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Events and transactional locked to Premium, no free plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Bento
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Indie SaaS that wants native Stripe and events on a budget.&lt;/p&gt;

&lt;p&gt;Bento leans hard on events: Stripe payments and your own product events trigger flows and build segments, and lifetime value syncs to profiles. Pricing is per &lt;em&gt;active&lt;/em&gt; user, which is kind to lists full of dormant free accounts. The interface is busy, and transactional email is now a separately priced package.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; $29/mo for 5,000 active users, then $0.01 per user (~$79 at 10,000).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Native Stripe at indie pricing, real event model, active-user billing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Cluttered UI, transactional billed separately, no account objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Resend
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developer-led SaaS that wants to own email in code.&lt;/p&gt;

&lt;p&gt;Resend has the best developer experience in email: a clean API, TypeScript SDK, React Email templates and fast domain setup. In April 2026 it added event-triggered Automations alongside broadcasts, so it now covers simple lifecycle email too. There are no billing integrations; you forward Stripe webhooks yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Transactional free for 3,000 emails/month; Pro Marketing $80/mo at 10,000 contacts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Excellent DX, React Email, transactional + broadcasts + automations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Automations are new and simple, no billing triggers, no account model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Postmark
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Transactional email that must arrive.&lt;/p&gt;

&lt;p&gt;Postmark is a transactional email service known for inbox placement and speed. Message streams keep broadcasts separate from password resets. There's no automation builder, so it pairs with a lifecycle tool rather than replacing one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Basic $15/mo for 10,000 emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Excellent deliverability and logs, simple pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No marketing automation or segments; you need a second tool for lifecycle email.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Brevo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Budget-conscious teams that also want SMS and a light CRM.&lt;/p&gt;

&lt;p&gt;Brevo bills by emails sent rather than contacts, includes transactional email, and adds SMS, WhatsApp and a simple CRM. SaaS-specific depth is limited: no native billing triggers, and the events API needs the Standard plan or higher.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; ~$69/mo Standard (20,000 emails).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Cheap at scale, transactional included, multi-channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; No native billing triggers, low email tiers cap your effective list size, generic rather than SaaS-focused.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. HubSpot Marketing Hub
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Sales-led SaaS already living in HubSpot CRM.&lt;/p&gt;

&lt;p&gt;If your sales team runs on HubSpot, keeping marketing email next to deals and contacts has real value. But it's expensive for what SaaS lifecycle email needs: Starter limits automation, custom behavioral events are Enterprise-only, and Professional carries a $3,000 onboarding fee.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; ~$470/mo Starter at 10,000 contacts; Professional far higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; CRM and email in one, strong reporting, huge ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Expensive, product events gated to Enterprise, contact-based pricing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe, Paddle or Lemon Squeezy is your source of truth:&lt;/strong&gt; Sequenzy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex product journeys and engineers to wire them:&lt;/strong&gt; Customer.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-revenue and want simple:&lt;/strong&gt; Loops or Sequenzy's free plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2B with many users per account:&lt;/strong&gt; Userlist or Sequenzy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-technical team, visual flows:&lt;/strong&gt; Encharge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tight budget, indie SaaS:&lt;/strong&gt; Bento or Sequenzy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want email in code:&lt;/strong&gt; Resend for everything, or Postmark for transactional plus a lifecycle tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales-led on HubSpot:&lt;/strong&gt; stay on HubSpot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose, set up these four flows first: onboarding (signup → activation), trial ending, failed payment, and cancellation/win-back. They're where SaaS email pays for itself.&lt;/p&gt;

&lt;p&gt;There's a longer, 20-tool version of this comparison, with billing-integration tables and pricing traps per tool, on the Sequenzy blog: &lt;a href="https://www.sequenzy.com/blog/best-email-marketing-tools-for-saas" rel="noopener noreferrer"&gt;20 best email marketing tools for SaaS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>marketing</category>
      <category>email</category>
    </item>
    <item>
      <title>How to send transactional emails from Next.js App Router (Server Actions, route handlers and after())</title>
      <dc:creator>Nic Polotnianko 🇺🇦</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:35:22 +0000</pubDate>
      <link>https://dev.to/nikpolale/how-to-send-transactional-emails-from-nextjs-app-router-server-actions-route-handlers-and-97c</link>
      <guid>https://dev.to/nikpolale/how-to-send-transactional-emails-from-nextjs-app-router-server-actions-route-handlers-and-97c</guid>
      <description>&lt;p&gt;Almost every Next.js app needs to send a few emails: a welcome email after signup, a magic link or password reset, a receipt. The App Router gives you several places to put that code, and they don't behave the same way when something is slow or fails.&lt;/p&gt;

&lt;p&gt;This guide covers where the send should live, how to keep the API key off the client, how to avoid making users wait for your email provider, and the mistakes that lead to missing or duplicate emails. The examples use a generic &lt;code&gt;sendEmail()&lt;/code&gt; helper so you can plug in whichever provider you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keep the send on the server, always
&lt;/h2&gt;

&lt;p&gt;Your email provider's API key can send mail as your domain. If it reaches the browser, anyone can use it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read it from &lt;code&gt;process.env.EMAIL_API_KEY&lt;/code&gt; (no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, which would inline it into client bundles).&lt;/li&gt;
&lt;li&gt;Put the helper in a module that only server code imports, and mark it so a mistaken client import fails the build:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/email.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SendEmailInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendEmailInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.your-email-provider.com/send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Email send failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;server-only&lt;/code&gt; package (&lt;code&gt;npm i server-only&lt;/code&gt;) makes the build fail if a Client Component imports this file.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pick the right entry point
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Where to send from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A form in your own UI (signup, invite a teammate, contact form)&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;Server Action&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An external system calls you (Stripe webhook, auth provider hook, cron)&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;route handler&lt;/strong&gt; (&lt;code&gt;app/api/.../route.ts&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Something that happens later (trial ending in 3 days, weekly digest)&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;queue or scheduled job&lt;/strong&gt;, not a request handler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Server Action: invite a teammate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/team/actions.ts&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sendEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;renderInviteEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/emails/invite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;inviteTeammate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not signed in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter a valid email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;invitedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Respond to the user now; send the email after the response is done.&lt;/span&gt;
  &lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; invited you to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderInviteEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;invite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inviter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`invite-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorization happens inside the action.&lt;/strong&gt; Server Actions are public HTTP endpoints. Anyone can call them with any form data, so check the session and validate input in the action itself, not only in the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;after()&lt;/code&gt;&lt;/strong&gt; (from &lt;code&gt;next/server&lt;/code&gt;, stable since Next.js 15.1) runs the callback once the response has been sent. The user doesn't wait on your email provider's latency, and a slow provider doesn't hold the form in a pending state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Route handler: a webhook from your auth provider
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/auth/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sendEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;renderWelcomeEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/emails/welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyAuthWebhook&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/auth-webhooks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyAuthWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// throws on a bad signature&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Welcome to Acme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`welcome-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook senders retry when you return an error or time out, which is good for reliability and bad for duplicate emails. That's what the &lt;code&gt;idempotencyKey&lt;/code&gt; is for (section 5).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Don't send from Client Components or &lt;code&gt;useEffect&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;It's tempting to fire a "send welcome email" request from the client after signup succeeds. Don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser tab can close before the request goes out.&lt;/li&gt;
&lt;li&gt;React Strict Mode runs effects twice in development, so you'll see double emails locally and might "fix" the wrong thing.&lt;/li&gt;
&lt;li&gt;You need a public endpoint for it, which anyone can call to make you send email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Send from the server code that created the user, or from the webhook your auth provider calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Templates: React Email or plain HTML
&lt;/h2&gt;

&lt;p&gt;Email clients are not browsers. Outlook on Windows still renders with Word's engine, Gmail strips &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks in some contexts, and flexbox and grid are unreliable. Two sane options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React Email&lt;/strong&gt; (&lt;code&gt;@react-email/components&lt;/code&gt; + &lt;code&gt;@react-email/render&lt;/code&gt;): write templates as React components using email-safe primitives (&lt;code&gt;Section&lt;/code&gt;, &lt;code&gt;Row&lt;/code&gt;, &lt;code&gt;Column&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt;) and render them to an HTML string on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider-side templates&lt;/strong&gt;: design the email in your provider's editor and send a template ID plus variables. Non-developers can then edit copy without a deploy.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// emails/welcome.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@react-email/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@react-email/render&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontFamily&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Arial, sans-serif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Container&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;560&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Heading&lt;/span&gt; &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome, &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Heading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Your workspace is ready. The fastest way to get value is to connect your data first.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://app.acme.com/onboarding"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;12px 20px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Finish setup
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Container&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WelcomeEmail&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt; &lt;span class="c1"&gt;// returns a Promise&amp;lt;string&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever you choose, also send a plain-text version if your provider doesn't generate one, and keep the important action in a real link rather than only in an image.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make sends idempotent
&lt;/h2&gt;

&lt;p&gt;Duplicates come from retries: a webhook delivered twice, a user double-submitting a form, a serverless function retried after a timeout. The fix is to give every logical email a stable key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;welcome-${userId}&lt;/code&gt;, &lt;code&gt;invite-${inviteId}&lt;/code&gt;, &lt;code&gt;receipt-${invoiceId}&lt;/code&gt;, &lt;code&gt;password-reset-${tokenId}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then either pass it to your provider as an idempotency key (many APIs accept one and return the original result for a repeated key), or record it in your database with a unique constraint and skip the send when the insert conflicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sentEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`welcome-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;skipDuplicates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already sent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Know what &lt;code&gt;after()&lt;/code&gt; doesn't do
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;after()&lt;/code&gt; is great for "don't make the user wait". It is not a job queue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the send throws, nothing retries it. Log the error, and alert on it for important emails.&lt;/li&gt;
&lt;li&gt;On serverless platforms the work is tied to the lifetime of that invocation. Short sends are fine; long work belongs elsewhere.&lt;/li&gt;
&lt;li&gt;It can't schedule anything for later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For emails that must arrive (password resets, receipts), either retry inside the callback with a short backoff, or enqueue a job (BullMQ, Inngest, QStash, Trigger.dev, your provider's own scheduling) and let the queue retry. For anything time-based (trial reminders, onboarding drips), use a scheduler or an email platform's automation instead of request handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Before you go live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Send from your own domain with SPF, DKIM and DMARC set up, not the provider's shared test domain&lt;/li&gt;
&lt;li&gt;[ ] Separate sending subdomains for transactional and marketing email, if you send both&lt;/li&gt;
&lt;li&gt;[ ] API key only in server env vars, &lt;code&gt;server-only&lt;/code&gt; on the email module&lt;/li&gt;
&lt;li&gt;[ ] Server Actions check the session and validate input&lt;/li&gt;
&lt;li&gt;[ ] Every email has an idempotency key&lt;/li&gt;
&lt;li&gt;[ ] Failures are logged and alerted, not swallowed&lt;/li&gt;
&lt;li&gt;[ ] Test rendering in Gmail, Outlook and Apple Mail (or a rendering preview tool)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using Sequenzy for this
&lt;/h2&gt;

&lt;p&gt;Here is how this looks with &lt;a href="https://www.sequenzy.com" rel="noopener noreferrer"&gt;Sequenzy&lt;/a&gt;, an email platform for SaaS. The &lt;code&gt;sendEmail&lt;/code&gt; helper above becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Sequenzy&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sequenzy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sequenzy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Sequenzy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SEQUENZY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sequenzy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactional&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// template designed in the dashboard&lt;/span&gt;
    &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;firstName&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same account also runs the onboarding and trial sequences from section 6, so time-based emails don't need their own queue. The &lt;a href="https://docs.sequenzy.com/quickstart" rel="noopener noreferrer"&gt;quick start&lt;/a&gt; takes about five minutes. Everything else in this guide applies whichever provider you choose.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>email</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stripe failed payment emails: a dunning flow that recovers revenue (Node.js)</title>
      <dc:creator>Nic Polotnianko 🇺🇦</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:33:14 +0000</pubDate>
      <link>https://dev.to/nikpolale/stripe-failed-payment-emails-a-dunning-flow-that-recovers-revenue-nodejs-10m8</link>
      <guid>https://dev.to/nikpolale/stripe-failed-payment-emails-a-dunning-flow-that-recovers-revenue-nodejs-10m8</guid>
      <description>&lt;p&gt;Failed payments are the quietest kind of churn. Nobody decided to leave: a card expired, a bank flagged a renewal, a corporate card hit its limit. If nobody tells the customer, Stripe retries a few times, gives up, and the subscription ends.&lt;/p&gt;

&lt;p&gt;Stripe will send a basic failed payment email for you (Settings → Billing → Subscriptions and emails). It's worth turning on, but it's one generic email. A short sequence that explains what happened, links straight to a page where the customer can fix it, and stops the moment they pay recovers noticeably more.&lt;/p&gt;

&lt;p&gt;This post walks through building that flow on Stripe webhooks in Node.js: the events you need, the timing, the copy, and the edge cases that make dunning emails go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The events that matter
&lt;/h2&gt;

&lt;p&gt;You need three webhook events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoice.payment_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A charge attempt on an invoice failed. Fires on the first attempt and on every automatic retry.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoice.paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The invoice was paid, either by a retry or by the customer. This is your "stop emailing" signal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;customer.subscription.updated&lt;/code&gt; / &lt;code&gt;customer.subscription.deleted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The subscription moved to &lt;code&gt;past_due&lt;/code&gt;, &lt;code&gt;unpaid&lt;/code&gt; or &lt;code&gt;canceled&lt;/code&gt; after retries ran out.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The invoice object on &lt;code&gt;invoice.payment_failed&lt;/code&gt; carries most of what an email needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt;: the invoice ID. It stays the same across every retry of that invoice, which makes it the right key for "one recovery flow per failed invoice".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attempt_count&lt;/code&gt;: &lt;code&gt;1&lt;/code&gt; on the first failure, then increments with each retry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next_payment_attempt&lt;/code&gt;: a Unix timestamp for the next automatic retry, or &lt;code&gt;null&lt;/code&gt; when Stripe will not retry again.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hosted_invoice_url&lt;/code&gt;: Stripe's hosted page where the customer can pay this invoice with a new card.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;amount_due&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt;: for "Your $49.00 payment didn't go through".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer&lt;/code&gt; and &lt;code&gt;customer_email&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: receive and verify the webhook
&lt;/h2&gt;

&lt;p&gt;Verify the signature against the raw body. Parsing JSON first breaks verification, which is the number one "why does my Stripe webhook fail" issue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Webhook Error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Acknowledge fast, do the work in a queue.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Return 2xx quickly.&lt;/strong&gt; Stripe retries webhooks it considers failed (for up to three days in live mode). If you send emails inline and your email provider is slow, Stripe times out, retries, and you send the same email twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate by &lt;code&gt;event.id&lt;/code&gt;.&lt;/strong&gt; Even with fast responses, Stripe can deliver an event more than once. Store processed event IDs (a unique index is enough) and skip repeats.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 2: decide what to send
&lt;/h2&gt;

&lt;p&gt;Here's a sequence that works for most B2B SaaS:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First failure (&lt;code&gt;attempt_count === 1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;"Your payment didn't go through"&lt;/td&gt;
&lt;td&gt;Calm, factual, one button to update the card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~3 days later, still unpaid&lt;/td&gt;
&lt;td&gt;"Quick reminder: update your card"&lt;/td&gt;
&lt;td&gt;Shorter. Mention what they'd lose (their data, their team's access).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final retry (&lt;code&gt;next_payment_attempt === null&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;"Last chance before your subscription is canceled"&lt;/td&gt;
&lt;td&gt;Clear deadline, same single button.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After cancellation&lt;/td&gt;
&lt;td&gt;"Your subscription was canceled"&lt;/td&gt;
&lt;td&gt;How to reactivate in one click. No guilt.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key detail: &lt;strong&gt;drive the flow off the first failure, not every failure.&lt;/strong&gt; &lt;code&gt;invoice.payment_failed&lt;/code&gt; fires on every retry. If you send an email per event, a customer on Stripe's default Smart Retries schedule (Stripe's recommended default is up to 8 tries within 2 weeks) can get eight near-identical emails. Start one flow when &lt;code&gt;attempt_count === 1&lt;/code&gt;, and use &lt;code&gt;next_payment_attempt === null&lt;/code&gt; to trigger the final notice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleStripeEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.payment_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startDunningFlow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;formatMoney&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;payUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hosted_invoice_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next_payment_attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendFinalNotice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stopDunningFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// keyed by invoice ID&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatMoney&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amountInCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amountInCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Zero-decimal currencies like JPY don't divide by 100. If you bill in them, check the currency before formatting.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;startDunningFlow&lt;/code&gt; should schedule the follow-ups as delayed jobs (BullMQ, Sidekiq, Cloud Tasks, whatever you use) and &lt;strong&gt;re-check the invoice status before each send&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendReminderIfStillUnpaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// paid, voided or uncollectible: stop&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dunning-reminder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The re-check is cheap insurance. Webhooks arrive out of order sometimes, and "we already charged you, please pay" is the fastest way to get a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: where the button should go
&lt;/h2&gt;

&lt;p&gt;Your email needs one button that lets the customer fix the problem in under a minute. You have two good options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hosted_invoice_url&lt;/code&gt;&lt;/strong&gt;: Stripe's hosted invoice page. The customer can pay that exact invoice with a new card, no login needed. Best for the dunning emails themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Stripe customer portal&lt;/strong&gt;: lets the customer replace their default payment method for future invoices too. Portal session URLs are short-lived, so don't paste one into an email. Link to a route in your app that creates a session on click:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/billing/update-card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billingPortal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;return_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com/settings/billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid sending people to a generic "log in to your account" page. Every extra step loses recoveries, and a customer whose card failed is not in the mood to hunt through settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the copy
&lt;/h2&gt;

&lt;p&gt;Dunning emails are transactional in tone even though they feel like marketing. A few things consistently help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lead with the fact, not the apology.&lt;/strong&gt; "Your $49.00 payment for Acme Pro didn't go through" beats "Uh oh! Something went wrong."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what happens next and when.&lt;/strong&gt; "We'll try again on November 4. If it still fails, your workspace will be paused on November 12."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never blame the customer.&lt;/strong&gt; Most failures are expired cards or bank fraud filters. "Your bank declined the charge" is accurate and neutral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One button.&lt;/strong&gt; "Update payment method". Not a menu of links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send from a human-looking address&lt;/strong&gt;, and let replies land in a real inbox. People reply to dunning emails with "I changed jobs, can you invoice my new company?", and those are recoveries too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A first email can be this short:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; Your Acme payment didn't go through&lt;/p&gt;

&lt;p&gt;Hi Sam,&lt;/p&gt;

&lt;p&gt;We tried to charge $49.00 for your Acme Pro plan, but your bank declined it. This usually means the card expired or was replaced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[Update payment method]&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We'll retry automatically on November 4. Nothing changes in your account until then.&lt;/p&gt;

&lt;p&gt;If you have questions, just reply to this email.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Edge cases that cause double (or wrong) emails
&lt;/h2&gt;

&lt;p&gt;These come up in almost every dunning implementation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two invoices fail at once.&lt;/strong&gt; A customer with two subscriptions can have two open invoices. Key the flow by invoice ID, not by customer, or the second failure gets swallowed (or the first payment stops both flows).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer pays between retries.&lt;/strong&gt; Handled by &lt;code&gt;invoice.paid&lt;/code&gt; plus the status re-check before each send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual invoices.&lt;/strong&gt; Invoices with &lt;code&gt;collection_method: "send_invoice"&lt;/code&gt; have no automatic retries, so &lt;code&gt;next_payment_attempt&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; from the start. Decide whether they get the same flow or a different "your invoice is overdue" email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-off invoices.&lt;/strong&gt; Not every failed invoice belongs to a subscription. Check &lt;code&gt;billing_reason&lt;/code&gt; (&lt;code&gt;subscription_create&lt;/code&gt;, &lt;code&gt;subscription_cycle&lt;/code&gt;, &lt;code&gt;subscription_update&lt;/code&gt;) if you only want to chase renewals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API version drift.&lt;/strong&gt; Stripe's &lt;code&gt;2025-03-31&lt;/code&gt; (basil) API moved some invoice fields, for example the subscription reference now lives under &lt;code&gt;invoice.parent.subscription_details&lt;/code&gt;. Pin your webhook endpoint's API version and read fields that exist in it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Measuring it
&lt;/h2&gt;

&lt;p&gt;Track two numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recovery rate&lt;/strong&gt;: failed invoices that end up paid ÷ failed invoices. Split it by "paid by automatic retry" and "paid after an email click". Automatic retries recover some without any email, and you want to know what the emails add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to recovery&lt;/strong&gt;: days from first failure to payment. If most recoveries happen after email 1, your later emails can be gentler; if they cluster at the final notice, the deadline is doing the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not maintain this
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.sequenzy.com" rel="noopener noreferrer"&gt;Sequenzy&lt;/a&gt;, an email platform for SaaS, handles this flow out of the box. You connect Stripe, and failed invoices arrive as a &lt;code&gt;saas.payment_failed&lt;/code&gt; event carrying &lt;code&gt;invoiceId&lt;/code&gt;, &lt;code&gt;attemptCount&lt;/code&gt;, &lt;code&gt;amountDueFormatted&lt;/code&gt;, &lt;code&gt;hostedInvoiceUrl&lt;/code&gt; and &lt;code&gt;willRetry&lt;/code&gt;. A sequence triggered on the first attempt and stopped by the matching &lt;code&gt;saas.purchase&lt;/code&gt; handles the "one flow per invoice, stop on payment" logic above without your own queue. The &lt;a href="https://docs.sequenzy.com/integrations/stripe" rel="noopener noreferrer"&gt;Stripe integration docs&lt;/a&gt; show the setup.&lt;/p&gt;

&lt;p&gt;Whether you build it yourself or not, the pattern is the same: start on the first failure, key by invoice, re-check before every send, and make the button go straight to a page where the customer can pay.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
