Use case

SaaS onboarding emails

Welcome, setup, activation, nudge — the four-email sequence every SaaS ships in its first quarter. Idempotent, milestone-aware, opt-out compliant.

An onboarding sequence is half marketing, half transactional. The 'welcome' email is unambiguously transactional (the user just signed up — they expect it). 'Day 7 — still need help?' is unambiguously marketing (they opted in by signing up but you must respect any subsequent opt-out). The patterns below handle both ends cleanly: idempotent scheduling on top of a job queue, milestone gates so you do not send 'finish your setup' to a user who already finished, and one-click unsubscribe on the marketing-side emails.

Anatomy

What every solid implementation has

  • 1User-signs-up event fires immediately; queue a welcome email with idempotency key user-welcome-<user_id>.
  • 2A scheduler enqueues subsequent emails at relative offsets (day 0, day 3, day 7). Idempotency keys per (user_id, milestone) so a re-trigger does not duplicate.
  • 3Each scheduled email checks a milestone gate before sending — "have they completed setup?" — and skips if so.
  • 4Marketing-tagged emails include a one-click List-Unsubscribe header (RFC 8058) and respect the user.marketing_opt_in flag.
  • 5A central send wrapper short-circuits on hard bounces and unsubscribes; the user record holds the source-of-truth flags.
  • 6Webhook events back update which messages landed, opened, clicked — the funnel data product wants.

Implementation

The actual code

  1. Welcome email — fired at signup

    Idempotency key derived from the user id means a signup retry never double-welcomes. This one bypasses marketing_opt_in (transactional — the user just signed up).

    import { Sndr } from '@rkiza/sndr';
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    
    export async function sendWelcome(userId: string) {
      const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
      if (await db.suppression.exists({ address: user.email, kind: 'hard_bounce' })) return;
    
      await sndr.emails.send(
        {
          from: 'hello@yourdomain.com',
          to: user.email,
          subject: `Welcome to Acme, ${user.name.split(' ')[0]}`,
          html: renderWelcomeHtml(user),
          text: renderWelcomeText(user),
          tags: [
            { key: 'kind', value: 'transactional' },
            { key: 'flow', value: 'onboarding' },
            { key: 'step', value: 'welcome' },
          ],
        },
        { idempotencyKey: `user-welcome-${userId}` },
      );
    
      // Enqueue the rest of the sequence relative to signup.
      await queue.scheduleAt('onboarding-day-0', { user_id: userId }, addMinutes(new Date(), 60));
      await queue.scheduleAt('onboarding-day-3', { user_id: userId }, addDays(new Date(), 3));
      await queue.scheduleAt('onboarding-day-7', { user_id: userId }, addDays(new Date(), 7));
    }
  2. Milestone-gated nudge — Day 3

    Check whether the user already hit the milestone before sending. Skipping is silent (no log noise). Honor marketing_opt_in because the nudge is genuinely marketing — the user can opt out and the law / RFCs require we respect that.

    export async function sendDay3Nudge(userId: string) {
      const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
    
      // Respect opt-out from marketing. Transactional sequences bypass
      // this; "still need help getting set up?" is marketing.
      if (!user.marketing_opt_in) return;
      if (await db.suppression.exists({ address: user.email })) return;
    
      // Milestone gate — do not nudge users who already completed setup.
      const setupComplete = await db.workspace.findFirst({
        where: { owner_id: userId, setup_completed: true },
      });
      if (setupComplete) return;
    
      await sndr.emails.send(
        {
          from: 'team@yourdomain.com',
          to: user.email,
          subject: 'Still getting set up?',
          html: renderDay3NudgeHtml(user),
          text: renderDay3NudgeText(user),
          // One-click unsubscribe — Gmail and Yahoo's 2024 sender rules
          // require this for any bulk-style sender. SNDR's outbound MTA
          // also adds List-Unsubscribe-Post automatically; do not duplicate.
          headers: {
            'List-Unsubscribe': `<https://yourdomain.com/u/${user.unsubscribe_token}>, <mailto:unsubscribe@yourdomain.com?subject=${userId}>`,
          },
          tags: [
            { key: 'kind', value: 'marketing' },
            { key: 'flow', value: 'onboarding' },
            { key: 'step', value: 'day-3-nudge' },
          ],
        },
        { idempotencyKey: `onboarding-day-3-${userId}` },
      );
    }

Production considerations

What you have to get right

Welcome is transactional; nudges are marketing

Treat them differently in your code: welcome bypasses marketing opt-out, nudges respect it. Tag every send so analytics + suppression tooling can distinguish.

Milestone gates prevent embarrassment

A 'still need help getting set up?' email sent to a power user who set up two hours ago looks broken. Always check whether the user already hit the milestone before sending the corresponding nudge.

Gmail / Yahoo 2024 sender rules

Any bulk-style send (10,000+ per day from your domain) must publish a List-Unsubscribe-Post header for one-click unsub. SNDR adds it automatically; if you set List-Unsubscribe yourself, leave the Post header to SNDR.

Re-trigger safety

Stable idempotency keys per (user_id, step) mean re-enqueuing the same step (e.g. after a worker crash) sends at most one email. Without this you risk double-welcomes during incidents.

New-domain warmup

Onboarding sequences are the highest-volume use of a new sending domain. Ramp gradually — SNDR handles per-MTA warmup automatically, but seeing inbox rate < 95% in the first week is normal and recovers as reputation builds.

Tagging for analytics

Tag every send with (kind=transactional|marketing, flow=onboarding, step=welcome|day-3|...). The SNDR analytics dashboard then filters by tag so you can see per-step open / click / bounce rates without writing custom queries.

FAQ

How many onboarding emails should I send?

Two to four in the first week is a healthy baseline. Welcome (immediate), Day 1 (a quick tip), Day 3 (milestone nudge if relevant), Day 7 (one final help offer). Beyond that, returns diminish fast and unsubscribe rate climbs.

When should I use templates vs raw HTML?

Templates (in code or in the SNDR template store) for anything sent more than once. Raw HTML for one-off operational sends. Template versioning lets a marketing team iterate copy without engineering deploys.

Should I use a marketing platform alongside SNDR?

For high-touch nurture sequences (10+ emails, branching journeys, lead scoring) a dedicated marketing tool earns its keep. For the typical 3-4 email onboarding sequence shown here, SNDR + a job queue is usually simpler and cheaper.

How do I track whether the sequence drove activation?

Tag sends with (flow=onboarding, step=...); SNDR webhooks report delivered/opened/clicked. Join those events against your activation events in your warehouse — the open-to-activation lift per step is the metric to optimise.

Related

Ship this in production today.

Free tier includes 3,000 emails / month. No card required.

Start building

Your next message starts here.

Connect your domain, create an API key, and send with SNDR.