Custom Email Sequences in Resend Without Webhook Code

Build custom email sequences in Resend without webhook code: scheduled sends, a cron-driven drip, audiences, and broadcasts — wired into a Next.js SaaS.

· Justin Boggs

A row of old metal mailboxes mounted on a textured wall

Photo by De an Sun on Unsplash

You can build a complete custom email sequence in Resend without writing a single webhook handler. The trick is combining three things Resend already gives you: the scheduledAt parameter for emails up to 72 hours out, a small cron job for anything further, and Broadcasts with Audiences for the one-to-many sends. That's the entire toolbox. No event bus, no queue infrastructure, no signature verification, no third-party automation platform. This post walks through the exact setup I run for Coding Capybaras — a welcome sequence, a day-by-day lifecycle drip, and a newsletter — in a Next.js + Supabase + Stripe app.

TL;DR

  • Use Resend's scheduledAt to pre-schedule sequence emails at signup time — it accepts ISO 8601 timestamps up to 72 hours in advance.
  • For emails beyond 72 hours, a daily cron route that scans your users table and asks "who is due for email N today?" replaces an entire automation platform.
  • Use Audiences + Broadcasts for newsletters: unsubscribes are handled for you via {{{RESEND_UNSUBSCRIBE_URL}}}.
  • Make sends idempotent with a sent-log table so a re-run never double-sends.

Why "no webhook code" is the right constraint

Most lifecycle email tutorials start by telling you to stand up webhook endpoints: listen for checkout.session.completed from Stripe, listen for email.delivered from your email provider, fan events into a queue, then trigger sends. That architecture is correct for a 20-person company. For a solo founder it's four new failure surfaces before the first email goes out — and if you've read my Stripe webhook hell post, you know each endpoint comes with signature verification, replay protection, and dev/prod parity headaches.

The insight that simplified everything for me: a sequence email doesn't need to be triggered — it needs to be due. A welcome email is due 5 minutes after signup. A check-in email is due on day 3. A "here's what Pro adds" email is due on day 10. Due-ness is a function of two things you already have in your database: who the user is and when they signed up. You don't need an event to tell you that; you need a clock.

Resend leans into this model. In August 2024 it shipped the Schedule Email API, which lets you attach a scheduledAt timestamp to any send. The stated examples are exactly our use case: a welcome email 5 minutes after signup, a reminder 24 hours before an event, a digest the next morning at 9am. A week later it added natural-language scheduling for Broadcasts — type "next Monday at 3pm ET" into the dashboard and it resolves the timestamp for you.

So the architecture for a custom sequence collapses to this:

flowchart TD
    A[User signs up] --> B[Schedule welcome emails with scheduledAt, +5 min and +24 h]
    A --> C[(users table, created_at)]
    D[Daily cron route, 9am] --> C
    C --> E{Who is due for email N today?}
    E --> F[Send day-3 check-in]
    E --> G[Send day-14 upgrade nudge]
    F --> H[(email log table for idempotency)]
    G --> H
    I[Weekly newsletter] --> J[Broadcast to Audience, unsubscribes handled by Resend]

Two paths: scheduled-at-signup for the near-term emails, cron-driven for the rest, plus Broadcasts for one-to-many. Let's build each.

Step 1: Schedule the welcome sequence with scheduledAt

The first emails in any sequence land within 72 hours of signup, which means you can schedule all of them in the same server action that creates the user. No cron, no events.

The scheduledAt parameter takes an ISO 8601 timestamp (e.g. 2026-06-10T11:52:01.858Z), and emails can be scheduled up to 72 hours in advance. In a Next.js server action, scheduling the welcome plus a 24-hour check-in looks like this:

import { Resend } from 'resend';

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

export async function scheduleWelcomeSequence(user: { email: string; firstName: string }) {
  const in5Minutes = new Date(Date.now() + 1000 * 60 * 5).toISOString();
  const in24Hours = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();

  // Email 1 — welcome, 5 minutes after signup
  await resend.emails.send({
    from: 'Justin <justin@yourdomain.com>',
    to: [user.email],
    subject: 'Welcome — here is your first step',
    html: welcomeTemplate(user.firstName),
    scheduledAt: in5Minutes,
  });

  // Email 2 — check-in, 24 hours later
  await resend.emails.send({
    from: 'Justin <justin@yourdomain.com>',
    to: [user.email],
    subject: 'Did you get your first deploy out?',
    html: checkinTemplate(user.firstName),
    scheduledAt: in24Hours,
  });
}

Why 5 minutes instead of instant? Two reasons. A short delay reads more human than a millisecond-fast autoresponder. And it gives you a cancellation window: Resend lets you cancel a scheduled email with resend.emails.cancel(id) or reschedule it with resend.emails.update({ id, scheduledAt }). If a user deletes their account 90 seconds after signing up (it happens), you can cancel the welcome instead of emailing a ghost. One caveat from the docs: once an email is canceled, it cannot be rescheduled — cancel is final.

Three limitations to know before you lean on scheduledAt, straight from Resend's announcement: batch emails can't be scheduled, emails sent via SMTP can't be scheduled, and emails with attachments can't be scheduled. None of those bite a standard sequence, but the 72-hour ceiling does — which is what the next step is for.

Step 2: The cron drip for everything past 72 hours

Day 3, day 7, day 14, day 30 — the emails that do the real lifecycle work all live past the scheduledAt horizon. The webhook-free answer is a single cron route that runs daily and asks one question: who became due for which email since the last run?

In the Coding Capybaras boilerplate this is the lifecycle drip in /platform/lib/email/lifecycle.ts, triggered by a Vercel cron hitting an API route every morning. The shape of the logic:

// Simplified from /platform/lib/email/lifecycle.ts
const DRIP_STEPS = [
  { day: 3,  key: 'day3-checkin',  subject: 'The 3 things people wire up first' },
  { day: 7,  key: 'day7-tips',     subject: 'One week in — the shortcuts' },
  { day: 14, key: 'day14-upgrade', subject: 'What Pro actually adds' },
];

export async function runLifecycleDrip() {
  for (const step of DRIP_STEPS) {
    const dueUsers = await getUsersSignedUpExactlyDaysAgo(step.day);

    for (const user of dueUsers) {
      const alreadySent = await emailLogContains(user.id, step.key);
      if (alreadySent) continue; // idempotency gate

      await sendEmail({
        to: user.email,
        subject: step.subject,
        template: step.key,
      });
      await logEmail(user.id, step.key);
    }
  }
}

The two lines that matter most are the idempotency gate and the log write. Cron jobs re-run. Deploys happen mid-run. If your drip isn't idempotent, someone eventually gets the day-7 email twice, and duplicate lifecycle email is the fastest way to teach users to ignore you. The boilerplate keeps a platform_email_log table and checks it before every send — the send and the log row are the unit of work.

A few practical rules I've settled on after running this in production:

The cron should run once a day at a fixed hour, not hourly. Lifecycle email isn't latency-sensitive; a day-3 email arriving at 9am vs 2pm changes nothing, and a daily run makes the "exactly N days ago" window math trivial.

Compute the window in the user's signup timestamp, not calendar days, or your day-3 email drifts depending on signup hour. created_at BETWEEN now() - interval '4 days' AND now() - interval '3 days' plus the sent-log check is robust against both drift and re-runs.

Branch on state, not just time. The day-14 upgrade nudge in my drip checks whether the user already bought Pro and skips them. That's the "custom" part of a custom sequence — a generic automation tool makes you model this in their UI; in your own cron route it's an if statement against your own database. The full sequence design — what each email should say and when — is its own topic, which I covered in lifecycle email for indie SaaS.

Step 3: Audiences and Broadcasts for the one-to-many sends

Sequences handle one-to-one timing. For the newsletter — same content, everyone at once — Resend's answer is Broadcasts paired with Audiences, and this is where you genuinely write zero code.

An Audience is a contact list. You can add contacts via the API at signup (one call in the same server action from Step 1), import a CSV, or let Resend collect them. The killer feature is what you don't build: when you include the {{{RESEND_UNSUBSCRIBE_URL}}} placeholder in a broadcast, Resend injects an unsubscribe link, hosts the unsubscribe flow, and automatically skips unsubscribed contacts on every future broadcast. Unsubscribe handling is legally required and miserable to build well; outsourcing it entirely is worth the price of admission.

Broadcasts themselves come in two flavors:

Dashboard broadcasts. A no-code editor with Markdown support — you can paste from Notion or Google Docs and formatting survives. Personalization tags like {{{contact.first_name|fallback}}} cover the merge-field basics. Scheduling accepts natural language ("tomorrow at 9am", "next Monday at 3pm ET"), and notably, broadcast scheduling is not subject to the 72-hour limit — Resend's scheduling announcement calls out a much more flexible range. You can also send a test email to yourself before committing, and a scheduled broadcast can be canceled back to draft any time before send.

API broadcasts. Since March 2025, the full broadcast lifecycle is exposed programmatically — six endpoints for creating, updating, and sending. Creating one is a single call:

await resend.broadcasts.create({
  audienceId: '78261eea-8f8b-4381-83c6-79fa7120f1cf',
  from: 'Justin <justin@yourdomain.com>',
  subject: 'This week in Coding Capybaras',
  html: 'Hi {{{FIRST_NAME|there}}}, ... {{{RESEND_UNSUBSCRIBE_URL}}}',
});

One gotcha from the docs worth flagging: broadcasts can only be edited and sent from where they were created. An API-created broadcast can be reviewed in the visual editor, but you can't edit it there, and vice versa. Pick one home per broadcast type — I draft newsletters in the dashboard and reserve the API for programmatic sends like changelog digests.

Which mechanism for which email?

The decision collapses to a small table. "Automation platform" here means the Customer.io / Loops / ConvertKit class of tools.

| Email type | Mechanism | Why | | --- | --- | --- | | Welcome (minutes after signup) | scheduledAt at signup | Within 72h window; cancelable; no infra | | Day 1–2 check-in | scheduledAt at signup | Same — schedule the whole near-term batch at once | | Day 3+ drip steps | Daily cron + sent-log | Past the 72h ceiling; branches on your own DB state | | Behavior-triggered (e.g. post-purchase) | Send inline in the server action | You're already in the code path — no webhook needed | | Newsletter / changelog | Broadcast + Audience | Unsubscribe handling is free; no-code editor | | Complex branching journeys (5+ branches, A/B arms) | Automation platform | This is the point where DIY stops being simpler |

That last row is the honest tradeoff. The cron-drip approach wins while your sequences are mostly linear with a few if statements. When you want visual journey builders, multi-arm experiments, and marketing-team self-service, a dedicated platform earns its subscription — I compared the options in Resend vs Postmark vs Mailgun. The mistake is paying that complexity tax on day one, when your entire lifecycle is five emails and a newsletter.

Note the fourth row, because it trips people up: "behavior-triggered" sounds like it requires webhooks, but most behavior in your app happens in your own code. A user upgrades to Pro — that's your server action. They complete onboarding — your server action. You're already standing in the exact line of code where the event happens; calling sendEmail() right there is the trigger. Webhooks are for events that happen on someone else's servers, and even Stripe's checkout events reach your app through the one webhook handler your payments setup already required — no new ones for email.

Deliverability housekeeping for sequences

A sequence multiplies your send volume, so the boring fundamentals matter more, not less.

Send from a verified domain with SPF and DKIM configured — Resend walks you through the DNS records at domain setup, and nothing in this post works well from an unverified domain. Consider a subdomain like mail.yourdomain.com for lifecycle sends so your transactional reputation (receipts, magic links) is insulated from your marketing reputation.

Keep transactional and lifecycle sends distinguishable in your own logs. Every send in the boilerplate goes through the central sendEmail() helper, which writes to platform_email_log — that one table answers "what did we send this user and when?" for both support and the idempotency gate. If you take one structural idea from this post, take that one: a single choke point for sends, a single log table behind it.

And throttle your own enthusiasm. The fact that you can schedule six emails at signup doesn't mean you should. My welcome sequence is two emails in the first 72 hours, three drip steps after, then the newsletter — and the unsubscribe rate told me even that was near the ceiling.

Frequently asked questions

Can Resend send a full drip sequence natively?

Not as a single configured "journey" the way Customer.io does — there's no visual sequence builder for multi-step drips. You compose sequences from scheduledAt (up to 72 hours out), your own cron logic for later steps, and Broadcasts for one-to-many. For linear SaaS lifecycles, that composition is genuinely all you need.

How far in advance can I schedule a Resend email?

Individual emails: up to 72 hours, via the ISO 8601 scheduledAt parameter. Broadcasts have a much more flexible scheduling range and accept natural-language times in the dashboard. For anything past 72 hours on the one-to-one side, use a cron job that sends when due.

Do I need webhooks for behavior-triggered emails?

Mostly no. Behavior inside your app (signup, upgrade, onboarding completion) happens in your own server actions — call your email helper right there. Webhooks only enter the picture for events on external services, and your payment provider's existing webhook handler typically covers those already.

How do I stop a sequence when a user converts?

Branch in the cron. Before sending each drip step, check the user's current state in your database — if the day-14 email pitches Pro and the user already bought Pro, skip and log. This is the main advantage of owning the drip logic: your conditions are SQL against your own tables, not a third-party UI.

What prevents duplicate sends if my cron re-runs?

An idempotency gate: a sent-log table keyed on user + email identifier, checked before every send and written after. Re-runs then become harmless. Without it, any retry, redeploy, or overlapping cron invocation risks double-sending.

Does Resend handle unsubscribes for sequence emails?

For Broadcasts, yes — include {{{RESEND_UNSUBSCRIBE_URL}}} and Resend hosts the flow and skips unsubscribed contacts automatically. For your cron-driven one-to-one sends, you own the preference check: store a marketing_opt_out flag and gate lifecycle sends on it (transactional emails like receipts are exempt and should keep flowing).

Conclusion

Custom email sequences in Resend come down to three webhook-free primitives: scheduledAt for everything within 72 hours of signup, a daily idempotent cron for the drip steps beyond it, and Broadcasts with Audiences for the one-to-many sends. The architecture fits in two files, branches on your own database instead of a third-party journey builder, and hands the genuinely annoying parts — scheduling infrastructure, throttling, unsubscribe compliance — to Resend. Start linear, log every send, and graduate to an automation platform only when your branching genuinely outgrows if statements.

The Resend lifecycle guide in the Coding Capybaras marketplace has the exact prompt to wire this whole setup — drip table, cron route, and sent-log — into a Next.js + Supabase app with Claude Code or Cursor.