Build This Now
Build This Now
What Is Claude Code?Claude Code InstallationClaude Code Native InstallerYour First Claude Code Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/Drip Email Sequences

Drip Email Sequences with Claude Code

Wire a multi-step lifecycle drip as a durable Inngest step function that sleeps between sends, cancels when the user converts, and delivers every email through Resend.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
speedy_devvWritten by speedy_devvPublished Jul 14, 202610 min readHandbook hubWorkflow index

Lifecycle emails are where most side projects leak revenue. A welcome email, a day-3 nudge, a trial-ending push. Wired as a cron job scanning a state table, this is fragile and annoying to reason about. Wired as one durable Inngest step function that sleeps between sends and cancels the instant the user converts, it becomes a single readable file. This guide builds exactly that, with Claude Code writing the code and Resend delivering the mail.


Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Why a Step Function Beats a Cron Loop

The naive drip is a cron job. Every hour you scan a users table, work out who is due for which email, send it, then write back the new state. You own the scheduling logic, the "which email is next" bookkeeping, the idempotency (so a retry does not send twice), and the cancellation logic when someone upgrades early. That is a lot of moving parts for three emails.

An Inngest step function inverts the model. You write the sequence top to bottom as if it runs in one go: send the welcome, wait three days, send the nudge, wait three more, send the trial-ending push. Between each send you call step.sleep, and Inngest stops executing. It stores the run state and re-invokes the function only when the timer fires. Each step.run is checkpointed, so a redeploy or a crash resumes from the last completed step instead of restarting the sequence. And a single cancelOn clause tears down the whole run the moment the user converts.

No state table. No scan loop. No idempotency keys you maintain by hand. The sequence is the code.

What You Are Building

The flow has four events and one long-lived function:

  • A user signs up. You fire a user/signed_up event.
  • The drip function starts. It sends a welcome email through Resend immediately.
  • It sleeps three days, then sends a day-3 nudge.
  • It sleeps three more days, then sends a trial-ending email (one day before a 7-day trial ends).
  • If the user upgrades at any point, you fire a user/converted event and Inngest cancels the run so no further emails go out.

Every email is a React Email template rendered and sent by Resend. Everything runs inside a Next.js 16 route handler that Inngest calls.

Install the Pieces

Start Claude Code in your Next.js 16 project and let it read your AGENTS.md so it gets the Next.js 16 conventions right (async params, proxy.ts, route handlers). Then install the three packages you need.

npm install inngest resend @react-email/components

Add your keys to .env.local. The Inngest keys come from the Inngest dashboard once you connect your app, and the Resend key from the Resend dashboard. During local development you can run without the Inngest keys because the dev server talks to your app directly.

# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
INNGEST_EVENT_KEY=your_inngest_event_key
INNGEST_SIGNING_KEY=signkey-prod-xxxxxxxx

The Typed Inngest Client

Define the client once and give it a schema for the two events you send. Typing the events means inngest.send and the function handler both know the exact shape of event.data, so a typo in a field name fails at compile time instead of silently dropping an email. Ask Claude to generate this and it wires the schema record for you.

// lib/inngest/client.ts
import { EventSchemas, Inngest } from "inngest";

type Events = {
  "user/signed_up": {
    data: {
      userId: string;
      email: string;
      name: string;
    };
  };
  "user/converted": {
    data: {
      userId: string;
    };
  };
};

export const inngest = new Inngest({
  id: "build-this-now",
  schemas: new EventSchemas().fromRecord<Events>(),
});

Both events carry a userId. That shared field is what lets the conversion event cancel the right drip run later.

The Resend Client

Resend needs one line of setup. Keep it in its own module so every email send imports the same configured instance.

// lib/resend.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);

The Email Templates

Each email is a React component from @react-email/components. Resend renders these to HTML at send time, so you get real templating (props, conditionals, shared styles) instead of string concatenation. Here is the welcome email. Note the props are typed, so the drip function cannot send it without a name.

// emails/welcome.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Text,
} from "@react-email/components";

const main = { backgroundColor: "#f6f6f6", fontFamily: "sans-serif" };
const container = { padding: "24px", maxWidth: "480px" };
const button = {
  backgroundColor: "#111827",
  color: "#ffffff",
  padding: "12px 20px",
  borderRadius: "8px",
  textDecoration: "none",
};

export function WelcomeEmail({ name }: { name: string }) {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Heading>Welcome aboard, {name}</Heading>
          <Text>
            You are in. Your first build takes about ten minutes. Open the
            dashboard and describe what you want to ship.
          </Text>
          <Button href="https://buildthisnow.io/dashboard" style={button}>
            Open your dashboard
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

The day-3 nudge follows the same shape. It reminds the user of the value they have not touched yet.

// emails/day-3-nudge.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Text,
} from "@react-email/components";

const main = { backgroundColor: "#f6f6f6", fontFamily: "sans-serif" };
const container = { padding: "24px", maxWidth: "480px" };
const button = {
  backgroundColor: "#111827",
  color: "#ffffff",
  padding: "12px 20px",
  borderRadius: "8px",
  textDecoration: "none",
};

export function Day3NudgeEmail({ name }: { name: string }) {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Heading>Still thinking it over, {name}?</Heading>
          <Text>
            Most people who ship their first feature do it in the first three
            days. Here is a 60-second walkthrough to get you unstuck.
          </Text>
          <Button href="https://buildthisnow.io/quickstart" style={button}>
            Watch the walkthrough
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

The trial-ending email carries the most urgency and a direct link to upgrade.

// emails/trial-ending.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Text,
} from "@react-email/components";

const main = { backgroundColor: "#f6f6f6", fontFamily: "sans-serif" };
const container = { padding: "24px", maxWidth: "480px" };
const button = {
  backgroundColor: "#111827",
  color: "#ffffff",
  padding: "12px 20px",
  borderRadius: "8px",
  textDecoration: "none",
};

export function TrialEndingEmail({ name }: { name: string }) {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Heading>Your trial ends tomorrow, {name}</Heading>
          <Text>
            Keep everything you have built. Upgrade now and nothing pauses. It
            is a one-time payment, no subscription.
          </Text>
          <Button href="https://buildthisnow.io/upgrade" style={button}>
            Keep building
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

The Drip as One Durable Step Function

This is the core of the guide. The function triggers on user/signed_up, sends three emails with sleeps between them, and cancels itself on user/converted. The cancelOn clause uses match: "data.userId", which tells Inngest to compare the userId on the triggering event against the userId on the conversion event. Only the drip belonging to the user who converted gets cancelled.

Each send is wrapped in step.run with a stable id. That id is how Inngest knows a step already completed. If the function replays after a sleep, the welcome send is not repeated because its step is cached. Ask Claude to generate this and it lays out the sleeps and steps in order.

// lib/inngest/functions/onboarding-drip.ts
import { inngest } from "@/lib/inngest/client";
import { resend } from "@/lib/resend";
import { WelcomeEmail } from "@/emails/welcome";
import { Day3NudgeEmail } from "@/emails/day-3-nudge";
import { TrialEndingEmail } from "@/emails/trial-ending";

const FROM = "Speedy <hello@buildthisnow.io>";

export const onboardingDrip = inngest.createFunction(
  {
    id: "onboarding-drip",
    cancelOn: [{ event: "user/converted", match: "data.userId" }],
  },
  { event: "user/signed_up" },
  async ({ event, step }) => {
    const { email, name } = event.data;

    await step.run("send-welcome", async () => {
      await resend.emails.send({
        from: FROM,
        to: email,
        subject: "Welcome aboard",
        react: WelcomeEmail({ name }),
      });
    });

    await step.sleep("wait-3-days", "3d");

    await step.run("send-day-3-nudge", async () => {
      await resend.emails.send({
        from: FROM,
        to: email,
        subject: "A quick nudge",
        react: Day3NudgeEmail({ name }),
      });
    });

    await step.sleep("wait-until-trial-ending", "3d");

    await step.run("send-trial-ending", async () => {
      await resend.emails.send({
        from: FROM,
        to: email,
        subject: "Your trial ends tomorrow",
        react: TrialEndingEmail({ name }),
      });
    });

    return { sequence: "onboarding-drip", email };
  }
);

Read it top to bottom and the whole lifecycle is right there. The two step.sleep calls are where Inngest stops executing entirely. Nothing runs during those six days. When the timer fires, Inngest re-invokes the function and skips straight past the steps it already ran.

Wiring the Next.js 16 Route Handler

Inngest reaches your functions through a single HTTP endpoint. The serve helper from inngest/next exports the route handlers for you. In Next.js 16 this is a standard route handler under app/api, and it exports GET, POST, and PUT. Register every function in the functions array.

// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { onboardingDrip } from "@/lib/inngest/functions/onboarding-drip";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [onboardingDrip],
});

That is the only route you add. Every drip run flows through it.

Firing the Signup Event

The drip starts when you send user/signed_up. Do this wherever a user finishes signing up. A Server Action is the clean place in Next.js 16. Because the client is typed, the data object here has to match the schema exactly or the build fails.

// app/actions/signup.ts
"use server";

import { inngest } from "@/lib/inngest/client";

export async function startOnboarding(
  userId: string,
  email: string,
  name: string
) {
  await inngest.send({
    name: "user/signed_up",
    data: { userId, email, name },
  });
}

Calling startOnboarding returns immediately. It does not wait for any email. It hands the event to Inngest and the drip runs independently of the request that started it.

Cancelling the Drip When the User Converts

The conversion signal comes from Stripe. When the checkout succeeds, your webhook fires user/converted with the same userId the drip is keyed on. Inngest matches it against the in-flight run and cancels between steps. Any email already sent stays sent. Every email still scheduled never goes out.

Here is a Stripe webhook route handler that verifies the signature and sends the conversion event. Keep your Stripe secret and webhook signing secret in the environment.

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { inngest } from "@/lib/inngest/client";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature") as string;

  let stripeEvent: Stripe.Event;
  try {
    stripeEvent = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET as string
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (stripeEvent.type === "checkout.session.completed") {
    const session = stripeEvent.data.object as Stripe.Checkout.Session;
    const userId = session.client_reference_id;

    if (userId) {
      await inngest.send({
        name: "user/converted",
        data: { userId },
      });
    }
  }

  return new Response(null, { status: 200 });
}

The client_reference_id is where you stash the userId when you create the checkout session. That is what threads the whole thing together: sign up with a userId, key the drip on it, and cancel with the same userId at conversion.

Testing It Locally

The Inngest dev server gives you a local dashboard where you can trigger events and watch a run step through its sleeps in real time. Run your Next.js dev server and the Inngest dev server side by side.

npx inngest-cli@latest dev

Open the dev dashboard it prints (usually on port 8288), find the user/signed_up event, and send a test payload. The run appears immediately and you can see the welcome step complete, then the function enters its sleep. In the dev server you can fast-forward the sleeps so you do not wait three real days to see the next send. Send a user/converted event with the same userId and watch the run flip to cancelled between steps.

{
  "name": "user/signed_up",
  "data": {
    "userId": "user_123",
    "email": "you@example.com",
    "name": "Ada"
  }
}

Point Resend at a test inbox or your own address and confirm the three templates render the way you expect.

Quality Gates Before You Ship

Two commands run before every commit. Type check with zero errors, because the typed events and typed email props catch the mistakes that would otherwise ship as a silently broken send.

npx tsc --noEmit

Then a clean production build.

npm run build

Ask Claude to run both after wiring the drip, and to fix anything the type checker flags. A mismatched event field, a missing template prop, or a wrong import path all surface here instead of in your users' inboxes.

Where the Code Kit Fits

You can build all of this by hand with Claude Code on a paid Anthropic plan, and this guide is the honest walkthrough of doing exactly that. The $29 Code Kit (one-time, no subscription) is a harness that sits on top of Claude Code and runs the same work as a pipeline: it plans the feature before writing code, builds it, evaluates the output, tests it against a real browser, and holds every change to the same gates you ran above (zero type errors, zero lint errors, a clean build). For a lifecycle drip that touches events, sleeps, webhooks, and email templates, that plan-build-evaluate-test loop is the difference between a sequence you hope works and one you watched pass every gate.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

On this page

Why a Step Function Beats a Cron Loop
What You Are Building
Install the Pieces
The Typed Inngest Client
The Resend Client
The Email Templates
The Drip as One Durable Step Function
Wiring the Next.js 16 Route Handler
Firing the Signup Event
Cancelling the Drip When the User Converts
Testing It Locally
Quality Gates Before You Ship
Where the Code Kit Fits

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →